pax_global_header00006660000000000000000000000064150475062060014517gustar00rootroot0000000000000052 comment=c527292d1fbd6150769206a2629e6a79bdabebac wiremix-0.7.0/000077500000000000000000000000001504750620600132075ustar00rootroot00000000000000wiremix-0.7.0/.envrc000066400000000000000000000000121504750620600143160ustar00rootroot00000000000000use flake wiremix-0.7.0/.github/000077500000000000000000000000001504750620600145475ustar00rootroot00000000000000wiremix-0.7.0/.github/actions/000077500000000000000000000000001504750620600162075ustar00rootroot00000000000000wiremix-0.7.0/.github/actions/setup-dependencies/000077500000000000000000000000001504750620600217735ustar00rootroot00000000000000wiremix-0.7.0/.github/actions/setup-dependencies/action.yml000066400000000000000000000007421504750620600237760ustar00rootroot00000000000000name: setup-dependencies runs: using: "composite" steps: - name: Disable man-db triggers shell: bash run: echo "set man-db/auto-update false" | sudo debconf-communicate - name: Reconfigure man-db shell: bash run: sudo dpkg-reconfigure man-db - name: Update package list shell: bash run: sudo apt-get update - name: Install dependencies shell: bash run: sudo apt-get install -y libpipewire-0.3-dev pkg-config clang wiremix-0.7.0/.github/workflows/000077500000000000000000000000001504750620600166045ustar00rootroot00000000000000wiremix-0.7.0/.github/workflows/ci.yml000066400000000000000000000041301504750620600177200ustar00rootroot00000000000000name: ci on: pull_request: push: branches: [main] schedule: - cron: '0 0 * * *' permissions: contents: read env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: save-if: ${{ github.ref == 'refs/heads/main' }} - uses: ./.github/actions/setup-dependencies - name: Build wiremix run: cargo build --locked --all-features --all-targets - name: Run tests run: cargo test --locked --all-features --all-targets - name: Run doc tests run: cargo test --locked --all-features --doc nixfmt: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: nixbuild/nix-quick-install-action@v30 - name: Check Nix formatting run: | nix shell nixpkgs#nixfmt-rfc-style -c \ find . -name '*.nix' -exec nixfmt -sw 80 --check {} + rustfmt: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: components: rustfmt - name: Check Rust formatting run: cargo fmt --all --check clippy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: components: clippy - uses: Swatinem/rust-cache@v2 with: save-if: ${{ github.ref == 'refs/heads/main' }} - uses: ./.github/actions/setup-dependencies - name: Run clippy run: cargo clippy --locked --all-features --all-targets -- -D warnings docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: save-if: ${{ github.ref == 'refs/heads/main' }} - uses: ./.github/actions/setup-dependencies - name: Check documentation env: RUSTDOCFLAGS: -D warnings run: cargo doc --locked --no-deps --document-private-items wiremix-0.7.0/.github/workflows/release.yml000066400000000000000000000010321504750620600207430ustar00rootroot00000000000000name: Publish to crates.io on: push: tags: ['v*'] # Triggers when pushing tags starting with 'v' jobs: publish: runs-on: ubuntu-latest environment: release # Optional: for enhanced security permissions: id-token: write # Required for OIDC token exchange steps: - uses: actions/checkout@v4 - uses: rust-lang/crates-io-auth-action@v1 id: auth - uses: ./.github/actions/setup-dependencies - run: cargo publish env: CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} wiremix-0.7.0/.gitignore000066400000000000000000000000231504750620600151720ustar00rootroot00000000000000/target/ /.direnv/ wiremix-0.7.0/CHANGELOG.md000066400000000000000000000037421504750620600150260ustar00rootroot00000000000000# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ## [0.7.0] - 2025-08-14 ### Added - Arbitrary PipeWire object properties can be used in configuration file. ### Fixed - Open dropdowns no longer persist after their underlying object is removed. ## [0.6.2] - 2025-07-14 ### Fixed - Prepend "v" to cargo version string. ## [0.6.1] - 2025-07-14 ### Fixed - Fall back to cargo version string for --version when publishing crate. ## [0.6.0] - 2025-07-14 ### Added - "git describe" information in --version output. - Keybinding help menu. - max_volume_percent option to set the upper range of volume sliders. - enforce_max_volume option to prevent increasing volume above max_volume_percent. ### Changed - Command-line help text wraps to terminal size. ### Fixed - Volume slider rendering when volume slider is full. - Peak capturing after a node's object id is reused. ## [0.5.0] - 2025-06-26 ### Changed - Get control characters from termios for emulating SIGINT/SIGQUIT/EOF. - Add client:application.name and client:application.process.binary tags. ## [0.4.0] - 2025-05-18 ### Changed - Combine bindings for opening a dropdown and choosing a dropdown item. ### Fixed - Fix a problem with ensuring that there is always an object selected. ## [0.3.0] - 2025-05-13 ### Added - Nix package to flake.nix. - Command-line and configuration file option for setting the initial tab. ### Fixed - Fix a discrepancy between wiremix.toml char set and real defaults. ## [0.2.0] - 2025-05-05 ### Added - This CHANGELOG file. - Shift+Tab default keybinding. ### Changed - Enable LTO and set codegen-units to 1. ## [0.1.1] - 2025-04-30 ### Fixed - Fix typos and outdated information in README and wiremix.toml. ## [0.1.0] - 2025-04-24 ### Added - Initial release of wiremix. wiremix-0.7.0/Cargo.lock000066400000000000000000001650541504750620600151270ustar00rootroot00000000000000# This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 3 [[package]] name = "aho-corasick" version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ "memchr", ] [[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android-tzdata" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" [[package]] name = "android_system_properties" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ "libc", ] [[package]] name = "annotate-snippets" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccaf7e9dfbb6ab22c82e473cd1a8a7bd313c19a5b7e40970f3d89ef5a5c9e81e" dependencies = [ "unicode-width 0.1.14", "yansi-term", ] [[package]] name = "anstream" version = "0.6.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" [[package]] name = "anstyle-parse" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" dependencies = [ "windows-sys 0.59.0", ] [[package]] name = "anstyle-wincon" version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" dependencies = [ "anstyle", "windows-sys 0.59.0", ] [[package]] name = "anyhow" version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" [[package]] name = "autocfg" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bindgen" version = "0.69.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" dependencies = [ "annotate-snippets", "bitflags", "cexpr", "clang-sys", "itertools 0.12.1", "lazy_static", "lazycell", "proc-macro2", "quote", "regex", "rustc-hash", "shlex", "syn", ] [[package]] name = "bitflags" version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" dependencies = [ "serde", ] [[package]] name = "bumpalo" version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" [[package]] name = "cassowary" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" [[package]] name = "castaway" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" dependencies = [ "rustversion", ] [[package]] name = "cc" version = "1.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a012a0df96dd6d06ba9a1b29d6402d1a5d77c6befd2566afdc26e10603dc93d7" dependencies = [ "jobserver", "libc", "shlex", ] [[package]] name = "cexpr" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ "nom", ] [[package]] name = "cfg-expr" version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" dependencies = [ "smallvec", "target-lexicon", ] [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "cfg_aliases" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" version = "0.4.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" dependencies = [ "android-tzdata", "iana-time-zone", "num-traits", "serde", "windows-link", ] [[package]] name = "clang-sys" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", "libloading", ] [[package]] name = "clap" version = "4.5.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8eb5e908ef3a6efbe1ed62520fb7287959888c88485abe072543190ecc66783" dependencies = [ "clap_builder", "clap_derive", ] [[package]] name = "clap_builder" version = "4.5.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96b01801b5fc6a0a232407abc821660c9c6d25a1cafc0d4f85f29fb8d9afc121" dependencies = [ "anstream", "anstyle", "clap_lex", "strsim", "terminal_size", ] [[package]] name = "clap_derive" version = "4.5.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54b755194d6389280185988721fffba69495eed5ee9feeee9a599b53db80318c" dependencies = [ "heck", "proc-macro2", "quote", "syn", ] [[package]] name = "clap_lex" version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" [[package]] name = "colorchoice" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" [[package]] name = "compact_str" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" dependencies = [ "castaway", "cfg-if", "itoa", "rustversion", "ryu", "serde", "static_assertions", ] [[package]] name = "convert_case" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" dependencies = [ "unicode-segmentation", ] [[package]] name = "convert_case" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" dependencies = [ "unicode-segmentation", ] [[package]] name = "cookie-factory" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" dependencies = [ "futures", ] [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "crossterm" version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags", "crossterm_winapi", "mio", "parking_lot", "rustix 0.38.43", "signal-hook", "signal-hook-mio", "winapi", ] [[package]] name = "crossterm" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ "bitflags", "crossterm_winapi", "derive_more", "document-features", "futures-core", "mio", "parking_lot", "rustix 1.0.7", "serde", "signal-hook", "signal-hook-mio", "winapi", ] [[package]] name = "crossterm_winapi" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" dependencies = [ "winapi", ] [[package]] name = "darling" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" dependencies = [ "darling_core", "darling_macro", ] [[package]] name = "darling_core" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", "strsim", "syn", ] [[package]] name = "darling_macro" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", "quote", "syn", ] [[package]] name = "deranged" version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", "serde", ] [[package]] name = "derive_builder" version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" dependencies = [ "derive_builder_macro", ] [[package]] name = "derive_builder_core" version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ "darling", "proc-macro2", "quote", "syn", ] [[package]] name = "derive_builder_macro" version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", "syn", ] [[package]] name = "derive_more" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "convert_case 0.7.1", "proc-macro2", "quote", "syn", ] [[package]] name = "displaydoc" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "document-features" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" dependencies = [ "litrs", ] [[package]] name = "either" version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" [[package]] name = "equivalent" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "erased-serde" version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24e2389d65ab4fab27dc2a5de7b191e1f6617d1f1c8855c0dc569c94a4cbb18d" dependencies = [ "serde", "typeid", ] [[package]] name = "errno" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", "windows-sys 0.59.0", ] [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" [[package]] name = "form_urlencoded" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" dependencies = [ "percent-encoding", ] [[package]] name = "futures" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", "futures-executor", "futures-io", "futures-sink", "futures-task", "futures-util", ] [[package]] name = "futures-channel" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", ] [[package]] name = "futures-core" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", "futures-util", ] [[package]] name = "futures-io" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-macro" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "futures-sink" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-timer" version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" [[package]] name = "futures-util" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-channel", "futures-core", "futures-io", "futures-macro", "futures-sink", "futures-task", "memchr", "pin-project-lite", "pin-utils", "slab", ] [[package]] name = "git2" version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2deb07a133b1520dc1a5690e9bd08950108873d7ed5de38dcc74d3b5ebffa110" dependencies = [ "bitflags", "libc", "libgit2-sys", "log", "url", ] [[package]] name = "glob" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "hashbrown" version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" dependencies = [ "allocator-api2", "equivalent", "foldhash", ] [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "iana-time-zone" version = "0.1.61" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", "wasm-bindgen", "windows-core", ] [[package]] name = "iana-time-zone-haiku" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ "cc", ] [[package]] name = "icu_collections" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" dependencies = [ "displaydoc", "potential_utf", "yoke", "zerofrom", "zerovec", ] [[package]] name = "icu_locale_core" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" dependencies = [ "displaydoc", "litemap", "tinystr", "writeable", "zerovec", ] [[package]] name = "icu_normalizer" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" dependencies = [ "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", "zerovec", ] [[package]] name = "icu_normalizer_data" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" [[package]] name = "icu_properties" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" dependencies = [ "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", "potential_utf", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" [[package]] name = "icu_provider" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" dependencies = [ "displaydoc", "icu_locale_core", "stable_deref_trait", "tinystr", "writeable", "yoke", "zerofrom", "zerotrie", "zerovec", ] [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" dependencies = [ "idna_adapter", "smallvec", "utf8_iter", ] [[package]] name = "idna_adapter" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", ] [[package]] name = "indexmap" version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", "serde", ] [[package]] name = "indexmap" version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" dependencies = [ "equivalent", "hashbrown 0.15.2", "serde", ] [[package]] name = "indoc" version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" [[package]] name = "instability" version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf9fed6d91cfb734e7476a06bde8300a1b94e217e1b523b6f0cd1a01998c71d" dependencies = [ "darling", "indoc", "proc-macro2", "quote", "syn", ] [[package]] name = "is_terminal_polyfill" version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "itertools" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" dependencies = [ "either", ] [[package]] name = "itertools" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] [[package]] name = "itertools" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] [[package]] name = "itoa" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" [[package]] name = "jobserver" version = "0.1.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" dependencies = [ "libc", ] [[package]] name = "js-sys" version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" dependencies = [ "once_cell", "wasm-bindgen", ] [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "lazycell" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "libc" version = "0.2.169" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" [[package]] name = "libgit2-sys" version = "0.18.2+1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c42fe03df2bd3c53a3a9c7317ad91d80c81cd1fb0caec8d7cc4cd2bfa10c222" dependencies = [ "cc", "libc", "libz-sys", "pkg-config", ] [[package]] name = "libloading" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" dependencies = [ "cfg-if", "windows-targets", ] [[package]] name = "libspa" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65f3a4b81b2a2d8c7f300643676202debd1b7c929dbf5c9bb89402ea11d19810" dependencies = [ "bitflags", "cc", "convert_case 0.6.0", "cookie-factory", "libc", "libspa-sys", "nix 0.27.1", "nom", "system-deps", ] [[package]] name = "libspa-sys" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf0d9716420364790e85cbb9d3ac2c950bde16a7dd36f3209b7dfdfc4a24d01f" dependencies = [ "bindgen", "cc", "system-deps", ] [[package]] name = "libz-sys" version = "1.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d" dependencies = [ "cc", "libc", "pkg-config", "vcpkg", ] [[package]] name = "linux-raw-sys" version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" [[package]] name = "litemap" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" [[package]] name = "litrs" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" [[package]] name = "lock_api" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" dependencies = [ "autocfg", "scopeguard", ] [[package]] name = "log" version = "0.4.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d6ea2a48c204030ee31a7d7fc72c93294c92fe87ecb1789881c9543516e1a0d" dependencies = [ "value-bag", ] [[package]] name = "lru" version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ "hashbrown 0.15.2", ] [[package]] name = "matchers" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" dependencies = [ "regex-automata 0.1.10", ] [[package]] name = "memchr" version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ "libc", "log", "wasi", "windows-sys 0.52.0", ] [[package]] name = "nix" version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" dependencies = [ "bitflags", "cfg-if", "libc", ] [[package]] name = "nix" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags", "cfg-if", "cfg_aliases", "libc", ] [[package]] name = "nom" version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" dependencies = [ "memchr", "minimal-lexical", ] [[package]] name = "nu-ansi-term" version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" dependencies = [ "overload", "winapi", ] [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "num-traits" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] [[package]] name = "num_threads" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" dependencies = [ "libc", ] [[package]] name = "once_cell" version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "overload" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" [[package]] name = "parking_lot" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" dependencies = [ "lock_api", "parking_lot_core", ] [[package]] name = "parking_lot_core" version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", "windows-targets", ] [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "percent-encoding" version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pin-project-lite" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "pin-utils" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pipewire" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08e645ba5c45109106d56610b3ee60eb13a6f2beb8b74f8dc8186cf261788dda" dependencies = [ "anyhow", "bitflags", "libc", "libspa", "libspa-sys", "nix 0.27.1", "once_cell", "pipewire-sys", "thiserror", ] [[package]] name = "pipewire-sys" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "849e188f90b1dda88fe2bfe1ad31fe5f158af2c98f80fb5d13726c44f3f01112" dependencies = [ "bindgen", "libspa-sys", "system-deps", ] [[package]] name = "pkg-config" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" [[package]] name = "potential_utf" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" dependencies = [ "zerovec", ] [[package]] name = "powerfmt" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "proc-macro2" version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" dependencies = [ "unicode-ident", ] [[package]] name = "quote" version = "1.0.38" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" dependencies = [ "proc-macro2", ] [[package]] name = "ratatui" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ "bitflags", "cassowary", "compact_str", "crossterm 0.28.1", "indoc", "instability", "itertools 0.13.0", "lru", "paste", "serde", "strum 0.26.3", "unicode-segmentation", "unicode-truncate", "unicode-width 0.2.0", ] [[package]] name = "redox_syscall" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" dependencies = [ "bitflags", ] [[package]] name = "regex" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", "regex-automata 0.4.9", "regex-syntax 0.8.5", ] [[package]] name = "regex-automata" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" dependencies = [ "regex-syntax 0.6.29", ] [[package]] name = "regex-automata" version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", "regex-syntax 0.8.5", ] [[package]] name = "regex-syntax" version = "0.6.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "rustc-hash" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustix" version = "0.38.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a78891ee6bf2340288408954ac787aa063d8e8817e9f53abb37c695c6d834ef6" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys 0.4.15", "windows-sys 0.59.0", ] [[package]] name = "rustix" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys 0.9.4", "windows-sys 0.59.0", ] [[package]] name = "rustversion" version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" [[package]] name = "ryu" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" version = "1.0.218" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" version = "1.0.218" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "serde_fmt" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d4ddca14104cd60529e8c7f7ba71a2c8acd8f7f5cfcdc2faf97eeb7c3010a4" dependencies = [ "serde", ] [[package]] name = "serde_json" version = "1.0.137" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "930cfb6e6abf99298aaad7d29abbef7a9999a9a8806a40088f55f0dcec03146b" dependencies = [ "itoa", "memchr", "ryu", "serde", ] [[package]] name = "serde_spanned" version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" dependencies = [ "serde", ] [[package]] name = "serde_with" version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" dependencies = [ "base64", "chrono", "hex", "indexmap 1.9.3", "indexmap 2.7.0", "serde", "serde_derive", "serde_json", "serde_with_macros", "time", ] [[package]] name = "serde_with_macros" version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" dependencies = [ "darling", "proc-macro2", "quote", "syn", ] [[package]] name = "sharded-slab" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" dependencies = [ "lazy_static", ] [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" dependencies = [ "libc", "signal-hook-registry", ] [[package]] name = "signal-hook-mio" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", "mio", "signal-hook", ] [[package]] name = "signal-hook-registry" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" dependencies = [ "libc", ] [[package]] name = "slab" version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" dependencies = [ "autocfg", ] [[package]] name = "smallvec" version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" [[package]] name = "stable_deref_trait" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ "strum_macros 0.26.4", ] [[package]] name = "strum" version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" dependencies = [ "strum_macros 0.27.1", ] [[package]] name = "strum_macros" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ "heck", "proc-macro2", "quote", "rustversion", "syn", ] [[package]] name = "strum_macros" version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" dependencies = [ "heck", "proc-macro2", "quote", "rustversion", "syn", ] [[package]] name = "sval" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6dc0f9830c49db20e73273ffae9b5240f63c42e515af1da1fceefb69fceafd8" [[package]] name = "sval_buffer" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "429922f7ad43c0ef8fd7309e14d750e38899e32eb7e8da656ea169dd28ee212f" dependencies = [ "sval", "sval_ref", ] [[package]] name = "sval_dynamic" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68f16ff5d839396c11a30019b659b0976348f3803db0626f736764c473b50ff4" dependencies = [ "sval", ] [[package]] name = "sval_fmt" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c01c27a80b6151b0557f9ccbe89c11db571dc5f68113690c1e028d7e974bae94" dependencies = [ "itoa", "ryu", "sval", ] [[package]] name = "sval_json" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0deef63c70da622b2a8069d8600cf4b05396459e665862e7bdb290fd6cf3f155" dependencies = [ "itoa", "ryu", "sval", ] [[package]] name = "sval_nested" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a39ce5976ae1feb814c35d290cf7cf8cd4f045782fe1548d6bc32e21f6156e9f" dependencies = [ "sval", "sval_buffer", "sval_ref", ] [[package]] name = "sval_ref" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb7c6ee3751795a728bc9316a092023529ffea1783499afbc5c66f5fabebb1fa" dependencies = [ "sval", ] [[package]] name = "sval_serde" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a5572d0321b68109a343634e3a5d576bf131b82180c6c442dee06349dfc652a" dependencies = [ "serde", "sval", "sval_nested", ] [[package]] name = "syn" version = "2.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46f71c0377baf4ef1cc3e3402ded576dccc315800fbc62dfc7fe04b009773b4a" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "synstructure" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "system-deps" version = "6.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" dependencies = [ "cfg-expr", "heck", "pkg-config", "toml", "version-compare", ] [[package]] name = "target-lexicon" version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "terminal_size" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" dependencies = [ "rustix 1.0.7", "windows-sys 0.59.0", ] [[package]] name = "thiserror" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "thread_local" version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" dependencies = [ "cfg-if", "once_cell", ] [[package]] name = "time" version = "0.3.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" dependencies = [ "deranged", "itoa", "libc", "num-conv", "num_threads", "powerfmt", "serde", "time-core", "time-macros", ] [[package]] name = "time-core" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" dependencies = [ "num-conv", "time-core", ] [[package]] name = "tinystr" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" dependencies = [ "displaydoc", "zerovec", ] [[package]] name = "toml" version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148" dependencies = [ "serde", "serde_spanned", "toml_datetime", "toml_edit", ] [[package]] name = "toml_datetime" version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" dependencies = [ "serde", ] [[package]] name = "toml_edit" version = "0.22.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" dependencies = [ "indexmap 2.7.0", "serde", "serde_spanned", "toml_datetime", "winnow", ] [[package]] name = "tracing" version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "pin-project-lite", "tracing-attributes", "tracing-core", ] [[package]] name = "tracing-attributes" version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "tracing-core" version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ "once_cell", "valuable", ] [[package]] name = "tracing-error" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" dependencies = [ "tracing", "tracing-subscriber", ] [[package]] name = "tracing-log" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" dependencies = [ "log", "once_cell", "tracing-core", ] [[package]] name = "tracing-subscriber" version = "0.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" dependencies = [ "matchers", "nu-ansi-term", "once_cell", "regex", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", ] [[package]] name = "typeid" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e13db2e0ccd5e14a544e8a246ba2312cd25223f616442d7f2cb0e3db614236e" [[package]] name = "unicode-ident" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" [[package]] name = "unicode-segmentation" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-truncate" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ "itertools 0.13.0", "unicode-segmentation", "unicode-width 0.1.14", ] [[package]] name = "unicode-width" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "url" version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" dependencies = [ "form_urlencoded", "idna", "percent-encoding", ] [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "valuable" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" [[package]] name = "value-bag" version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ef4c4aa54d5d05a279399bfa921ec387b7aba77caf7a682ae8d86785b8fdad2" dependencies = [ "value-bag-serde1", "value-bag-sval2", ] [[package]] name = "value-bag-serde1" version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4bb773bd36fd59c7ca6e336c94454d9c66386416734817927ac93d81cb3c5b0b" dependencies = [ "erased-serde", "serde", "serde_fmt", ] [[package]] name = "value-bag-sval2" version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53a916a702cac43a88694c97657d449775667bcd14b70419441d05b7fea4a83a" dependencies = [ "sval", "sval_buffer", "sval_dynamic", "sval_fmt", "sval_json", "sval_ref", "sval_serde", ] [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "vergen" version = "9.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b2bf58be11fc9414104c6d3a2e464163db5ef74b12296bda593cac37b6e4777" dependencies = [ "anyhow", "derive_builder", "rustversion", "vergen-lib", ] [[package]] name = "vergen-git2" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f6ee511ec45098eabade8a0750e76eec671e7fb2d9360c563911336bea9cac1" dependencies = [ "anyhow", "derive_builder", "git2", "rustversion", "time", "vergen", "vergen-lib", ] [[package]] name = "vergen-lib" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b07e6010c0f3e59fcb164e0163834597da68d1f864e2b8ca49f74de01e9c166" dependencies = [ "anyhow", "derive_builder", "rustversion", ] [[package]] name = "version-compare" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" dependencies = [ "bumpalo", "log", "proc-macro2", "quote", "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" dependencies = [ "quote", "wasm-bindgen-macro-support", ] [[package]] name = "wasm-bindgen-macro-support" version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", "syn", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" dependencies = [ "unicode-ident", ] [[package]] name = "winapi" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" dependencies = [ "winapi-i686-pc-windows-gnu", "winapi-x86_64-pc-windows-gnu", ] [[package]] name = "winapi-i686-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-core" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ "windows-targets", ] [[package]] name = "windows-link" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ "windows-targets", ] [[package]] name = "windows-sys" version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ "windows-targets", ] [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ "windows_aarch64_gnullvm", "windows_aarch64_msvc", "windows_i686_gnu", "windows_i686_gnullvm", "windows_i686_msvc", "windows_x86_64_gnu", "windows_x86_64_gnullvm", "windows_x86_64_msvc", ] [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7f4ea97f6f78012141bcdb6a216b2609f0979ada50b20ca5b52dde2eac2bb1" dependencies = [ "memchr", ] [[package]] name = "wiremix" version = "0.7.0" dependencies = [ "anyhow", "clap", "crossterm 0.29.0", "futures", "futures-timer", "itertools 0.14.0", "libspa", "libspa-sys", "log", "nix 0.29.0", "paste", "pipewire", "ratatui", "regex", "scopeguard", "serde", "serde_json", "serde_with", "smallvec", "strum 0.27.1", "toml", "tracing", "tracing-error", "tracing-subscriber", "vergen-git2", ] [[package]] name = "writeable" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" [[package]] name = "yansi-term" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe5c30ade05e61656247b2e334a031dfd0cc466fadef865bdcdea8d537951bf1" dependencies = [ "winapi", ] [[package]] name = "yoke" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" dependencies = [ "serde", "stable_deref_trait", "yoke-derive", "zerofrom", ] [[package]] name = "yoke-derive" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", "syn", "synstructure", ] [[package]] name = "zerofrom" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", "syn", "synstructure", ] [[package]] name = "zerotrie" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" dependencies = [ "displaydoc", "yoke", "zerofrom", ] [[package]] name = "zerovec" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" dependencies = [ "yoke", "zerofrom", "zerovec-derive", ] [[package]] name = "zerovec-derive" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", "syn", ] wiremix-0.7.0/Cargo.toml000066400000000000000000000030571504750620600151440ustar00rootroot00000000000000[package] name = "wiremix" version = "0.7.0" authors = ["Thomas Sowell "] description = "A TUI mixer for PipeWire" readme = "README.md" repository = "https://github.com/tsowell/wiremix" license = "MIT OR Apache-2.0" categories = ["command-line-utilities", "multimedia::audio"] keywords = ["mixer", "pipewire", "volume", "audio", "tui"] edition = "2021" rust-version = "1.74.1" include = ["src/**/*", "Cargo.toml", "LICENSE*", "README.md", "wiremix.toml"] build = "build.rs" [build-dependencies] vergen-git2 = "1.0.1" [dependencies] anyhow = "1.0.95" clap = { version = "4.5.26", features = ["derive", "wrap_help"] } crossterm = { version = "0.29.0", features = ["event-stream", "serde"] } futures = "0.3.31" futures-timer = "3.0.3" itertools = "0.14.0" libspa = "0.8.0" libspa-sys = "0.8.0" log = "0.4.24" nix = { version = "0.29.0", features = ["event", "term"] } pipewire = { version = "0.8.0", features = ["v0_3_44"] } ratatui = { version = "0.29.0", features = ["serde"] } regex = "1.11.1" scopeguard = "1.2.0" serde = { version = "1.0.218", features = ["derive"] } serde_json = "1.0.137" serde_with = "3.12.0" smallvec = "1.14.0" toml = "0.8.20" tracing = { version = "0.1.41", optional = true } tracing-error = { version = "0.2.1", optional = true } tracing-subscriber = { version = "0.3.19", features = ["env-filter"], optional = true } [dev-dependencies] paste = "1.0.15" strum = { version = "0.27.1", features = ["derive"] } [features] trace = ["dep:tracing", "dep:tracing-error", "dep:tracing-subscriber"] [profile.release] codegen-units = 1 lto = true wiremix-0.7.0/LICENSE-APACHE000066400000000000000000000227731504750620600151460ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS wiremix-0.7.0/LICENSE-MIT000066400000000000000000000017771504750620600146570ustar00rootroot00000000000000Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. wiremix-0.7.0/README.md000066400000000000000000000231401504750620600144660ustar00rootroot00000000000000# wiremix wiremix is a simple TUI audio mixer for PipeWire. You can use it to adjust volumes, route audio between devices and applications, and configure audio device settings like input/output ports and profiles. wiremix's interface is more or less a clone of the wonderful [ncpamixer](https://github.com/fulhax/ncpamixer) which was itself inspired by pavucontrol, so users of either should find it familiar. Issues and pull requests are welcome! ## Installation ### Package Managers * Arch Linux: Install the [official package](https://archlinux.org/packages/extra/x86_64/wiremix/) via `pacman -S wiremix` or `paru -S wiremix-git` for the latest development version from the [AUR](https://aur.archlinux.org/packages/wiremix-git). * Nix: `nix run nixpkgs#wiremix` or add `wiremix` to your configuration. * Gentoo: Install the [official package](https://packages.gentoo.org/packages/media-sound/wiremix) via `emerge -av wiremix`. ### Manual Installation wiremix depends on Rust and the PipeWire libraries. To install all dependencies: * Ubuntu: `sudo apt install cargo libpipewire-0.3-dev pkg-config clang` * Debian: `sudo apt install libpipewire-0.3-dev pkg-config clang` (you will also need to install a somewhat recent Rust toolchain - rustup is one way) * Fedora: `sudo dnf install cargo pipewire-devel clang` Then install wiremix with `cargo install wiremix` ## Quick Start 1. Run `wiremix` to launch with default settings 2. Use mouse and keyboard bindings to operate the mixer - ? to display keyboard bindings - Arrow keys or hjkl to navigate and adjust volume - Tab or HL to change tabs - c to open a dropdown to route audio to a different destination - m to mute/unmute - d set an input or output device as the default source/sink ## Command-line Options ``` PipeWire mixer Usage: wiremix [OPTIONS] Options: -c, --config Override default config file path -r, --remote The name of the remote to connect to -f, --fps Target frames per second (or 0 for unlimited) -s, --char-set Character set to use [built-in sets: default, compat, extracompat] -t, --theme Theme to use [built-in themes: default, nocolor, plain] -p, --peaks Audio peak meters [possible values: off, mono, auto] --no-mouse Disable mouse support --mouse Enable mouse support -v, --tab Initial tab view [possible values: playback, recording, output, input, configuration] -m, --max-volume-percent Maximum volume for volume sliders --no-enforce-max-volume Allow increasing volume past max-volume-percent --enforce-max-volume Prevent increasing volume past max-volume-percent -h, --help Print help -V, --version Print version ``` Command-line options override corresponding settings in the configuration file. ## Input Bindings Everything except quitting can also be done with the mouse. Some of the less-intuitive mouse controls are: * Click the numeric volume percentage to toggle muting. * Scroll through lists and dropdowns with the mouse wheel or click on scroll buttons (default appearence: `•••`) * Right-click to set as the default source/sink ### Default Keyboard Bindings | Input | Action | | ------------- | ----------------------- | | q | Quit | | m | Toggle mute | | d | Set default source/sink | | l/Right arrow | Increment volume | | h/Left arrow | Decrement volume | | Enter/c | Open dropdown or choose | | Esc | Cancel dropdown | | j/Down arrow | Move down | | k/Up arrow | Move up | | H/Shift+Tab | Select previous tab | | L/Tab | Select next tab | | ` (Backtick) | Set volume 0% | | 1 | Set volume 10% | | 2 | Set volume 20% | | 3 | Set volume 30% | | 4 | Set volume 40% | | 5 | Set volume 50% | | 6 | Set volume 60% | | 7 | Set volume 70% | | 8 | Set volume 80% | | 9 | Set volume 90% | | 0 | Set volume 100% | | ? | Toggle help screen | ## Configuration wiremix can be configured through a TOML configuration file. It searches for the configuration file in these locations (in order of precedence): 1. Path specified on the command-line via `-c`/`--config` 2. `$XDG_CONFIG_HOME/wiremix/wiremix.toml` 3. `~/.config/wiremix/wiremix.toml` This README only describes basic capabilities. Please see [wiremix.toml](./wiremix.toml) in this repository for detailed documentation on configuring wiremix. It also provides a reference for all of wiremix's defaults. The configuration specified in the file is merged with wiremix's defaults, so it only needs to specify the options that need to be changed. It is recommended to start with an empty configuration file and use this repository's [wiremix.toml](./wiremix.toml) as a reference. ### Basic Configuration Everything that can specified on the command-line has a corresponding option in the configuration file. ```toml #remote = "pipewire-0" #fps = 60.0 mouse = true peaks = "auto" char_set = "default" theme = "default" tab = "playback" ``` ### Keybindings The configuration file can customize keyboard controls for all wiremix actions. See [wiremix.toml](./wiremix.toml) for more details. #### Examples ```toml keybindings = [ # Use ncpamixer-style absolute volume bindings { key = { Char = "`" }, action = "Nothing" }, { key = { Char = "0" }, action = { SetAbsoluteVolume = 0.0 } }, # Chars 1-9 already work like ncpamixer ] ``` ```toml keybindings = [ # Use F-keys to select tabs { key = { F = 1 }, action = { SelectTab = 0 } }, { key = { F = 2 }, action = { SelectTab = 1 } }, { key = { F = 3 }, action = { SelectTab = 2 } }, { key = { F = 4 }, action = { SelectTab = 3 } }, { key = { F = 5 }, action = { SelectTab = 4 } }, ] ``` ### Character Sets Character sets define the symbols used in the user interface. You can define multiple character sets and switch between them using the `char_set` configuration option or the `-s`/`--char-set` command-line argument. There are three built-in character sets. 1. `default` is the default set. It may contain symbols that can't be rendered with your terminal or console. 2. `compat` uses only symbols from [cross-platform-terminal-characters](https://github.com/ehmicky/cross-platform-terminal-characters). 3. `extracompat` uses only ASCII symbols. The configuration file allows for both modifying built-in character sets and creating custom ones. See [wiremix.toml](./wiremix.toml) for more details. ### Themes Themes define colors and other text attributes for UI elements. They are similar to character sets in that you can define your own themes and switch between them with the `theme` configuration option or the `-t`/`--theme` command-line arguments. There are three built-in themes: 1. `default` is the default theme. 2. `nocolor` uses no color, only attributes. 3. `plain` uses only the default style - no colors or attributes. The configuration file allows for both modifying built-in themes and creating custom ones. See [wiremix.toml](./wiremix.toml) for more details. ### Names You can customize how streams, endpoints, and devices are displayed in the user interface using a template system to generate names from PipeWire properties. It's likely that any particular naming scheme won't work well with 100% of your software and devices, so you can also specify alternate name templates to use for PipeWire nodes matching configurable criteria. See [wiremix.toml](./wiremix.toml) for more details. #### Examples The default naming scheme is: ```toml [names] stream = [ "{node:node.name}: {node:media.name}" ] endpoint = [ "{device:device.nick}", "{node:node.description}" ] device = [ "{device:device.nick}", "{device:device.description}" ] ``` Not all nodes and devices have the same properties present, so if multiple naming templates are specified, wiremix will try to resolve them in order and use the first one that works. For ncpamixer-style names you can use: ```toml [names] stream = [ "{node:node.name}: {node:media.name}" ] endpoint = [ "{node:node.description}" ] device = [ "{device:device.description}" ] ``` I use these overrides with the default names: ```toml # This device's device.name is truncated to "USB-C to 3.5mm Headphone Jack # A". This override makes wiremix use device.description instead, which for # this device is "USB-C to 3.5mm Headphone Jack Adapter". [[names.overrides]] types = [ "endpoint", "device" ] property = "device:device.name" value = "alsa_card.usb-Apple__Inc._USB-C_to_3.5mm_Headphone_Jack_Adapter_DWH841302FEJKLTA3-00" templates = [ "{device:device.description}" ] # The Spotify client's node.name is "spotify", and it also uses "Spotify" for # media.name. This override makes wiremix use just the node.name, so it shows # as "spotify" instead of "spotify: Spotify". [[names.overrides]] types = [ "stream" ] property = "node:node.name" value = "spotify" templates = [ "{node:node.name}" ] # mpv is also a bit redundant with the default naming scheme - it suffixes # media.name with "- mpv". This override makes it show as "foo - mpv" instead # of "mpv: foo - mpv". [[names.overrides]] types = [ "stream" ] property = "node:node.name" value = "mpv" templates = [ "{node:media.name}" ] ``` wiremix-0.7.0/build.rs000066400000000000000000000006661504750620600146640ustar00rootroot00000000000000use vergen_git2::{Emitter, Git2Builder}; fn main() -> Result<(), Box> { // Put "git describe" output (ex. "v0.5.0-2-gbc03f8a-dirty") in // VERGEN_GIT_DESCRIBE environment variable for use in --version output. let describe = Git2Builder::default() .describe(true, true, Some("v[0-9]*.[0-9]*.[0-9]*")) .build()?; Emitter::default().add_instructions(&describe)?.emit()?; Ok(()) } wiremix-0.7.0/flake.lock000066400000000000000000000020011504750620600151340ustar00rootroot00000000000000{ "nodes": { "nixpkgs": { "locked": { "lastModified": 1743315132, "narHash": "sha256-6hl6L/tRnwubHcA4pfUUtk542wn2Om+D4UnDhlDW9BE=", "owner": "NixOS", "repo": "nixpkgs", "rev": "52faf482a3889b7619003c0daec593a1912fddc1", "type": "github" }, "original": { "owner": "NixOS", "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" } }, "root": { "inputs": { "nixpkgs": "nixpkgs", "systems": "systems" } }, "systems": { "locked": { "lastModified": 1689347949, "narHash": "sha256-12tWmuL2zgBgZkdoB6qXZsgJEH9LR3oUgpaQq2RbI80=", "owner": "nix-systems", "repo": "default-linux", "rev": "31732fcf5e8fea42e59c2488ad31a0e651500f68", "type": "github" }, "original": { "owner": "nix-systems", "repo": "default-linux", "type": "github" } } }, "root": "root", "version": 7 } wiremix-0.7.0/flake.nix000066400000000000000000000020001504750620600150010ustar00rootroot00000000000000{ description = "Simple TUI audio mixer for PipeWire"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; systems.url = "github:nix-systems/default-linux"; }; outputs = { self, nixpkgs, systems, ... }: let eachSystem = callback: nixpkgs.lib.genAttrs (import systems) ( system: callback nixpkgs.legacyPackages.${system} ); in { devShells = eachSystem (pkgs: { default = with pkgs; mkShell { packages = [ rustc cargo rustfmt nixfmt-rfc-style clippy pkg-config rustPlatform.bindgenHook pipewire ]; }; }); packages = eachSystem ( pkgs: let package = pkgs.callPackage ./package.nix { }; in { default = package; wiremix = package; } ); }; } wiremix-0.7.0/package.nix000066400000000000000000000015001504750620600153160ustar00rootroot00000000000000{ rustPlatform, lib, pkg-config, pipewire, }: let fs = lib.fileset; cargoPackage = (lib.importTOML ./Cargo.toml).package; in rustPlatform.buildRustPackage { pname = cargoPackage.name; version = cargoPackage.version; src = fs.toSource { root = ./.; fileset = fs.unions [ (fs.fileFilter (file: builtins.any file.hasExt [ "rs" ]) ./src) ./build.rs ./wiremix.toml ./Cargo.lock ./Cargo.toml ]; }; nativeBuildInputs = [ pkg-config rustPlatform.bindgenHook ]; buildInputs = [ pipewire ]; cargoLock.lockFile = ./Cargo.lock; # Vendor default configuration for reference or for wrapping # without having to commit the file to a git repository. postInstall = '' mkdir -p $out/share install -Dm755 ${./wiremix.toml} $out/share/wiremix.toml ''; } wiremix-0.7.0/rustfmt.toml000066400000000000000000000000171504750620600156060ustar00rootroot00000000000000max_width = 80 wiremix-0.7.0/src/000077500000000000000000000000001504750620600137765ustar00rootroot00000000000000wiremix-0.7.0/src/app.rs000066400000000000000000001030351504750620600151260ustar00rootroot00000000000000//! Main rendering and event processing for the application. use std::sync::mpsc; use std::time::{Duration, Instant}; use crate::config::{Config, Peaks}; use crate::wirehose::{CommandSender, Event as PipewireEvent, StateEvent}; use anyhow::{anyhow, Result}; use ratatui::{ layout::Flex, prelude::{Buffer, Constraint, Direction, Layout, Position, Rect}, text::{Line, Span}, widgets::{Clear, StatefulWidget, Widget}, DefaultTerminal, Frame, }; use crossterm::event::{ Event as CrosstermEvent, KeyEvent, KeyEventKind, MouseButton, MouseEvent, MouseEventKind, }; use serde::Deserialize; use smallvec::{smallvec, SmallVec}; use crate::device_kind::DeviceKind; use crate::event::Event; use crate::help::{HelpWidget, HelpWidgetState}; use crate::object_list::{ObjectList, ObjectListWidget}; use crate::view::{self, ListKind, View}; use crate::wirehose::{state::State, ObjectId}; /// A UI action. /// /// Used internally as the result of input events. /// /// Also generated by interaction with [`MouseArea`]s. /// /// The ordering of variants is used in the help screen. #[derive(Debug, Clone, Copy, Deserialize, PartialEq, PartialOrd)] pub enum Action { Help, Exit, MoveUp, MoveDown, ToggleMute, SetRelativeVolume(f32), SetDefault, ActivateDropdown, CloseDropdown, TabLeft, TabRight, SelectTab(usize), SetAbsoluteVolume(f32), #[serde(skip_deserializing)] SelectObject(ObjectId), #[serde(skip_deserializing)] SetTarget(view::Target), // This can be used to delete a default keybinding - make it do nothing. Nothing, } impl std::fmt::Display for Action { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Action::SelectTab(tab) => write!(f, "Select {tab} tab"), Action::MoveUp => write!(f, "Move cursor up"), Action::MoveDown => write!(f, "Move cursor down"), Action::TabLeft => write!(f, "Select previous tab"), Action::TabRight => write!(f, "Select next tab"), Action::CloseDropdown => write!(f, "Close menu"), Action::ActivateDropdown => write!(f, "Open menu"), Action::SelectObject(object_id) => { write!(f, "Select object {object_id:?}") } Action::SetTarget(_) => write!(f, "Set target"), Action::ToggleMute => write!(f, "Toggle mute"), Action::SetAbsoluteVolume(vol) => { write!(f, "Set volume to {}%", Self::format_percentage(*vol)) } Action::SetRelativeVolume(vol) => { Self::format_relative_volume(f, *vol) } Action::SetDefault => write!(f, "Set default"), Action::Help => write!(f, "Show/hide help"), Action::Exit => write!(f, "Exit wiremix"), Action::Nothing => write!(f, "Nothing"), } } } impl Action { fn format_percentage(vol: f32) -> u16 { (vol * 100.0).trunc() as u16 } fn format_relative_volume( f: &mut std::fmt::Formatter<'_>, vol: f32, ) -> std::fmt::Result { match vol { 0.01 => write!(f, "Increment volume"), -0.01 => write!(f, "Decrement volume"), v if v >= 0.0 => { write!(f, "Increase volume by {}%", Self::format_percentage(v)) } v => { write!(f, "Decrease volume by {}%", Self::format_percentage(-v)) } } } } struct Tab { title: String, list: ObjectList, } impl Tab { fn new(title: String, list: ObjectList) -> Self { Self { title, list } } } #[derive( Deserialize, Default, Debug, Clone, Copy, PartialEq, clap::ValueEnum, )] #[serde(rename_all = "lowercase")] #[cfg_attr(test, derive(strum::EnumIter))] pub enum TabKind { #[default] Playback, Recording, Output, Input, Configuration, } impl TabKind { pub fn index(&self) -> usize { *self as usize } } impl std::fmt::Display for TabKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { TabKind::Playback => write!(f, "Playback"), TabKind::Recording => write!(f, "Recording"), TabKind::Output => write!(f, "Output Devices"), TabKind::Input => write!(f, "Input Devices"), TabKind::Configuration => write!(f, "Configuration"), } } } // Mouse events matching one of the MouseEventKinds within the Rect will // perform the Actions. pub type MouseArea = (Rect, SmallVec<[MouseEventKind; 4]>, SmallVec<[Action; 4]>); #[derive(Default, Debug, Clone, Copy)] pub enum StateDirty { #[default] Clean, PeaksOnly, Everything, } /// Handles the main UI for the application. /// /// This runs the main loop to process PipeWire events and terminal input and /// to render the main tabs of the application. pub struct App<'a> { /// If set, tells the main loop it's time to exit exit: bool, /// wirehose handle, for sending commands wirehose: &'a dyn CommandSender, /// [`Event`](`crate::event::Event`) channel rx: mpsc::Receiver, /// An error message to return on exit error_message: Option, /// The main tabs tabs: Vec, /// The index of the currently-visible tab current_tab_index: usize, /// Areas populated during rendering which define actions corresponding to /// mouse activity mouse_areas: Vec, /// wirehose has received all initial information is_ready: bool, /// The current PipeWire state state: State, /// How dirty is the PipeWire state? state_dirty: StateDirty, /// A rendering view based on the current PipeWire state view: View<'a>, /// The application configuration config: Config, /// The row on which the mouse is being dragged. While the left mouse /// button is held down, this is used in place of the real row to allow the /// mouse to move on the vertical axis during horizontal dragging. drag_row: Option, /// Position in help text (None if not showing help) help_position: Option, } macro_rules! current_list { ($self:expr) => { $self.tabs[$self.current_tab_index].list }; } impl<'a> App<'a> { pub fn new( wirehose: &'a dyn CommandSender, rx: mpsc::Receiver, config: Config, ) -> Self { let tabs = vec![ Tab::new( TabKind::Playback.to_string(), ObjectList::new(ListKind::Node(view::NodeKind::Playback), None), ), Tab::new( TabKind::Recording.to_string(), ObjectList::new( ListKind::Node(view::NodeKind::Recording), None, ), ), Tab::new( TabKind::Output.to_string(), ObjectList::new( ListKind::Node(view::NodeKind::Output), Some(DeviceKind::Sink), ), ), Tab::new( TabKind::Input.to_string(), ObjectList::new( ListKind::Node(view::NodeKind::Input), Some(DeviceKind::Source), ), ), Tab::new( TabKind::Configuration.to_string(), ObjectList::new(ListKind::Device, None), ), ]; // Update peaks with VU-meter-style ballistics let peak_processor = |current_peak, new_peak, rate, samples| { // Attack/release time of 300 ms let time_constant = 0.3; let coef = 1.0 - (-(samples as f32) / (time_constant * rate as f32)).exp(); current_peak + (new_peak - current_peak) * coef }; let state = State::default() .with_peak_processor(Box::new(peak_processor)) .with_capture(config.peaks != Peaks::Off); App { exit: false, wirehose, rx, error_message: None, tabs, current_tab_index: config.tab.index(), mouse_areas: Vec::new(), is_ready: false, state, state_dirty: StateDirty::default(), view: View::new(wirehose), config, drag_row: None, help_position: None, } } pub fn run(mut self, terminal: &mut DefaultTerminal) -> Result<()> { // Wait until we've received all initial data from PipeWire let _ = terminal.draw(|frame| { frame.render_widget(Line::from("Initializing..."), frame.area()); }); while !self.exit && !self.is_ready { let _ = self.handle_events(None); } let mut pacer = RenderPacer::new(self.config.fps); // Did we handle any events and thus need to re-render? let mut needs_render = true; while !self.exit { // Update view if needed match self.state_dirty { StateDirty::Everything => { self.view = View::from( self.wirehose, &self.state, &self.config.names, ); } StateDirty::PeaksOnly => { self.view.update_peaks(&self.state); } _ => {} } self.state_dirty = StateDirty::Clean; if needs_render && pacer.is_time_to_render() { needs_render = false; self.mouse_areas.clear(); terminal.draw(|frame| { current_list!(self).update(frame.area(), &self.view); self.draw(frame); })?; } needs_render |= self.handle_events( // If there's no fps limit, we definitely rendered in this // iteration, so needs_render is false, and there is no timeout. needs_render.then_some(pacer.duration_until_next_frame()), )?; } self.error_message.map_or(Ok(()), |s| Err(anyhow!(s))) } fn draw(&mut self, frame: &mut Frame) { let widget = AppWidget { current_tab_index: self.current_tab_index, view: &self.view, config: &self.config, }; let mut widget_state = AppWidgetState { mouse_areas: &mut self.mouse_areas, tabs: &mut self.tabs, help_position: &mut self.help_position, }; frame.render_stateful_widget(widget, frame.area(), &mut widget_state); } fn exit(&mut self, error_message: Option) { self.exit = true; self.error_message = error_message; } /// Handle events with optional timeout. /// Returns true if events were handled. fn handle_events(&mut self, timeout: Option) -> Result { let mut were_events_handled = match timeout { Some(timeout) => match self.rx.recv_timeout(timeout) { Ok(event) => event.handle(self)?, Err(mpsc::RecvTimeoutError::Timeout) => return Ok(false), Err(e) => return Err(e.into()), }, // Block on the next event. None => self.rx.recv()?.handle(self)?, }; // Then handle the rest that are available. while let Ok(event) = self.rx.try_recv() { were_events_handled |= event.handle(self)?; } Ok(were_events_handled) } } struct RenderPacer { frame_duration: Duration, next_frame_time: Instant, } impl RenderPacer { fn new(fps: Option) -> Self { let frame_duration = fps.map_or(Default::default(), |fps| { Duration::from_secs_f32(1.0 / fps) }); Self { frame_duration, next_frame_time: Instant::now(), } } fn is_time_to_render(&mut self) -> bool { let now = Instant::now(); if now >= self.next_frame_time { if now > self.next_frame_time + self.frame_duration { // We're running behind, so reset the frame timing. self.next_frame_time = now + self.frame_duration; } else { self.next_frame_time += self.frame_duration; } return true; } false } fn duration_until_next_frame(&self) -> Duration { self.next_frame_time .saturating_duration_since(Instant::now()) } } trait Handle { /// Handle some kind of event. Returns true if the event was handled which /// indicates that the UI needs to be redrawn. fn handle(self, app: &mut App) -> Result; } impl Handle for Event { fn handle(self, app: &mut App) -> Result { match self { Event::Input(event) => event.handle(app), Event::Pipewire(event) => event.handle(app), } } } impl Handle for crossterm::event::Event { fn handle(self, app: &mut App) -> Result { match self { CrosstermEvent::Key(event) => event.handle(app), CrosstermEvent::Mouse(event) => event.handle(app), CrosstermEvent::Resize(..) => Ok(true), _ => Ok(false), } } } impl Handle for KeyEvent { fn handle(self, app: &mut App) -> Result { if self.kind != KeyEventKind::Press { return Ok(false); } if let Some(&action) = app.config.keybindings.get(&self) { return action.handle(app); } Ok(false) } } impl Handle for Action { fn handle(self, app: &mut App) -> Result { if let Some(ref mut help_position) = app.help_position { match self { Action::MoveDown => { *help_position = help_position.saturating_add(1); return Ok(true); } Action::MoveUp => { *help_position = help_position.saturating_sub(1); return Ok(true); } Action::ActivateDropdown | Action::CloseDropdown | Action::Help => { // Close the help menu app.help_position = None; return Ok(true); } Action::Exit => { app.exit(None); return Ok(true); } _ => { return Ok(false); } } } match self { Action::SelectTab(index) => { if index < app.tabs.len() { app.current_tab_index = index; } } Action::MoveDown => { current_list!(app).down(&app.view); } Action::MoveUp => { current_list!(app).up(&app.view); } Action::TabLeft => { app.current_tab_index = app .current_tab_index .checked_sub(1) .unwrap_or(app.tabs.len() - 1) } Action::TabRight => { app.current_tab_index = (app.current_tab_index + 1) % app.tabs.len() } Action::CloseDropdown => { current_list!(app).dropdown_close(); } Action::ActivateDropdown => { current_list!(app).dropdown_activate(&app.view); } Action::SetTarget(target) => { current_list!(app).set_target(&app.view, target); } Action::SelectObject(object_id) => { app.tabs[app.current_tab_index].list.selected = Some(object_id) } Action::ToggleMute => { current_list!(app).toggle_mute(&app.view); } Action::SetAbsoluteVolume(volume) => { let max = app .config .enforce_max_volume .then_some(app.config.max_volume_percent); current_list!(app).set_absolute_volume(&app.view, volume, max); return Ok(current_list!(app) .set_absolute_volume(&app.view, volume, max)); } Action::SetRelativeVolume(volume) => { // Relative decreases have no maximum. let max = (volume > 0.0 && app.config.enforce_max_volume) .then_some(app.config.max_volume_percent); return Ok(current_list!(app) .set_relative_volume(&app.view, volume, max)); } Action::SetDefault => { current_list!(app).set_default(&app.view); } Action::Exit => { app.exit(None); } Action::Nothing => { // Did nothing return Ok(false); } Action::Help => { // Activate the help menu app.help_position = Some(0); } } Ok(true) } } impl Handle for MouseEvent { fn handle(self, app: &mut App) -> Result { match self.kind { MouseEventKind::Down(MouseButton::Left) => { app.drag_row = Some(self.row) } MouseEventKind::Up(MouseButton::Left) => app.drag_row = None, _ => {} } let actions = app .mouse_areas .iter() .rev() .find(|(rect, kinds, _)| { rect.contains(Position { x: self.column, y: app.drag_row.unwrap_or(self.row), }) && kinds.contains(&self.kind) }) .map(|(_, _, action)| action.clone()) .into_iter() .flatten(); let mut handled_action = false; for action in actions { handled_action = true; let _ = action.handle(app); } Ok(handled_action) } } impl Handle for PipewireEvent { fn handle(self, app: &mut App) -> Result { match self { PipewireEvent::Ready => { app.is_ready = true; Ok(true) } PipewireEvent::Error(message) => { match message { // These happen when objects are removed while wirehose is // still in the process of setting up listeners error if error.starts_with("no global ") => {} error if error.starts_with("unknown resource ") => {} // I see this one when disconnecting a Bluetooth sink error if error == "Received error event" => {} // Not sure where this originates error if error == "Error: Buffer allocation failed" => {} _ => app.exit(Some(message)), } Ok(false) // This makes sense for now } PipewireEvent::State(event) => event.handle(app), } } } impl Handle for StateEvent { fn handle(self, app: &mut App) -> Result { // Peaks updates are very frequent and easy to merge, so track if those // are the only updates done since the state was last Clean. match (app.state_dirty, &self) { ( StateDirty::Clean | StateDirty::PeaksOnly, StateEvent::NodePeaks { .. }, ) => { app.state_dirty = StateDirty::PeaksOnly; } _ => { app.state_dirty = StateDirty::Everything; } } app.state.update(app.wirehose, self); Ok(true) } } impl Handle for String { fn handle(self, app: &mut App) -> Result { // Handle errors match self { // These happen when objects are removed while wirehose is still in // the process of setting up listeners error if error.starts_with("no global ") => {} error if error.starts_with("unknown resource ") => {} // I see this one when disconnecting a Bluetooth sink error if error == "Received error event" => {} // Not sure where this originates error if error == "Error: Buffer allocation failed" => {} _ => app.exit(Some(self)), } Ok(false) // This makes sense for now } } pub struct AppWidget<'a, 'b> { current_tab_index: usize, view: &'a View<'b>, config: &'a Config, } pub struct AppWidgetState<'a> { mouse_areas: &'a mut Vec, tabs: &'a mut Vec, help_position: &'a mut Option, } impl<'a> StatefulWidget for AppWidget<'a, '_> { type State = AppWidgetState<'a>; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { let layout = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(0), // list_area Constraint::Length(1), // menu_area ]) .split(area); let list_area = layout[0]; let menu_area = layout[1]; let constraints: Vec<_> = state .tabs .iter() .map(|tab| Constraint::Length(tab.title.len() as u16 + 2)) .collect(); let menu_areas = Layout::default() .direction(Direction::Horizontal) .constraints(constraints) .split(menu_area); for (i, tab) in state.tabs.iter().enumerate() { let title_line = if i == self.current_tab_index { Line::from(vec![ Span::styled( &self.config.char_set.tab_marker_left, self.config.theme.tab_marker, ), Span::styled(&tab.title, self.config.theme.tab_selected), Span::styled( &self.config.char_set.tab_marker_right, self.config.theme.tab_marker, ), ]) } else { Line::from(Span::styled( format!(" {} ", tab.title), self.config.theme.tab, )) }; title_line.render(menu_areas[i], buf); state.mouse_areas.push(( menu_areas[i], smallvec![MouseEventKind::Down(MouseButton::Left)], smallvec![Action::SelectTab(i)], )); } let mut widget = ObjectListWidget { object_list: &mut state.tabs[self.current_tab_index].list, view: self.view, config: self.config, }; widget.render(list_area, buf, state.mouse_areas); // Render the help menu if it's open if let Some(ref mut help_position) = state.help_position { // Ignore any mouse actions on the lower area state.mouse_areas.clear(); // Close the help menu if clicked anywhere outside state.mouse_areas.push(( area, smallvec![MouseEventKind::Down(MouseButton::Left)], smallvec![Action::Help], )); let width: u16 = self .config .help .widths .iter() .fold(HelpWidget::base_width(), |acc, &x| acc.saturating_add(x)) .try_into() .unwrap_or(u16::MAX); let [help_area] = Layout::horizontal([Constraint::Max(width)]) .flex(Flex::Center) .areas(list_area); // Fit to the number of help text rows, or 80% of the area if they // don't all fit let height: u16 = self .config .help .rows .len() .saturating_add(2) .try_into() .unwrap_or(u16::MAX) .min(((help_area.height as f32) * 0.90) as u16); let [help_area] = Layout::vertical([Constraint::Length(height)]) .flex(Flex::Center) .areas(help_area); Clear.render(help_area, buf); HelpWidget { config: self.config, } .render( help_area, buf, &mut HelpWidgetState { mouse_areas: state.mouse_areas, help_position, }, ); } } } #[cfg(test)] mod tests { use super::*; use crate::mock; use crate::wirehose::PropertyStore; use strum::IntoEnumIterator; fn fixture(wirehose: &mock::WirehoseHandle) -> App<'_> { let (_, event_rx) = mpsc::channel(); let config = Config { remote: None, fps: None, mouse: false, peaks: Default::default(), char_set: Default::default(), theme: Default::default(), max_volume_percent: Default::default(), enforce_max_volume: Default::default(), keybindings: Default::default(), help: Default::default(), names: Default::default(), tab: Default::default(), }; let mut app = App::new(wirehose, event_rx, config); // Create a node for testing let object_id = ObjectId::from_raw_id(0); let mut props = PropertyStore::default(); props.set_node_description(String::from("Test node")); props.set_media_class(String::from("Stream/Output/Audio")); props.set_media_name(String::from("Media name")); props.set_node_name(String::from("Node name")); props.set_object_serial(0); let props = props; let events = vec![ StateEvent::NodeProperties { object_id, props }, StateEvent::NodePeaks { object_id, peaks: vec![0.0, 0.0], samples: 512, }, StateEvent::NodePositions { object_id, positions: vec![0, 1], }, StateEvent::NodeRate { object_id, rate: 44100, }, StateEvent::NodeVolumes { object_id, volumes: vec![1.0, 1.0], }, StateEvent::NodeMute { object_id, mute: false, }, ]; for event in events { assert!(event.handle(&mut app).unwrap()); } app.view = View::from(wirehose, &app.state, &app.config.names); // Select the node assert!(Action::SelectObject(object_id).handle(&mut app).unwrap()); app } #[test] fn select_tab_bounds() { let wirehose = mock::WirehoseHandle::default(); let mut app = fixture(&wirehose); let _ = Action::SelectTab(app.tabs.len()).handle(&mut app); assert!(app.current_tab_index < app.tabs.len()); } #[test] fn key_modifiers() { use crossterm::event::{KeyCode, KeyModifiers}; use std::collections::HashMap; let wirehose = mock::WirehoseHandle::default(); let (_, event_rx) = mpsc::channel(); let x = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE); let ctrl_x = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL); let keybindings = HashMap::from([ (x, Action::SelectTab(2)), (ctrl_x, Action::SelectTab(4)), ]); let config = Config { remote: None, fps: None, mouse: false, peaks: Default::default(), char_set: Default::default(), theme: Default::default(), max_volume_percent: Default::default(), enforce_max_volume: Default::default(), keybindings, help: Default::default(), names: Default::default(), tab: Default::default(), }; let mut app = App::new(&wirehose, event_rx, config); let _ = x.handle(&mut app); assert_eq!(app.current_tab_index, 2); let _ = ctrl_x.handle(&mut app); assert_eq!(app.current_tab_index, 4); let _ = x.handle(&mut app); assert_eq!(app.current_tab_index, 2); } /// Ensure that the tabs enum variants are in the same order as the app's /// tab Vec. Making the initial tab configurable depends on this property /// because it uses the position of the enum variants to derivce an index /// into the tab Vec. #[test] fn tab_enum_order_matches_tab_vec() { let wirehose = mock::WirehoseHandle::default(); let app = fixture(&wirehose); assert_eq!(TabKind::iter().count(), app.tabs.len()); for (tab, Tab { title, .. }) in TabKind::iter().zip(app.tabs.iter()) { match tab { TabKind::Playback => assert_eq!(title, "Playback"), TabKind::Recording => assert_eq!(title, "Recording"), TabKind::Output => assert_eq!(title, "Output Devices"), TabKind::Input => assert_eq!(title, "Input Devices"), TabKind::Configuration => assert_eq!(title, "Configuration"), } } } #[test] fn help_underflow() { let wirehose = mock::WirehoseHandle::default(); let mut app = fixture(&wirehose); assert!(Action::Help.handle(&mut app).unwrap()); assert_eq!(app.help_position, Some(0)); assert!(Action::MoveUp.handle(&mut app).unwrap()); assert_eq!(app.help_position, Some(0)); } #[test] fn help_up_down() { let wirehose = mock::WirehoseHandle::default(); let mut app = fixture(&wirehose); assert!(Action::Help.handle(&mut app).unwrap()); assert_eq!(app.help_position, Some(0)); assert!(Action::MoveDown.handle(&mut app).unwrap()); assert_eq!(app.help_position, Some(1)); assert!(Action::MoveUp.handle(&mut app).unwrap()); assert_eq!(app.help_position, Some(0)); } #[test] fn help_toggle() { let wirehose = mock::WirehoseHandle::default(); let mut app = fixture(&wirehose); assert!(Action::Help.handle(&mut app).unwrap()); assert_eq!(app.help_position, Some(0)); assert!(Action::Help.handle(&mut app).unwrap()); assert!(app.help_position.is_none()); } #[test] fn help_ignore_other_actions() { let wirehose = mock::WirehoseHandle::default(); let mut app = fixture(&wirehose); assert!(Action::SetDefault.handle(&mut app).unwrap()); assert!(Action::Help.handle(&mut app).unwrap()); assert_eq!(app.help_position, Some(0)); assert!(!Action::SetDefault.handle(&mut app).unwrap()); } #[test] fn volume_limit_not_enforcing() { let wirehose = mock::WirehoseHandle::default(); let mut app = fixture(&wirehose); app.config.max_volume_percent = 100.0; app.config.enforce_max_volume = false; // The current volume is 100% // 110% is allowed assert!(Action::SetRelativeVolume(0.10).handle(&mut app).unwrap()); assert!(Action::SetAbsoluteVolume(1.10).handle(&mut app).unwrap()); // 90% is allowed assert!(Action::SetRelativeVolume(-0.10).handle(&mut app).unwrap()); assert!(Action::SetAbsoluteVolume(0.90).handle(&mut app).unwrap()); } #[test] fn volume_limit_at_max() { let wirehose = mock::WirehoseHandle::default(); let mut app = fixture(&wirehose); app.config.max_volume_percent = 100.0; app.config.enforce_max_volume = true; // The current volume is 100% // 110% is not allowed assert!(!Action::SetRelativeVolume(0.10).handle(&mut app).unwrap()); assert!(!Action::SetAbsoluteVolume(1.10).handle(&mut app).unwrap()); // 90% is allowed assert!(Action::SetRelativeVolume(-0.10).handle(&mut app).unwrap()); assert!(Action::SetAbsoluteVolume(0.90).handle(&mut app).unwrap()); // 100% is allowed assert!(Action::SetAbsoluteVolume(1.00).handle(&mut app).unwrap()); } #[test] fn volume_limit_above_max() { let wirehose = mock::WirehoseHandle::default(); let mut app = fixture(&wirehose); app.config.max_volume_percent = 95.0; app.config.enforce_max_volume = true; // The current volume is 100.0 // 110% is not allowed assert!(!Action::SetRelativeVolume(0.10).handle(&mut app).unwrap()); assert!(!Action::SetAbsoluteVolume(1.10).handle(&mut app).unwrap()); // 90% is allowed assert!(Action::SetRelativeVolume(-0.10).handle(&mut app).unwrap()); assert!(Action::SetAbsoluteVolume(0.90).handle(&mut app).unwrap()); // 95% is allowed assert!(Action::SetAbsoluteVolume(0.95).handle(&mut app).unwrap()); } #[test] fn volume_limit_below_max() { let wirehose = mock::WirehoseHandle::default(); let mut app = fixture(&wirehose); app.config.max_volume_percent = 105.0; app.config.enforce_max_volume = true; // The current volume is 100.0 // 110% is not allowed assert!(!Action::SetRelativeVolume(0.10).handle(&mut app).unwrap()); assert!(!Action::SetAbsoluteVolume(1.10).handle(&mut app).unwrap()); // 105% is allowed assert!(Action::SetRelativeVolume(0.05).handle(&mut app).unwrap()); assert!(Action::SetAbsoluteVolume(1.05).handle(&mut app).unwrap()); // 90% is allowed assert!(Action::SetRelativeVolume(-0.10).handle(&mut app).unwrap()); assert!(Action::SetAbsoluteVolume(0.90).handle(&mut app).unwrap()); } } wiremix-0.7.0/src/config.rs000066400000000000000000000336401504750620600156170ustar00rootroot00000000000000//! Mixer configuration. mod char_set; mod help; mod keybinding; mod name_template; mod names; mod tag; mod theme; use std::collections::HashMap; use std::convert::TryFrom; use std::env; use std::fs; use std::path::{Path, PathBuf}; use anyhow::Context; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{style::Style, widgets::block::BorderType}; use serde::Deserialize; use toml; use crate::app::{Action, TabKind}; use crate::opt::Opt; #[derive(Debug)] #[cfg_attr(test, derive(PartialEq))] pub struct Config { pub remote: Option, pub fps: Option, pub mouse: bool, pub peaks: Peaks, pub char_set: CharSet, pub theme: Theme, pub max_volume_percent: f32, pub enforce_max_volume: bool, pub keybindings: HashMap, pub help: help::Help, pub names: Names, pub tab: TabKind, } /// Represents a configuration deserialized from a file. This gets baked into a /// Config, which, for example, has a single char_set and theme. #[derive(Deserialize, Debug)] #[cfg_attr(test, derive(PartialEq))] #[serde(deny_unknown_fields)] struct ConfigFile { remote: Option, fps: Option, #[serde(default = "default_mouse")] mouse: bool, #[serde(default = "default_peaks")] peaks: Option, #[serde(default = "default_char_set_name")] char_set: String, #[serde(default = "default_theme_name")] theme: String, #[serde(default = "default_max_volume_percent")] max_volume_percent: Option, #[serde(default = "default_enforce_max_volume")] enforce_max_volume: bool, #[serde( default = "Keybinding::defaults", deserialize_with = "Keybinding::merge" )] keybindings: HashMap, #[serde(default)] names: Names, #[serde( default = "CharSet::defaults", deserialize_with = "CharSet::merge" )] char_sets: HashMap, #[serde(default = "Theme::defaults", deserialize_with = "Theme::merge")] themes: HashMap, #[serde(default = "default_tab")] tab: Option, } #[derive(Deserialize, Default, Debug, Clone, PartialEq, clap::ValueEnum)] #[serde(rename_all = "lowercase")] pub enum Peaks { Off, Mono, #[default] Auto, } #[derive(Deserialize, Debug)] #[serde(deny_unknown_fields)] pub struct Keybinding { pub key: KeyCode, #[serde(default = "Keybinding::default_modifiers")] pub modifiers: KeyModifiers, pub action: Action, } #[derive(Deserialize, Debug)] #[cfg_attr(test, derive(PartialEq))] #[serde(deny_unknown_fields)] pub struct Names { #[serde(default = "Names::default_stream")] pub stream: Vec, #[serde(default = "Names::default_endpoint")] pub endpoint: Vec, #[serde(default = "Names::default_device")] pub device: Vec, #[serde(default)] pub overrides: Vec, } #[derive(PartialEq, Deserialize, Debug)] #[serde(rename_all = "lowercase")] pub enum OverrideType { Stream, Endpoint, Device, } #[derive(Deserialize, Debug)] #[cfg_attr(test, derive(PartialEq))] #[serde(deny_unknown_fields)] pub struct NameOverride { pub types: Vec, pub property: names::Tag, pub value: String, pub templates: Vec, } #[derive(Debug)] #[cfg_attr(test, derive(PartialEq))] pub struct CharSet { pub default_device: String, pub default_stream: String, pub selector_top: String, pub selector_middle: String, pub selector_bottom: String, pub tab_marker_left: String, pub tab_marker_right: String, pub list_more: String, pub volume_empty: String, pub volume_filled: String, pub meter_left_inactive: String, pub meter_left_active: String, pub meter_left_overload: String, pub meter_right_inactive: String, pub meter_right_active: String, pub meter_right_overload: String, pub meter_center_left_inactive: String, pub meter_center_left_active: String, pub meter_center_right_inactive: String, pub meter_center_right_active: String, pub dropdown_icon: String, pub dropdown_selector: String, pub dropdown_more: String, pub dropdown_border: BorderType, pub help_more: String, pub help_border: BorderType, } #[derive(Deserialize, Debug)] #[cfg_attr(test, derive(PartialEq))] pub struct Theme { pub default_device: Style, pub default_stream: Style, pub selector: Style, pub tab: Style, pub tab_selected: Style, pub tab_marker: Style, pub list_more: Style, pub node_title: Style, pub node_target: Style, pub volume: Style, pub volume_empty: Style, pub volume_filled: Style, pub meter_inactive: Style, pub meter_active: Style, pub meter_overload: Style, pub meter_center_inactive: Style, pub meter_center_active: Style, pub config_device: Style, pub config_profile: Style, pub dropdown_icon: Style, pub dropdown_border: Style, pub dropdown_item: Style, pub dropdown_selected: Style, pub dropdown_more: Style, pub help_border: Style, pub help_item: Style, pub help_more: Style, } fn default_mouse() -> bool { true } fn default_peaks() -> Option { Some(Peaks::default()) } fn default_tab() -> Option { Some(TabKind::default()) } fn default_char_set_name() -> String { String::from("default") } fn default_theme_name() -> String { String::from("default") } fn default_max_volume_percent() -> Option { Some(150.0) } fn default_enforce_max_volume() -> bool { false } impl ConfigFile { /// Override configuration with command-line arguments. pub fn apply_opt(&mut self, opt: &Opt) { if let Some(remote) = &opt.remote { self.remote = Some(remote.clone()); } if let Some(fps) = opt.fps { self.fps = (fps != 0.0).then_some(fps); } if opt.no_mouse { self.mouse = false; } if opt.mouse { self.mouse = true; } if let Some(peaks) = &opt.peaks { self.peaks = Some(peaks.clone()); } if let Some(char_set) = &opt.char_set { self.char_set = char_set.clone(); } if let Some(theme) = &opt.theme { self.theme = theme.clone(); } if let Some(tab) = &opt.tab { self.tab = Some(*tab); } if let Some(max_volume_percent) = &opt.max_volume_percent { self.max_volume_percent = Some(*max_volume_percent); } if opt.no_enforce_max_volume { self.enforce_max_volume = false; } if opt.enforce_max_volume { self.enforce_max_volume = true; } } } impl TryFrom for Config { type Error = anyhow::Error; fn try_from(mut config_file: ConfigFile) -> Result { let Some(char_set) = config_file.char_sets.remove(&config_file.char_set) else { anyhow::bail!( "char_set '{}' does not exist", &config_file.char_set ); }; let Some(theme) = config_file.themes.remove(&config_file.theme) else { anyhow::bail!("theme '{}' does not exist", &config_file.theme); }; let help = help::Help::from(&config_file.keybindings); if let Some(max_volume_percent) = config_file.max_volume_percent { if max_volume_percent < 0.0 { anyhow::bail!( "max_volume_percent {max_volume_percent} is negative" ); } } // Emulate signals. This is intentionally done after generating help. config_file .keybindings .extend(Keybinding::control_char_keybindings()); Ok(Self { remote: config_file.remote, fps: config_file.fps, mouse: config_file.mouse, peaks: config_file.peaks.unwrap_or_default(), max_volume_percent: config_file .max_volume_percent .unwrap_or_default(), enforce_max_volume: config_file.enforce_max_volume, char_set, theme, keybindings: config_file.keybindings, help, names: config_file.names, tab: config_file.tab.unwrap_or_default(), }) } } impl Config { /// Returns the configuration file path. pub fn default_path() -> Option { if let Ok(xdg_config) = env::var("XDG_CONFIG_HOME") { return Some(Path::new(&xdg_config).join("wiremix/wiremix.toml")); } if let Ok(home) = env::var("HOME") { return Some(Path::new(&home).join(".config/wiremix/wiremix.toml")); } None } /// Parse configuration from the file at the supplied path. pub fn try_new( path: Option<&Path>, opt: &Opt, ) -> Result { let mut config_file: ConfigFile = match path { Some(path) if path.exists() => { let context = || { format!( "Failed to read configuration from file '{}'", path.display() ) }; let toml_str = fs::read_to_string(path).with_context(context)?; toml::from_str(&toml_str).with_context(context)? } _ => toml::from_str("")?, }; // Override with command-line options config_file.apply_opt(opt); let config_file = config_file; Self::try_from(config_file) } } #[cfg(test)] /// Parse a config file without applying any defaults. pub mod strict { use super::*; use serde::de::Error; use crate::config::char_set::CharSetOverlay; use crate::config::theme::ThemeOverlay; #[derive(Deserialize, Debug, PartialEq)] #[serde(deny_unknown_fields)] pub struct ConfigFile { remote: Option, fps: Option, mouse: bool, peaks: Option, char_set: String, theme: String, max_volume_percent: Option, enforce_max_volume: bool, #[serde(deserialize_with = "keybindings")] keybindings: HashMap, names: Names, #[serde(deserialize_with = "charsets")] char_sets: HashMap, #[serde(deserialize_with = "themes")] themes: HashMap, tab: Option, } impl From for super::ConfigFile { fn from(strict: ConfigFile) -> Self { super::ConfigFile { remote: strict.remote, fps: strict.fps, mouse: strict.mouse, peaks: strict.peaks, char_set: strict.char_set, theme: strict.theme, max_volume_percent: strict.max_volume_percent, enforce_max_volume: strict.enforce_max_volume, keybindings: strict.keybindings, names: strict.names, char_sets: strict.char_sets, themes: strict.themes, tab: strict.tab, } } } fn keybindings<'de, D>( deserializer: D, ) -> Result, D::Error> where D: serde::Deserializer<'de>, { Ok(Vec::::deserialize(deserializer)? .into_iter() .map(|keybinding| { ( KeyEvent::new(keybinding.key, keybinding.modifiers), keybinding.action, ) }) .collect::>()) } fn charsets<'de, D>( deserializer: D, ) -> Result, D::Error> where D: serde::Deserializer<'de>, { HashMap::::deserialize(deserializer)? .into_iter() .map(|(key, value)| { CharSet::try_from(value) .map_err(D::Error::custom) .map(move |charset| (key, charset)) }) .collect::, D::Error>>() } fn themes<'de, D>( deserializer: D, ) -> Result, D::Error> where D: serde::Deserializer<'de>, { HashMap::::deserialize(deserializer)? .into_iter() .map(|(key, value)| { Theme::try_from(value) .map_err(D::Error::custom) .map(move |charset| (key, charset)) }) .collect::, D::Error>>() } } #[cfg(test)] mod tests { use super::*; #[test] fn unknown_field_config_file() { let config = r#" unknown = "unknown" "#; assert!(toml::from_str::(config).is_err()); } #[test] fn unknown_field_keybinding() { let config = r#" key = { Char = "x" } action = "Nothing" unknown = "unknown" "#; assert!(toml::from_str::(config).is_err()); } #[test] fn unknown_field_names() { let config = r#" unknown = "unknown" "#; assert!(toml::from_str::(config).is_err()); } #[test] fn unknown_field_name_override() { let config = r#" types = [ "stream" ] property = "node:node.name" value = "value" templates = [ "template" ] unknown = "unknown" "#; assert!(toml::from_str::(config).is_err()); } #[test] fn example_config_file_matches_default_config_file() { let toml_str = include_str!("../wiremix.toml"); let example: strict::ConfigFile = toml::from_str(toml_str).unwrap(); let default: ConfigFile = toml::from_str("").unwrap(); assert_eq!(default, example.into()); } } wiremix-0.7.0/src/config/000077500000000000000000000000001504750620600152435ustar00rootroot00000000000000wiremix-0.7.0/src/config/char_set.rs000066400000000000000000000336341504750620600174120ustar00rootroot00000000000000//! Implementation for [`CharSet`](`crate::config::CharSet`). Defines default //! character sets and handles merging of configured char sets with defaults. use std::collections::HashMap; use ratatui::{text::Span, widgets::block::BorderType}; use serde::{de::Error, Deserialize}; use crate::config::CharSet; // This is what actually gets parsed from the config. #[derive(Deserialize, Debug)] #[serde(deny_unknown_fields)] pub struct CharSetOverlay { inherit: Option, default_device: Option, default_stream: Option, selector_top: Option, selector_middle: Option, selector_bottom: Option, tab_marker_left: Option, tab_marker_right: Option, list_more: Option, volume_empty: Option, volume_filled: Option, meter_left_inactive: Option, meter_left_active: Option, meter_left_overload: Option, meter_right_inactive: Option, meter_right_active: Option, meter_right_overload: Option, meter_center_left_inactive: Option, meter_center_left_active: Option, meter_center_right_inactive: Option, meter_center_right_active: Option, dropdown_icon: Option, dropdown_selector: Option, dropdown_more: Option, dropdown_border: Option, help_more: Option, help_border: Option, } #[derive(Deserialize, Debug)] enum BorderTypeDef { Plain, Rounded, Double, Thick, QuadrantInside, QuadrantOutside, } impl From for BorderType { fn from(def: BorderTypeDef) -> Self { match def { BorderTypeDef::Plain => Self::Plain, BorderTypeDef::Rounded => Self::Rounded, BorderTypeDef::Double => Self::Double, BorderTypeDef::Thick => Self::Thick, BorderTypeDef::QuadrantInside => Self::QuadrantInside, BorderTypeDef::QuadrantOutside => Self::QuadrantOutside, } } } impl TryFrom for CharSet { type Error = anyhow::Error; fn try_from(overlay: CharSetOverlay) -> Result { let mut char_set: Self = match overlay.inherit.as_deref() { Some("default") => CharSet::default(), Some("compat") => CharSet::compat(), Some("extracompat") => CharSet::extracompat(), Some(inherit) => { anyhow::bail!("'{}' is not a built-in character set", inherit) } None => CharSet::default(), }; macro_rules! validate_and_set { // Overwrite default char with char from overlay while validating // width. Length of 0 means don't check width. ($field:ident, $length:expr) => { if let Some(value) = overlay.$field { if $length > 0 && Span::raw(&value).width() != $length { anyhow::bail!( "{} must be {} characters wide", stringify!($field), $length ); } char_set.$field = value; } }; } validate_and_set!(default_device, 1); validate_and_set!(default_stream, 1); validate_and_set!(selector_top, 1); validate_and_set!(selector_middle, 1); validate_and_set!(selector_bottom, 1); validate_and_set!(tab_marker_left, 1); validate_and_set!(tab_marker_right, 1); validate_and_set!(list_more, 0); validate_and_set!(volume_empty, 1); validate_and_set!(volume_filled, 1); validate_and_set!(meter_left_inactive, 1); validate_and_set!(meter_left_active, 1); validate_and_set!(meter_left_overload, 1); validate_and_set!(meter_right_inactive, 1); validate_and_set!(meter_right_active, 1); validate_and_set!(meter_right_overload, 1); validate_and_set!(meter_center_left_inactive, 1); validate_and_set!(meter_center_left_active, 1); validate_and_set!(meter_center_right_inactive, 1); validate_and_set!(meter_center_right_active, 1); validate_and_set!(dropdown_icon, 1); validate_and_set!(dropdown_selector, 1); validate_and_set!(dropdown_more, 0); validate_and_set!(help_more, 0); if let Some(dropdown_border) = overlay.dropdown_border { char_set.dropdown_border = dropdown_border.into(); } if let Some(help_border) = overlay.help_border { char_set.help_border = help_border.into(); } Ok(char_set) } } impl Default for CharSet { fn default() -> Self { Self { default_device: String::from("◇"), default_stream: String::from("◇"), selector_top: String::from("░"), selector_middle: String::from("▒"), selector_bottom: String::from("░"), tab_marker_left: String::from("["), tab_marker_right: String::from("]"), list_more: String::from("•••"), volume_empty: String::from("╌"), volume_filled: String::from("━"), meter_left_inactive: String::from("▮"), meter_left_active: String::from("▮"), meter_left_overload: String::from("▮"), meter_right_inactive: String::from("▮"), meter_right_active: String::from("▮"), meter_right_overload: String::from("▮"), meter_center_left_inactive: String::from("▮"), meter_center_left_active: String::from("▮"), meter_center_right_inactive: String::from("▮"), meter_center_right_active: String::from("▮"), dropdown_icon: String::from("▼"), dropdown_selector: String::from(">"), dropdown_more: String::from("•••"), dropdown_border: BorderType::Rounded, help_more: String::from("•••"), help_border: BorderType::Rounded, } } } impl CharSet { pub fn defaults() -> HashMap { HashMap::from([ (String::from("default"), CharSet::default()), (String::from("compat"), CharSet::compat()), (String::from("extracompat"), CharSet::extracompat()), ]) } fn compat() -> CharSet { Self { default_device: String::from("◊"), default_stream: String::from("◊"), selector_top: String::from("░"), selector_middle: String::from("▒"), selector_bottom: String::from("░"), tab_marker_left: String::from("["), tab_marker_right: String::from("]"), list_more: String::from("•••"), volume_empty: String::from("─"), volume_filled: String::from("━"), meter_left_inactive: String::from("┃"), meter_left_active: String::from("┃"), meter_left_overload: String::from("┃"), meter_right_inactive: String::from("┃"), meter_right_active: String::from("┃"), meter_right_overload: String::from("┃"), meter_center_left_inactive: String::from("█"), meter_center_left_active: String::from("█"), meter_center_right_inactive: String::from("█"), meter_center_right_active: String::from("█"), dropdown_icon: String::from("▼"), dropdown_selector: String::from(">"), dropdown_more: String::from("•••"), dropdown_border: BorderType::Plain, help_more: String::from("•••"), help_border: BorderType::Plain, } } fn extracompat() -> CharSet { Self { default_device: String::from("*"), default_stream: String::from("*"), selector_top: String::from("-"), selector_middle: String::from("="), selector_bottom: String::from("-"), tab_marker_left: String::from("["), tab_marker_right: String::from("]"), list_more: String::from("~~~"), volume_empty: String::from("-"), volume_filled: String::from("="), meter_left_inactive: String::from("="), meter_left_active: String::from("#"), meter_left_overload: String::from("!"), meter_right_inactive: String::from("="), meter_right_active: String::from("#"), meter_right_overload: String::from("!"), meter_center_left_inactive: String::from("["), meter_center_left_active: String::from("["), meter_center_right_inactive: String::from("]"), meter_center_right_active: String::from("]"), dropdown_icon: String::from("\\"), dropdown_selector: String::from(">"), dropdown_more: String::from("~~~"), dropdown_border: BorderType::Plain, help_more: String::from("~~~"), help_border: BorderType::Plain, } } /// Merge deserialized charsets with defaults pub fn merge<'de, D>( deserializer: D, ) -> Result, D::Error> where D: serde::Deserializer<'de>, { let configured = HashMap::::deserialize(deserializer)?; let mut merged = configured .into_iter() .map(|(key, value)| { CharSet::try_from(value) .map_err(D::Error::custom) .map(move |charset| (key, charset)) }) .collect::, D::Error>>()?; if !merged.contains_key("default") { merged.insert(String::from("default"), CharSet::default()); } if !merged.contains_key("compat") { merged.insert(String::from("compat"), CharSet::compat()); } if !merged.contains_key("extracompat") { merged.insert(String::from("extracompat"), CharSet::extracompat()); } Ok(merged) } } #[cfg(test)] mod tests { use super::*; #[test] fn empty_overlay() { let config = r#""#; let overlay = toml::from_str::(config).unwrap(); CharSet::try_from(overlay).unwrap(); } #[test] fn builtins_present() { #[derive(Deserialize)] struct S { #[serde(deserialize_with = "CharSet::merge")] char_sets: HashMap, } let config = r#"[char_sets.test]"#; let s = toml::from_str::(config).unwrap(); for name in CharSet::defaults().keys() { assert!(s.char_sets.contains_key(name)); } } #[test] fn override_default() { let config = r#" dropdown_icon = "$" "#; let overlay = toml::from_str::(config).unwrap(); let char_set = CharSet::try_from(overlay).unwrap(); assert_eq!(char_set.dropdown_icon, "$") } #[test] fn width_too_narrow() { let config = r#" meter_right_active = "" "#; let overlay = toml::from_str::(config).unwrap(); let char_set = CharSet::try_from(overlay); assert!(char_set.is_err()); } #[test] fn width_too_wide() { let config = r#" meter_right_active = "$$" "#; let overlay = toml::from_str::(config).unwrap(); let char_set = CharSet::try_from(overlay); assert!(char_set.is_err()); } #[test] fn width_correct() { let config = r#" meter_right_active = "$" "#; let overlay = toml::from_str::(config).unwrap(); let char_set = CharSet::try_from(overlay).unwrap(); assert_eq!(char_set.meter_right_active, "$"); } #[test] fn width_1_column_grapheme_cluster() { let config = r#" meter_right_active = "⚓︎" "#; let overlay = toml::from_str::(config).unwrap(); let char_set = CharSet::try_from(overlay).unwrap(); assert_eq!(char_set.meter_right_active, "⚓︎"); } #[test] fn width_2_column_grapheme_cluster() { let config = r#" meter_right_active = "🏳️‍🌈" "#; let overlay = toml::from_str::(config).unwrap(); let char_set = CharSet::try_from(overlay); assert!(char_set.is_err()); } #[test] fn width_unlimited() { let config = r#" list_more = "" dropdown_more = "$$$$$$$$$$$$$$$$$$$$$$$$" "#; let overlay = toml::from_str::(config).unwrap(); let char_set = CharSet::try_from(overlay).unwrap(); assert_eq!(char_set.list_more, ""); assert_eq!(char_set.dropdown_more, "$$$$$$$$$$$$$$$$$$$$$$$$"); } #[test] fn inherit_nonexistent() { let config = r#" inherit = "doesntexist" meter_right_active = "$" "#; let overlay = toml::from_str::(config).unwrap(); let char_set = CharSet::try_from(overlay); assert!(char_set.is_err()); } #[test] fn inherit() { for (builtin_key, builtin) in CharSet::defaults().iter() { let config = format!( r#" inherit = "{builtin_key}" meter_right_active = "$" "# ); let overlay = toml::from_str::(&config).unwrap(); let char_set = CharSet::try_from(overlay).unwrap(); assert_eq!(char_set.meter_right_active, "$"); assert_eq!(char_set.meter_left_active, builtin.meter_left_active); } } #[test] fn unknown_field() { let config = r#" unknown = "unknown" "#; assert!(toml::from_str::(config).is_err()); } } wiremix-0.7.0/src/config/help.rs000066400000000000000000000133351504750620600165460ustar00rootroot00000000000000use std::collections::HashMap; use crossterm::event::{KeyCode, KeyEvent}; use crate::config::Action; /// Keybinding help text. /// /// Caches a tabular text representation of the configured keybindings. #[derive(Debug)] #[cfg_attr(test, derive(Default, PartialEq))] pub struct Help { /// Human-readable help text in the form ["action", "keybinding"] pub rows: Vec<[String; 2]>, /// The max width of each column in [`Help::rows`] pub widths: [usize; 2], } impl From<&HashMap> for Help { fn from(keybindings: &HashMap) -> Self { let mut sorted: Vec<_> = keybindings .iter() .filter(|(_, action)| !matches!(action, Action::Nothing)) .collect(); sorted.sort_by(|(a_key, a_action), (b_key, b_action)| { a_action .partial_cmp(b_action) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| { a_key .partial_cmp(b_key) .unwrap_or(std::cmp::Ordering::Equal) }) }); let sorted = sorted; let rows = Self::generate_rows(&sorted); let widths = Self::calculate_widths(&rows); Self { rows, widths } } } impl Help { fn generate_rows(bindings: &[(&KeyEvent, &Action)]) -> Vec<[String; 2]> { let mut rows = Vec::new(); let mut last_action = String::new(); for (key, action) in bindings { let key_string = Self::format_key(key); let action_string = action.to_string(); let action_display = if last_action == action_string { String::new() // Don't repeat the action name } else { action_string.clone() }; rows.push([action_display, key_string]); last_action = action_string; } rows } fn format_key(key: &KeyEvent) -> String { let key_code_string = match key.code { KeyCode::BackTab => "Tab".to_string(), other => other.to_string(), }; if key.modifiers.is_empty() { key_code_string } else { format!("{}+{}", key.modifiers, key_code_string) } } fn calculate_widths(rows: &[[String; 2]]) -> [usize; 2] { rows.iter().fold([0; 2], |mut acc, row| { for (i, string) in row.iter().enumerate() { acc[i] = acc[i].max(string.len()); } acc }) } } #[cfg(test)] mod tests { use super::*; use crossterm::event::{KeyCode, KeyModifiers}; use std::collections::HashMap; #[test] fn action_formatting() { assert_eq!(Action::Help.to_string(), "Show/hide help"); assert_eq!( Action::SetRelativeVolume(0.01).to_string(), "Increment volume" ); assert_eq!( Action::SetRelativeVolume(-0.01).to_string(), "Decrement volume" ); assert_eq!( Action::SetRelativeVolume(0.02).to_string(), "Increase volume by 2%" ); assert_eq!( Action::SetRelativeVolume(-0.02).to_string(), "Decrease volume by 2%" ); assert_eq!( Action::SetRelativeVolume(0.00).to_string(), "Increase volume by 0%" ); assert_eq!( Action::SetAbsoluteVolume(0.5).to_string(), "Set volume to 50%", ); } #[test] fn help_empty() { let keybindings = HashMap::default(); let help = Help::from(&keybindings); assert!(help.rows.is_empty()); assert_eq!(help.widths, [0, 0]); } #[test] fn help_single_binding() { let mut keybindings = HashMap::new(); keybindings.insert( KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE), Action::Help, ); let help = Help::from(&keybindings); assert_eq!(help.rows.len(), 1); assert_eq!( help.rows[0], [String::from("Show/hide help"), String::from("?")] ); assert_eq!(help.widths[0], "Show/hide help".len()); assert_eq!(help.widths[1], "?".len()); } #[test] fn help_nothing_filtered() { let mut keybindings = HashMap::new(); keybindings.insert( KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE), Action::Help, ); keybindings.insert( KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE), Action::Nothing, ); let help = Help::from(&keybindings); assert_eq!(help.rows.len(), 1); assert_eq!( help.rows[0], [String::from("Show/hide help"), String::from("?")] ); assert_eq!(help.widths[0], "Show/hide help".len()); assert_eq!(help.widths[1], "?".len()); } #[test] fn help_same_action() { let mut keybindings = HashMap::new(); keybindings.insert( KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE), Action::Help, ); keybindings.insert( KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE), Action::Help, ); let help = Help::from(&keybindings); assert_eq!(help.rows.len(), 2); // Keybindings should be sorted // First binding shows the action name assert_eq!( help.rows[0], [String::from("Show/hide help"), String::from("F1")] ); // Second binding should have empty action name assert_eq!(help.rows[1], [String::from(""), String::from("?")]); assert_eq!(help.widths[0], "Show/hide help".len()); assert_eq!(help.widths[1], "F1".len()); } } wiremix-0.7.0/src/config/keybinding.rs000066400000000000000000000115751504750620600177450ustar00rootroot00000000000000//! Implementation for [`Keybinding`](`crate::config::Keybinding`). Defines //! default bindings and handles merging of configured bindings with defaults. use std::collections::HashMap; use std::os::fd::AsFd; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use nix::sys::termios::{self, SpecialCharacterIndices}; use serde::Deserialize; use crate::config::{Action, Keybinding}; impl Keybinding { pub fn defaults() -> HashMap { let event = |code| KeyEvent::new(code, KeyModifiers::NONE); HashMap::from([ (event(KeyCode::Char('q')), Action::Exit), (event(KeyCode::Char('m')), Action::ToggleMute), (event(KeyCode::Char('d')), Action::SetDefault), (event(KeyCode::Char('l')), Action::SetRelativeVolume(0.01)), (event(KeyCode::Right), Action::SetRelativeVolume(0.01)), (event(KeyCode::Char('h')), Action::SetRelativeVolume(-0.01)), (event(KeyCode::Left), Action::SetRelativeVolume(-0.01)), (event(KeyCode::Esc), Action::CloseDropdown), (event(KeyCode::Char('c')), Action::ActivateDropdown), (event(KeyCode::Enter), Action::ActivateDropdown), (event(KeyCode::Char('j')), Action::MoveDown), (event(KeyCode::Down), Action::MoveDown), (event(KeyCode::Char('k')), Action::MoveUp), (event(KeyCode::Up), Action::MoveUp), (event(KeyCode::Char('H')), Action::TabLeft), (event(KeyCode::Char('L')), Action::TabRight), ( KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT), Action::TabLeft, ), (event(KeyCode::Tab), Action::TabRight), (event(KeyCode::Char('`')), Action::SetAbsoluteVolume(0.00)), (event(KeyCode::Char('1')), Action::SetAbsoluteVolume(0.10)), (event(KeyCode::Char('2')), Action::SetAbsoluteVolume(0.20)), (event(KeyCode::Char('3')), Action::SetAbsoluteVolume(0.30)), (event(KeyCode::Char('4')), Action::SetAbsoluteVolume(0.40)), (event(KeyCode::Char('5')), Action::SetAbsoluteVolume(0.50)), (event(KeyCode::Char('6')), Action::SetAbsoluteVolume(0.60)), (event(KeyCode::Char('7')), Action::SetAbsoluteVolume(0.70)), (event(KeyCode::Char('8')), Action::SetAbsoluteVolume(0.80)), (event(KeyCode::Char('9')), Action::SetAbsoluteVolume(0.90)), (event(KeyCode::Char('0')), Action::SetAbsoluteVolume(1.00)), (event(KeyCode::Char('?')), Action::Help), ]) } pub fn default_modifiers() -> KeyModifiers { KeyModifiers::NONE } /// Merge deserialized keybindings with defaults pub fn merge<'de, D>( deserializer: D, ) -> Result, D::Error> where D: serde::Deserializer<'de>, { let mut keybindings = Self::defaults(); let configured = Vec::::deserialize(deserializer)?; for keybinding in configured.into_iter() { keybindings.insert( KeyEvent::new(keybinding.key, keybinding.modifiers), keybinding.action, ); } Ok(keybindings) } /// Return keybindings emulating effects of certain terminal special /// characters pub fn control_char_keybindings() -> HashMap { let mut bindings = HashMap::new(); let Ok(termios) = termios::tcgetattr(std::io::stdin().as_fd()) else { return bindings; }; const SPECIAL_CHAR_INDICES: &[SpecialCharacterIndices] = &[ SpecialCharacterIndices::VINTR, SpecialCharacterIndices::VQUIT, SpecialCharacterIndices::VEOF, ]; for &index in SPECIAL_CHAR_INDICES { let byte = termios.control_chars[index as usize]; let key_event = match byte { // Handle control characters that are represented by crossterm // as non-Char KeyCodes 9 => KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), 27 => KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), // CrossTerm reports Ctrl-\ as Ctrl-4 28 => KeyEvent::new(KeyCode::Char('4'), KeyModifiers::CONTROL), // Translate the other control characters to control + // a printable character 1..=31 => KeyEvent::new( KeyCode::Char((byte + 96) as char), KeyModifiers::CONTROL, ), // Pass the printable characters as-is with no modifiers 32..=126 => KeyEvent::new( KeyCode::Char(byte as char), KeyModifiers::NONE, ), _ => continue, }; bindings.insert(key_event, Action::Exit); } bindings } } wiremix-0.7.0/src/config/name_template.rs000066400000000000000000000174751504750620600204420ustar00rootroot00000000000000//! A type for validating and rendering name template strings. //! //! Templates are strings with tags enclosed in { and }. All tag contents must //! be parsable into Tags in order by the string to be accepted. //! { without a matching } or } without a matching { are invalid. //! { and } can be escaped with {{ and }}. use anyhow::{anyhow, bail}; use serde_with::DeserializeFromStr; use crate::config::tag::Tag; #[derive(Debug, DeserializeFromStr)] #[cfg_attr(test, derive(PartialEq))] pub struct NameTemplate { parts: Vec, } #[derive(Debug)] #[cfg_attr(test, derive(PartialEq))] enum Part { Literal(String), Tag(Tag), } impl std::str::FromStr for NameTemplate { type Err = anyhow::Error; fn from_str(s: &str) -> Result { Self::parse_string(s) } } impl NameTemplate { fn parse_string(s: &str) -> Result { // Sort string into literal and tag parts while unescaping {{ and }} // to { and }. let mut parts = Vec::new(); let mut chars = s.chars().peekable(); let mut current_part = String::new(); while let Some(ch) = chars.next() { match ch { '{' => { // Handle escaped brace: {{. if chars.peek() == Some(&'{') { current_part.push('{'); chars.next(); // Consume the extra. continue; } else { // Start of a tag. if !current_part.is_empty() { parts.push(Part::Literal(current_part)); current_part = String::new(); } let tag_content = Self::parse_tag(&mut chars)?; let tag = tag_content.parse::().map_err(|_| { anyhow!("\"{}\" is not implemented", tag_content) })?; parts.push(Part::Tag(tag)); } } '}' => { // Handle escaped brace: }}. if chars.peek() == Some(&'}') { current_part.push('}'); chars.next(); // Consume the extra. } else { bail!("'}}' without '{{'"); } } _ => current_part.push(ch), } } if !current_part.is_empty() { parts.push(Part::Literal(current_part)); } Ok(NameTemplate { parts }) } fn parse_tag( chars: &mut std::iter::Peekable, ) -> Result { let mut content = String::new(); for ch in chars.by_ref() { match ch { '}' => { return Ok(content); } '{' => bail!("'{{' without '}}'"), _ => content.push(ch), } } Err(anyhow!("'{{' without '}}'")) } /// Renders a template string using the provided lookup function to convert /// Tags into replacement strings. pub fn render>( &self, lookup: impl Fn(&Tag) -> Option, ) -> Option { let mut result = String::new(); for part in &self.parts { match part { Part::Literal(literal) => result.push_str(literal), Part::Tag(tag) => result.push_str(lookup(tag)?.as_ref()), } } Some(result) } } #[cfg(test)] mod tests { use super::*; #[test] fn no_tags() { let s = String::from("Hello"); let template: Result = s.parse(); assert!(template.is_ok()); assert_eq!( template.unwrap(), NameTemplate { parts: vec![Part::Literal(s.clone())], } ); } #[test] fn good_tag() { let s = String::from("Hello {node:node.name}"); let template: Result = s.parse(); assert!(template.is_ok()); assert_eq!( template.unwrap(), NameTemplate { parts: vec![ Part::Literal(String::from("Hello ")), Part::Tag(Tag::Node(String::from("node.name"))), ], } ); } #[test] fn unimplemented_tag() { let s = String::from("Hello {world}"); let template: Result = s.parse(); assert!(template.is_err()); } #[test] fn escapes() { let s = String::from("Hello }} {{ {{ {node:node.name} }}"); let template: Result = s.parse(); assert!(template.is_ok()); assert_eq!( template.unwrap(), NameTemplate { parts: vec![ Part::Literal(String::from("Hello } { { ")), Part::Tag(Tag::Node(String::from("node.name"))), Part::Literal(String::from(" }")), ], } ); } #[test] fn extra_opening() { let s = String::from("Hello { {node:node.name}}"); let template: Result = s.parse(); assert!(template.is_err()); } #[test] fn extra_closing() { let s = String::from("Hello {node:node.name}}"); let template: Result = s.parse(); assert!(template.is_err()); } #[test] fn empty_tag() { let s = String::from("Hello {}"); let template: Result = s.parse(); assert!(template.is_err()); } #[test] fn nested_escapes() { let s = String::from("Hello {{{{}}}}"); let template: Result = s.parse(); assert!(template.is_ok()); assert_eq!( template.unwrap(), NameTemplate { parts: vec![Part::Literal(String::from("Hello {{}}")),], } ); } #[test] fn render_empty() { let s = String::from(""); let template: Result = s.parse(); assert!(template.is_ok()); let rendered = template.unwrap().render(|_| None::<&str>); assert_eq!(rendered, Some(s)); } #[test] fn render_tags() { let s = String::from("{node:node.name}{device:device.name}"); let template: Result = s.parse(); assert!(template.is_ok()); let rendered = template.unwrap().render(|tag| match tag { Tag::Node(ref s) if s == "node.name" => Some(String::from("foo")), Tag::Device(ref s) if s == "device.name" => { Some(String::from("bar")) } _ => None, }); assert_eq!(rendered, Some(String::from("foobar"))); } #[test] fn render_missing_tag() { let s = String::from("{node:node.name}{device:device.name}"); let template: Result = s.parse(); assert!(template.is_ok()); let rendered = template.unwrap().render(|tag| match tag { Tag::Node(ref s) if s == "node.name" => Some(String::from("foo")), _ => None, }); assert_eq!(rendered, None) } #[test] fn render_mixed() { let s = String::from("let {node:node.name} = {device:device.name};"); let template: Result = s.parse(); assert!(template.is_ok()); let rendered = template.unwrap().render(|tag| match tag { Tag::Node(ref s) if s == "node.name" => Some(String::from("foo")), Tag::Device(ref s) if s == "device.name" => { Some(String::from("bar")) } _ => None, }); assert_eq!(rendered, Some(String::from("let foo = bar;"))); } } wiremix-0.7.0/src/config/names.rs000066400000000000000000000422331504750620600167200ustar00rootroot00000000000000//! Implementation for [`Names`](`crate::config::Names`). Defines default name //! templates and handles resolving templates into strings. use crate::config; use crate::wirehose::state; pub use crate::config::name_template::NameTemplate; pub use crate::config::tag::Tag; use crate::config::Names; use crate::wirehose::media_class; impl Names { pub fn default_stream() -> Vec { vec!["{node:node.name}: {node:media.name}".parse().unwrap()] } pub fn default_endpoint() -> Vec { vec![ "{device:device.nick}".parse().unwrap(), "{node:node.description}".parse().unwrap(), ] } pub fn default_device() -> Vec { vec![ "{device:device.nick}".parse().unwrap(), "{device:device.description}".parse().unwrap(), ] } /// Tries to resolve an object's name. /// /// Returns a name using the first template string that can be successfully /// resolved using the resolver. /// /// Precedence is: /// /// 1. Overrides /// 2. Stream/endpoint/device default templates /// 3. Fallback pub fn resolve( &self, state: &state::State, resolver: &T, ) -> Option { resolver .templates(state, self) .iter() .find_map(|template| { template.render(|tag| resolver.resolve_tag(state, tag)) }) .or(resolver.fallback().cloned()) } } impl Default for Names { fn default() -> Self { Self { stream: Self::default_stream(), endpoint: Self::default_endpoint(), device: Self::default_device(), overrides: Vec::new(), } } } pub trait TagResolver { fn resolve_tag<'a>( &'a self, state: &'a state::State, tag: &Tag, ) -> Option<&'a str>; } pub trait NameResolver: TagResolver { fn fallback(&self) -> Option<&String>; fn templates<'a>( &self, state: &state::State, names: &'a config::Names, ) -> &'a Vec; fn name_override<'a>( &self, state: &state::State, overrides: &'a [config::NameOverride], override_type: config::OverrideType, ) -> Option<&'a Vec> { overrides.iter().find_map(|name_override| { (name_override.types.contains(&override_type) && self.resolve_tag(state, &name_override.property) == Some(&name_override.value)) .then_some(&name_override.templates) }) } } impl TagResolver for state::Device { /// Resolve a tag using Device. fn resolve_tag<'a>( &'a self, _state: &'a state::State, tag: &Tag, ) -> Option<&'a str> { match tag { Tag::Device(s) => self.props.raw(s), Tag::Node(_) => None, Tag::Client(_) => None, } } } impl NameResolver for state::Device { fn fallback(&self) -> Option<&String> { self.props.device_name() } fn templates<'a>( &self, state: &state::State, names: &'a config::Names, ) -> &'a Vec { self.name_override( state, &names.overrides, config::OverrideType::Device, ) .unwrap_or(&names.device) } } impl TagResolver for state::Node { /// Resolve a tag using Node. Falls back on resolving using the linked /// Device, if present. fn resolve_tag<'a>( &'a self, state: &'a state::State, tag: &Tag, ) -> Option<&'a str> { match tag { Tag::Node(s) => self.props.raw(s), Tag::Device(_) => { let device = state.devices.get(self.props.device_id()?)?; device.resolve_tag(state, tag) } Tag::Client(_) => { let client = state.clients.get(self.props.client_id()?)?; client.resolve_tag(state, tag) } } } } impl NameResolver for state::Node { fn fallback(&self) -> Option<&String> { self.props.node_name() } fn templates<'a>( &self, state: &state::State, names: &'a config::Names, ) -> &'a Vec { match self.props.media_class() { Some(media_class) if media_class::is_sink(media_class) || media_class::is_source(media_class) => { self.name_override( state, &names.overrides, config::OverrideType::Endpoint, ) .unwrap_or(&names.endpoint) } _ => self .name_override( state, &names.overrides, config::OverrideType::Stream, ) .unwrap_or(&names.stream), } } } impl TagResolver for state::Client { /// Resolve a tag using Client. fn resolve_tag<'a>( &'a self, _state: &'a state::State, tag: &Tag, ) -> Option<&'a str> { match tag { Tag::Client(s) => self.props.raw(s), Tag::Node(_) => None, Tag::Device(_) => None, } } } #[cfg(test)] mod tests { use super::*; use crate::config::{NameOverride, Names, OverrideType}; use crate::mock; use crate::wirehose::{state::State, ObjectId, PropertyStore, StateEvent}; #[test] fn default_stream() { // Just make sure this doesn't panic. let _ = Names::default_stream(); } #[test] fn default_endpoint() { // Just make sure this doesn't panic. let _ = Names::default_endpoint(); } #[test] fn default_device() { // Just make sure this doesn't panic. let _ = Names::default_device(); } struct Fixture { state: State, device_id: ObjectId, node_id: ObjectId, client_id: ObjectId, node_props: PropertyStore, } impl Fixture { fn new(wirehose: &mock::WirehoseHandle) -> Self { let mut state = State::default(); let device_id = ObjectId::from_raw_id(0); let node_id = ObjectId::from_raw_id(1); let client_id = ObjectId::from_raw_id(2); let mut device_props = PropertyStore::default(); device_props.set_device_name(String::from("Device name")); device_props.set_device_nick(String::from("Device nick")); let device_props = device_props; let mut node_props = PropertyStore::default(); node_props.set_node_name(String::from("Node name")); node_props.set_node_nick(String::from("Node nick")); let node_props = node_props; let mut client_props = PropertyStore::default(); client_props.set_application_name(String::from("Client name")); let client_props = client_props; let events = vec![ StateEvent::DeviceProperties { object_id: device_id, props: device_props.clone(), }, StateEvent::NodeProperties { object_id: node_id, props: node_props.clone(), }, StateEvent::ClientProperties { object_id: client_id, props: client_props.clone(), }, ]; for event in events { state.update(wirehose, event); } Self { state, device_id, node_id, client_id, node_props, } } } #[test] fn render_endpoint() { let wirehose = mock::WirehoseHandle::default(); let mut fixture = Fixture::new(&wirehose); fixture .node_props .set_media_class(String::from("Audio/Sink")); fixture.state.update( &wirehose, StateEvent::NodeProperties { object_id: fixture.node_id, props: fixture.node_props, }, ); let names = Names { endpoint: vec!["{node:node.nick}".parse().unwrap()], ..Default::default() }; let node = fixture.state.nodes.get(&fixture.node_id).unwrap(); let result = names.resolve(&fixture.state, node); assert_eq!(result, Some(String::from("Node nick"))) } #[test] fn render_endpoint_missing_tag() { let wirehose = mock::WirehoseHandle::default(); let mut fixture = Fixture::new(&wirehose); fixture .node_props .set_media_class(String::from("Audio/Sink")); fixture.state.update( &wirehose, StateEvent::NodeProperties { object_id: fixture.node_id, props: fixture.node_props, }, ); let names = Names { endpoint: vec!["{node:node.description}".parse().unwrap()], ..Default::default() }; let node = fixture.state.nodes.get(&fixture.node_id).unwrap(); let result = names.resolve(&fixture.state, node); // Should fall back to node name assert_eq!(result, Some(String::from("Node name"))) } #[test] fn render_device_missing_tag() { let wirehose = mock::WirehoseHandle::default(); let fixture = Fixture::new(&wirehose); let names = Names { device: vec!["{device:device.description}".parse().unwrap()], ..Default::default() }; let device = fixture.state.devices.get(&fixture.device_id).unwrap(); let result = names.resolve(&fixture.state, device); // Should fall back to device name assert_eq!(result, Some(String::from("Device name"))) } #[test] fn render_endpoint_linked_device() { let wirehose = mock::WirehoseHandle::default(); let mut fixture = Fixture::new(&wirehose); fixture .node_props .set_media_class(String::from("Audio/Sink")); fixture.node_props.set_device_id(fixture.device_id); fixture.state.update( &wirehose, StateEvent::NodeProperties { object_id: fixture.node_id, props: fixture.node_props, }, ); let names = Names { endpoint: vec!["{device:device.nick}".parse().unwrap()], ..Default::default() }; let node = fixture.state.nodes.get(&fixture.node_id).unwrap(); let result = names.resolve(&fixture.state, node); assert_eq!(result, Some(String::from("Device nick"))) } #[test] fn render_endpoint_linked_device_missing_tag() { let wirehose = mock::WirehoseHandle::default(); let mut fixture = Fixture::new(&wirehose); fixture .node_props .set_media_class(String::from("Audio/Sink")); fixture.node_props.set_device_id(fixture.device_id); fixture.state.update( &wirehose, StateEvent::NodeProperties { object_id: fixture.node_id, props: fixture.node_props, }, ); let names = Names { endpoint: vec!["{device:device.description}".parse().unwrap()], ..Default::default() }; let node = fixture.state.nodes.get(&fixture.node_id).unwrap(); let result = names.resolve(&fixture.state, node); // Should fall back to node name assert_eq!(result, Some(String::from("Node name"))) } #[test] fn render_endpoint_no_linked_device() { let wirehose = mock::WirehoseHandle::default(); let mut fixture = Fixture::new(&wirehose); fixture .node_props .set_media_class(String::from("Audio/Sink")); fixture.state.update( &wirehose, StateEvent::NodeProperties { object_id: fixture.node_id, props: fixture.node_props, }, ); let names = Names { endpoint: vec!["{device:device.nick}".parse().unwrap()], ..Default::default() }; let node = fixture.state.nodes.get(&fixture.node_id).unwrap(); let result = names.resolve(&fixture.state, node); // Should fall back to node name assert_eq!(result, Some(String::from("Node name"))) } #[test] fn render_stream() { let wirehose = mock::WirehoseHandle::default(); let fixture = Fixture::new(&wirehose); let names = Names { stream: vec!["{node:node.nick}".parse().unwrap()], ..Default::default() }; let node = fixture.state.nodes.get(&fixture.node_id).unwrap(); let result = names.resolve(&fixture.state, node); assert_eq!(result, Some(String::from("Node nick"))) } #[test] fn render_stream_linked_client() { let wirehose = mock::WirehoseHandle::default(); let mut fixture = Fixture::new(&wirehose); fixture.node_props.set_client_id(fixture.client_id); fixture.state.update( &wirehose, StateEvent::NodeProperties { object_id: fixture.node_id, props: fixture.node_props, }, ); let names = Names { stream: vec!["{client:application.name}".parse().unwrap()], ..Default::default() }; let node = fixture.state.nodes.get(&fixture.node_id).unwrap(); let result = names.resolve(&fixture.state, node); assert_eq!(result, Some(String::from("Client name"))) } #[test] fn render_precedence() { let wirehose = mock::WirehoseHandle::default(); let fixture = Fixture::new(&wirehose); let names = Names { stream: vec![ "{node:node.description}".parse().unwrap(), "{node:node.nick}".parse().unwrap(), ], ..Default::default() }; let node = fixture.state.nodes.get(&fixture.node_id).unwrap(); let result = names.resolve(&fixture.state, node); assert_eq!(result, Some(String::from("Node nick"))) } #[test] fn render_override_match() { let wirehose = mock::WirehoseHandle::default(); let fixture = Fixture::new(&wirehose); let names = Names { overrides: vec![NameOverride { types: vec![OverrideType::Device, OverrideType::Stream], property: Tag::Node(String::from("node.name")), value: String::from("Node name"), templates: vec![ "{node:node.description}".parse().unwrap(), "{node:node.nick}".parse().unwrap(), ], }], ..Default::default() }; let node = fixture.state.nodes.get(&fixture.node_id).unwrap(); let result = names.resolve(&fixture.state, node); assert_eq!(result, Some(String::from("Node nick"))) } #[test] fn render_override_type_mismatch() { let wirehose = mock::WirehoseHandle::default(); let fixture = Fixture::new(&wirehose); let names = Names { overrides: vec![NameOverride { types: vec![OverrideType::Device], property: Tag::Node(String::from("node.name")), value: String::from("Node name"), templates: vec!["{node:node.nick}".parse().unwrap()], }], ..Default::default() }; let node = fixture.state.nodes.get(&fixture.node_id).unwrap(); let result = names.resolve(&fixture.state, node); assert_eq!(result, Some(String::from("Node name"))) } #[test] fn render_override_value_mismatch() { let wirehose = mock::WirehoseHandle::default(); let fixture = Fixture::new(&wirehose); let names = Names { overrides: vec![NameOverride { types: vec![OverrideType::Device], property: Tag::Node(String::from("node.description")), value: String::from("Node name"), templates: vec!["{node:node.nick}".parse().unwrap()], }], ..Default::default() }; let node = fixture.state.nodes.get(&fixture.node_id).unwrap(); let result = names.resolve(&fixture.state, node); assert_eq!(result, Some(String::from("Node name"))) } #[test] fn render_override_empty_templates() { let wirehose = mock::WirehoseHandle::default(); let fixture = Fixture::new(&wirehose); let names = Names { overrides: vec![NameOverride { types: vec![OverrideType::Device, OverrideType::Stream], property: Tag::Node(String::from("node.name")), value: String::from("Node name"), templates: vec![], }], ..Default::default() }; let node = fixture.state.nodes.get(&fixture.node_id).unwrap(); let result = names.resolve(&fixture.state, node); assert_eq!(result, Some(String::from("Node name"))) } } wiremix-0.7.0/src/config/tag.rs000066400000000000000000000022201504750620600163600ustar00rootroot00000000000000//! Represent valid name templating tags use serde_with::DeserializeFromStr; #[derive(Debug, Clone, DeserializeFromStr)] #[cfg_attr(test, derive(PartialEq))] pub enum Tag { Device(String), Node(String), Client(String), } #[allow(clippy::to_string_trait_impl)] // This is not for display. impl ToString for Tag { fn to_string(&self) -> String { match self { Tag::Device(s) => { format!("device:{s}") } Tag::Node(s) => { format!("node:{s}") } Tag::Client(s) => { format!("client:{s}") } } } } impl std::str::FromStr for Tag { type Err = String; fn from_str(s: &str) -> Result { if let Some(key) = s.strip_prefix("client:") { Ok(Tag::Client(String::from(key))) } else if let Some(key) = s.strip_prefix("device:") { Ok(Tag::Device(String::from(key))) } else if let Some(key) = s.strip_prefix("node:") { Ok(Tag::Node(String::from(key))) } else { Err(format!("\"{s}\" is not implemented")) } } } wiremix-0.7.0/src/config/theme.rs000066400000000000000000000243541504750620600167230ustar00rootroot00000000000000use std::collections::HashMap; use ratatui::style::{Color, Modifier, Style}; use serde::{de::Error, Deserialize}; use crate::config::Theme; // This is what actually gets parsed from the config. #[derive(Deserialize, Debug)] #[serde(deny_unknown_fields)] pub struct ThemeOverlay { inherit: Option, default_device: Option, default_stream: Option, selector: Option, tab: Option, tab_selected: Option, tab_marker: Option, list_more: Option, node_title: Option, node_target: Option, volume: Option, volume_empty: Option, volume_filled: Option, meter_inactive: Option, meter_active: Option, meter_overload: Option, meter_center_inactive: Option, meter_center_active: Option, config_device: Option, config_profile: Option, dropdown_icon: Option, dropdown_border: Option, dropdown_item: Option, dropdown_selected: Option, dropdown_more: Option, help_border: Option, help_item: Option, help_more: Option, } #[derive(Deserialize, Debug)] #[serde(deny_unknown_fields)] struct StyleDef { pub fg: Option, pub bg: Option, pub underline_color: Option, #[serde(default = "default_modifier")] pub add_modifier: Modifier, #[serde(default = "default_modifier")] pub sub_modifier: Modifier, } fn default_modifier() -> Modifier { Modifier::empty() } impl From for Style { fn from(def: StyleDef) -> Self { Self { fg: def.fg, bg: def.bg, underline_color: def.underline_color, add_modifier: def.add_modifier, sub_modifier: def.sub_modifier, } } } impl TryFrom for Theme { type Error = anyhow::Error; fn try_from(overlay: ThemeOverlay) -> Result { let mut theme: Self = match overlay.inherit.as_deref() { Some("default") => Theme::default(), Some("nocolor") => Theme::nocolor(), Some("plain") => Theme::plain(), Some(inherit) => { anyhow::bail!("'{}' is not a built-in theme", inherit) } None => Theme::default(), }; macro_rules! set { ($field:ident) => { if let Some($field) = overlay.$field { theme.$field = $field.into(); } }; } set!(default_device); set!(default_stream); set!(selector); set!(tab); set!(tab_selected); set!(tab_marker); set!(list_more); set!(node_title); set!(node_target); set!(volume); set!(volume_empty); set!(volume_filled); set!(meter_inactive); set!(meter_active); set!(meter_overload); set!(meter_center_inactive); set!(meter_center_active); set!(config_device); set!(config_profile); set!(dropdown_icon); set!(dropdown_border); set!(dropdown_item); set!(dropdown_selected); set!(dropdown_more); set!(help_border); set!(help_item); set!(help_more); Ok(theme) } } impl Default for Theme { fn default() -> Self { Self { default_device: Style::default(), default_stream: Style::default(), selector: Style::default().fg(Color::LightCyan), tab: Style::default(), tab_selected: Style::default().fg(Color::LightCyan), tab_marker: Style::default().fg(Color::LightCyan), list_more: Style::default().fg(Color::DarkGray), node_title: Style::default(), node_target: Style::default(), volume: Style::default(), volume_empty: Style::default().fg(Color::DarkGray), volume_filled: Style::default().fg(Color::LightBlue), meter_inactive: Style::default().fg(Color::DarkGray), meter_active: Style::default().fg(Color::LightGreen), meter_overload: Style::default().fg(Color::Red), meter_center_inactive: Style::default().fg(Color::DarkGray), meter_center_active: Style::default().fg(Color::LightGreen), config_device: Style::default(), config_profile: Style::default(), dropdown_icon: Style::default(), dropdown_border: Style::default(), dropdown_item: Style::default(), dropdown_selected: Style::default() .fg(Color::LightCyan) .add_modifier(Modifier::REVERSED), dropdown_more: Style::default().fg(Color::DarkGray), help_border: Style::default(), help_item: Style::default(), help_more: Style::default().fg(Color::DarkGray), } } } impl Theme { pub fn defaults() -> HashMap { HashMap::from([ (String::from("default"), Theme::default()), (String::from("nocolor"), Theme::nocolor()), (String::from("plain"), Theme::plain()), ]) } fn nocolor() -> Self { Self { default_device: Style::default(), default_stream: Style::default(), selector: Style::default().add_modifier(Modifier::BOLD), tab: Style::default(), tab_selected: Style::default().add_modifier(Modifier::BOLD), tab_marker: Style::default().add_modifier(Modifier::BOLD), list_more: Style::default(), node_title: Style::default(), node_target: Style::default(), volume: Style::default(), volume_empty: Style::default().add_modifier(Modifier::DIM), volume_filled: Style::default().add_modifier(Modifier::BOLD), meter_inactive: Style::default().add_modifier(Modifier::DIM), meter_active: Style::default().add_modifier(Modifier::BOLD), meter_overload: Style::default().add_modifier(Modifier::BOLD), meter_center_inactive: Style::default().add_modifier(Modifier::DIM), meter_center_active: Style::default().add_modifier(Modifier::BOLD), config_device: Style::default(), config_profile: Style::default(), dropdown_icon: Style::default(), dropdown_border: Style::default(), dropdown_item: Style::default(), dropdown_selected: Style::default() .add_modifier(Modifier::REVERSED | Modifier::BOLD), dropdown_more: Style::default(), help_border: Style::default(), help_item: Style::default(), help_more: Style::default(), } } fn plain() -> Self { Self { default_device: Style::default(), default_stream: Style::default(), selector: Style::default(), tab: Style::default(), tab_selected: Style::default(), tab_marker: Style::default(), list_more: Style::default(), node_title: Style::default(), node_target: Style::default(), volume: Style::default(), volume_empty: Style::default(), volume_filled: Style::default(), meter_inactive: Style::default(), meter_active: Style::default(), meter_overload: Style::default(), meter_center_inactive: Style::default(), meter_center_active: Style::default(), config_device: Style::default(), config_profile: Style::default(), dropdown_icon: Style::default(), dropdown_border: Style::default(), dropdown_item: Style::default(), dropdown_selected: Style::default(), dropdown_more: Style::default(), help_border: Style::default(), help_item: Style::default(), help_more: Style::default(), } } /// Merge deserialized themes with defaults pub fn merge<'de, D>( deserializer: D, ) -> Result, D::Error> where D: serde::Deserializer<'de>, { let configured = HashMap::::deserialize(deserializer)?; let mut merged = configured .into_iter() .map(|(key, value)| { Theme::try_from(value) .map_err(D::Error::custom) .map(move |theme| (key, theme)) }) .collect::, D::Error>>()?; if !merged.contains_key("default") { merged.insert(String::from("default"), Theme::default()); } if !merged.contains_key("nocolor") { merged.insert(String::from("nocolor"), Theme::nocolor()); } if !merged.contains_key("plain") { merged.insert(String::from("plain"), Theme::plain()); } Ok(merged) } } #[cfg(test)] mod tests { use super::*; #[test] fn unknown_field_theme() { let config = r#" unknown = "unknown" "#; assert!(toml::from_str::(config).is_err()); } #[test] fn unknown_field_style() { let config = r#" unknown = "unknown" "#; assert!(toml::from_str::(config).is_err()); } #[test] fn inherit_nonexistent() { let config = r#" inherit = "doesntexist" tab_selected = { } "#; let overlay = toml::from_str::(config).unwrap(); let theme = Theme::try_from(overlay); assert!(theme.is_err()); } #[test] fn inherit() { for (builtin_key, builtin) in Theme::defaults().iter() { let config = format!( r#" inherit = "{builtin_key}" tab_selected = {{ }} "# ); let overlay = toml::from_str::(&config).unwrap(); let theme = Theme::try_from(overlay).unwrap(); assert_eq!(theme.tab_selected, Style::default()); assert_eq!(theme.selector, builtin.selector); } } } wiremix-0.7.0/src/device_kind.rs000066400000000000000000000002071504750620600166070ustar00rootroot00000000000000//! Type representing whether a device is sink or source. #[derive(Debug, Clone, Copy)] pub enum DeviceKind { Sink, Source, } wiremix-0.7.0/src/device_widget.rs000066400000000000000000000111431504750620600171460ustar00rootroot00000000000000//! A Ratatui widget representing a single PipeWire node in an object list. use ratatui::{ layout::Flex, prelude::{Buffer, Constraint, Direction, Layout, Rect}, text::{Line, Span}, widgets::{StatefulWidget, Widget}, }; use crossterm::event::{MouseButton, MouseEventKind}; use smallvec::smallvec; use crate::app::{Action, MouseArea}; use crate::config::Config; use crate::object_list::ObjectList; use crate::view; pub struct DeviceWidget<'a> { device: &'a view::Device, selected: bool, config: &'a Config, } impl<'a> DeviceWidget<'a> { pub fn new( device: &'a view::Device, selected: bool, config: &'a Config, ) -> Self { Self { device, selected, config, } } /// Height of a full device display. pub fn height() -> u16 { 3 } /// Spacing between objects pub fn spacing() -> u16 { 2 } /// Area for the target dropdown pub fn dropdown_area( object_list: &ObjectList, list_area: &Rect, object_area: &Rect, ) -> Rect { // Number of items to show at once let max_visible_items = 5; let max_target_length = object_list .targets .iter() .map(|(_, title)| title.len()) .max() .unwrap_or(0); // Position the dropdown so that the first item is over the displayed item let x = list_area.left().saturating_add(4); let y = object_area.top().saturating_add(1); // Add 2 for vertical borders and 2 for highlight symbol let width = max_target_length.saturating_add(4) as u16; let height = std::cmp::min(max_visible_items, object_list.targets.len()) .saturating_add(2) as u16; // Add 2 for horizontal borders Rect::new(x, y, width, height) } } impl StatefulWidget for DeviceWidget<'_> { type State = Vec; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { let mouse_areas = state; mouse_areas.push(( area, smallvec![MouseEventKind::Down(MouseButton::Left)], smallvec![Action::SelectObject(self.device.object_id)], )); let layout = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Length(1), // selected_area Constraint::Min(0), // node_area ]) .split(area); let selected_area = layout[0]; let node_area = layout[1]; if self.selected { let rows = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ]) .split(selected_area); let style = self.config.theme.selector; Line::from(Span::styled(&self.config.char_set.selector_top, style)) .render(rows[0], buf); Line::from(Span::styled( &self.config.char_set.selector_middle, style, )) .render(rows[1], buf); Line::from(Span::styled( &self.config.char_set.selector_bottom, style, )) .render(rows[2], buf); } let layout = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), // title_area Constraint::Length(1), // target_area ]) .spacing(1) .flex(Flex::Legacy) .split(node_area); let title_area = layout[0]; let target_area = layout[1]; Line::from(vec![ Span::from(" "), Span::styled(&self.device.title, self.config.theme.config_device), ]) .render(title_area, buf); Line::from(vec![ Span::from(" "), Span::styled( &self.config.char_set.dropdown_icon, self.config.theme.dropdown_icon, ), Span::from(" "), Span::styled( &self.device.target_title, self.config.theme.config_profile, ), ]) .render(target_area, buf); mouse_areas.push(( target_area, smallvec![MouseEventKind::Down(MouseButton::Left)], smallvec![ Action::SelectObject(self.device.object_id), Action::ActivateDropdown ], )); } } wiremix-0.7.0/src/dropdown_widget.rs000066400000000000000000000117431504750620600175510ustar00rootroot00000000000000//! A Ratatui widget for a dropdown menu of options pertaining to a node or device //! widget. use ratatui::{ prelude::{Alignment, Buffer, Rect, Widget}, text::{Line, Span}, widgets::{Block, Borders, Clear, List, StatefulWidget}, }; use crossterm::event::{MouseButton, MouseEventKind}; use smallvec::smallvec; use crate::app::{Action, MouseArea}; use crate::config::Config; use crate::object_list::ObjectList; pub struct DropdownWidget<'a> { object_list: &'a mut ObjectList, dropdown_area: &'a Rect, config: &'a Config, } impl<'a> DropdownWidget<'a> { pub fn new( object_list: &'a mut ObjectList, dropdown_area: &'a Rect, config: &'a Config, ) -> Self { Self { object_list, dropdown_area, config, } } } impl StatefulWidget for DropdownWidget<'_> { type State = Vec; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { let mouse_areas = state; let targets: Vec<_> = self .object_list .targets .iter() .map(|(_, title)| title.clone()) .collect(); let dropdown_area = self.dropdown_area.clamp(area); // Click anywhere else in the object list to close the dropdown. mouse_areas.push(( area, smallvec![MouseEventKind::Down(MouseButton::Left)], smallvec![Action::CloseDropdown], )); // But clicking on the border does nothing. mouse_areas.push(( dropdown_area, smallvec![MouseEventKind::Down(MouseButton::Left)], smallvec![], )); Clear.render(dropdown_area, buf); let highlight_symbol = format!("{} ", self.config.char_set.dropdown_selector); let list = List::new(targets) .block( Block::default() .borders(Borders::ALL) .border_style(self.config.theme.dropdown_border) .border_type(self.config.char_set.dropdown_border), ) .style(self.config.theme.dropdown_item) .highlight_symbol(&highlight_symbol) .highlight_style(self.config.theme.dropdown_selected); StatefulWidget::render( &list, dropdown_area, buf, &mut self.object_list.dropdown_state, ); let first_index = self.object_list.dropdown_state.offset(); // Add a clickable indicator to the top border if there or more items // if scrolled up if first_index > 0 { let top_area = Rect::new( dropdown_area.x, dropdown_area.y, dropdown_area.width, 1, ); Line::from(Span::styled( &self.config.char_set.dropdown_more, self.config.theme.dropdown_more, )) .alignment(Alignment::Center) .render(top_area, buf); mouse_areas.push(( top_area, smallvec![MouseEventKind::Down(MouseButton::Left)], smallvec![Action::MoveUp], )); } // Subtract 2 for vertical borders let dropdown_area_inner_height = (dropdown_area.height as usize).saturating_sub(2); let last_index = first_index.saturating_add(dropdown_area_inner_height); // Add a clickable indicator to the bottom border if there or more // items if scrolled down if last_index < self.object_list.targets.len() { let y = dropdown_area .y .saturating_add(dropdown_area.height.saturating_sub(1)); let bottom_area = Rect::new(dropdown_area.x, y, dropdown_area.width, 1); Line::from(Span::styled( &self.config.char_set.dropdown_more, self.config.theme.dropdown_more, )) .alignment(Alignment::Center) .render(bottom_area, buf); mouse_areas.push(( bottom_area, smallvec![MouseEventKind::Down(MouseButton::Left)], smallvec![Action::MoveDown], )); } for i in 0..(dropdown_area.height - 2) { let target_area = Rect::new( dropdown_area.x, dropdown_area.y.saturating_add(1).saturating_add(i), dropdown_area.width, 1, ); let target = self .object_list .targets .iter() .skip(first_index) .nth(i as usize) .map(|(target, _)| target); if let Some(target) = target { mouse_areas.push(( target_area, smallvec![MouseEventKind::Down(MouseButton::Left)], smallvec![Action::SetTarget(*target)], )); } } } } wiremix-0.7.0/src/event.rs000066400000000000000000000007161504750620600154710ustar00rootroot00000000000000//! Input events for the application. //! //! These come from [`wirehose`](`crate::wirehose`) (PipeWire events) and from //! [`input`](`crate::input`) (terminal input events). use crate::wirehose::Event as PipewireEvent; #[derive(Debug)] pub enum Event { Input(crossterm::event::Event), Pipewire(PipewireEvent), } impl From for Event { fn from(event: crossterm::event::Event) -> Self { Event::Input(event) } } wiremix-0.7.0/src/help.rs000066400000000000000000000106511504750620600152770ustar00rootroot00000000000000use crossterm::event::{MouseButton, MouseEventKind}; use ratatui::{ prelude::{Alignment, Buffer, Constraint, Rect, Widget}, text::{Line, Span}, widgets::{Block, Borders, Padding, Row, StatefulWidget, Table}, }; use smallvec::smallvec; use crate::app::{Action, MouseArea}; use crate::config::Config; pub struct HelpWidget<'a> { pub config: &'a Config, } pub struct HelpWidgetState<'a> { pub mouse_areas: &'a mut Vec, pub help_position: &'a mut u16, } impl HelpWidget<'_> { const BORDER_WIDTH: usize = 1; const BORDER_PADDING: u16 = 2; const COLUMN_PADDING: u16 = 2; pub fn base_width() -> usize { // * 2 because there are 2 horizontal borders Self::BORDER_WIDTH * 2 + (Self::BORDER_PADDING as usize * 2) + (Self::COLUMN_PADDING as usize) } } impl<'a> StatefulWidget for HelpWidget<'a> { type State = HelpWidgetState<'a>; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { // App initialized mouse_areas so clicking anywhere closes this widget. // Make it safe to click within the widget. state.mouse_areas.push(( area, smallvec![MouseEventKind::Down(MouseButton::Left)], smallvec![Action::Nothing], )); let borders = Block::default() .borders(Borders::ALL) .border_style(self.config.theme.help_border) .border_type(self.config.char_set.help_border) .padding(Padding::horizontal(Self::BORDER_PADDING)); let list_area = borders.inner(area); borders.render(area, buf); state.mouse_areas.push(( list_area, smallvec![MouseEventKind::ScrollUp], smallvec![Action::MoveUp], )); state.mouse_areas.push(( list_area, smallvec![MouseEventKind::ScrollDown], smallvec![Action::MoveDown], )); // Fix help_position if we are scrolled beyond the bottom of the list let rows_total = self.config.help.rows.len(); { let rows_visible = rows_total.saturating_sub((*state.help_position).into()); if rows_visible < list_area.height.into() { *state.help_position = rows_total .saturating_sub(list_area.height.into()) .try_into() .unwrap_or(u16::MAX); } } // Add a clickable indicator to the top border if there are more items if *state.help_position > 0 { let top_area = Rect::new(area.x, area.y, area.width, 1); Line::from(Span::styled( &self.config.char_set.help_more, self.config.theme.help_more, )) .alignment(Alignment::Center) .render(top_area, buf); state.mouse_areas.push(( top_area, smallvec![MouseEventKind::Down(MouseButton::Left)], smallvec![Action::MoveUp], )); } // Add a clickable indiciator to the bottom border if there are more // items if usize::from(*state.help_position + list_area.height) < rows_total { let y = area.y.saturating_add(area.height.saturating_sub(1)); let bottom_area = Rect::new(area.x, y, area.width, 1); Line::from(Span::styled( &self.config.char_set.help_more, self.config.theme.help_more, )) .alignment(Alignment::Center) .render(bottom_area, buf); state.mouse_areas.push(( bottom_area, smallvec![MouseEventKind::Down(MouseButton::Left)], smallvec![Action::MoveDown], )); } let rows: Vec = self .config .help .rows .iter() .skip((*state.help_position).into()) .map(|row| Row::new(row.clone())) .collect(); let widths: Vec = self .config .help .widths .iter() .map(|width| { Constraint::Max((*width).try_into().unwrap_or(u16::MAX)) }) .collect(); let table = Table::new(rows, widths) .style(self.config.theme.help_item) .column_spacing(Self::COLUMN_PADDING); Widget::render(table, list_area, buf); } } wiremix-0.7.0/src/input.rs000066400000000000000000000037601504750620600155110ustar00rootroot00000000000000//! Setup and teardown of terminal input. //! //! [`spawn()`] starts the input thead. use std::sync::{mpsc, Arc}; use std::thread; use std::time::Duration; use crossterm::event::EventStream; use futures::{channel::oneshot, FutureExt, StreamExt}; use futures_timer::Delay; use crate::event::Event; /// Spawns a thread to listen for terminal input events. /// /// [`Event`](`crate::event::Event`)s are sent to tx. /// /// Returns a [`InputHandle`] to automatically clean up the thread. pub fn spawn(tx: Arc>) -> InputHandle { let (shutdown_tx, shutdown_rx) = oneshot::channel(); let handle = thread::spawn(move || { futures::executor::block_on(async move { input_loop(shutdown_rx, tx).await; }); }); InputHandle { tx: Some(shutdown_tx), handle: Some(handle), } } /// Handle for the input thread. /// /// On cleanup, the thread will be notified to quit and will be joined. pub struct InputHandle { tx: Option>, handle: Option>, } impl Drop for InputHandle { fn drop(&mut self) { if let Some(tx) = self.tx.take() { let _ = tx.send(()); } if let Some(handle) = self.handle.take() { let _ = handle.join(); } } } async fn input_loop( shutdown_rx: oneshot::Receiver<()>, tx: Arc>, ) { let mut reader = EventStream::new(); let mut shutdown = shutdown_rx.fuse(); loop { let mut delay = Delay::new(Duration::from_millis(1_000)).fuse(); let mut event = reader.next().fuse(); futures::select! { _ = shutdown => break, _ = delay => { }, maybe_event = event => { match maybe_event { Some(Ok(event)) => { let _ = tx.send(Event::from(event)); } None => break, _ => {}, } } } } } wiremix-0.7.0/src/lib.rs000066400000000000000000000033541504750620600151170ustar00rootroot00000000000000pub mod app; pub mod config; pub mod device_kind; pub mod device_widget; pub mod dropdown_widget; pub mod event; pub mod help; pub mod input; pub mod meter; pub mod node_widget; pub mod object_list; pub mod opt; pub mod view; pub mod wirehose; #[cfg(feature = "trace")] pub mod trace; #[cfg(test)] mod mock { use crate::wirehose::{CommandSender, ObjectId}; #[derive(Default)] pub struct WirehoseHandle {} impl CommandSender for WirehoseHandle { fn node_capture_start( &self, _object_id: ObjectId, _object_serial: u64, _capture_sink: bool, ) { } fn node_capture_stop(&self, _object_id: ObjectId) {} fn node_mute(&self, _object_id: ObjectId, _mute: bool) {} fn node_volumes(&self, _object_id: ObjectId, _volumes: Vec) {} fn device_mute( &self, _object_id: ObjectId, _route_index: i32, _route_device: i32, _mute: bool, ) { } fn device_set_profile( &self, _object_id: ObjectId, _profile_index: i32, ) { } fn device_set_route( &self, _object_id: ObjectId, _route_index: i32, _route_device: i32, ) { } fn device_volumes( &self, _object_id: ObjectId, _route_index: i32, _route_device: i32, _volumes: Vec, ) { } fn metadata_set_property( &self, _object_id: ObjectId, _subject: u32, _key: String, _type_: Option, _value: Option, ) { } } } wiremix-0.7.0/src/main.rs000066400000000000000000000037241504750620600152760ustar00rootroot00000000000000use std::io::stdout; use std::sync::{mpsc, Arc}; use anyhow::Result; use crossterm::{ event::{DisableMouseCapture, EnableMouseCapture}, ExecutableCommand, }; use wiremix::app; use wiremix::config::Config; use wiremix::event::Event; use wiremix::input; use wiremix::opt::Opt; use wiremix::wirehose::Session; fn main() -> Result<()> { // Event channel for sending PipeWire and input events to the UI let (event_tx, event_rx) = mpsc::channel(); let event_tx = Arc::new(event_tx); // Parse command-line arguments let opt = Opt::parse(); let config_default_path = Config::default_path(); let config_path = opt.config.as_deref().or(config_default_path.as_deref()); let config = Config::try_new(config_path, &opt)?; // Handler for events from PipeWire - just wrap them and put them on the // event channel. let event_handler = { let event_tx = Arc::clone(&event_tx); move |event| event_tx.send(Event::Pipewire(event)).is_ok() }; // Spawn the wirehose thread to monitor PipeWire let client = Session::spawn(config.remote.clone(), event_handler)?; let _input_handle = input::spawn(Arc::clone(&event_tx)); #[cfg(debug_assertions)] if opt.dump_events { // Event dumping mode for debugging the monitor code for received in event_rx { use wiremix::event::Event; match received { Event::Pipewire(event) => print!("{event:?}\r\n"), event => { print!("{event:?}\r\n"); } } } return Ok(()); } // Normal UI mode let support_mouse = config.mouse; if support_mouse { stdout().execute(EnableMouseCapture)?; } let mut terminal = ratatui::init(); let app_result = app::App::new(&client, event_rx, config).run(&mut terminal); ratatui::restore(); if support_mouse { stdout().execute(DisableMouseCapture)?; } app_result } wiremix-0.7.0/src/meter.rs000066400000000000000000000120201504750620600154530ustar00rootroot00000000000000//! Peak level meter rendering. use ratatui::{ prelude::{Alignment, Buffer, Constraint, Direction, Layout, Rect, Widget}, text::{Line, Span}, }; use crate::config::Config; fn render_peak(peak: f32, area: Rect) -> (usize, usize, usize) { fn normalize(value: f32) -> f32 { let amplitude = 10.0_f32.powf(value / 60.0); let min = 10.0_f32.powf(-60.0 / 60.0); let max = 10.0_f32.powf(6.0 / 60.0); (amplitude - min) / (max - min) } // Convert to dB between -20 and +3 let db = 20.0 * (peak + 1e-10).log10(); let vu_value = db.clamp(-60.0, 6.0); let meter = normalize(vu_value); let total_chars = area.width as usize; let lit = ((meter * total_chars as f32).round() as usize).min(total_chars); // Values above 0.0 will be colored differently let zero_char = (normalize(0.0) * total_chars as f32).round() as usize; // Assign colors let active_size = lit.min(zero_char); let overload_size = lit.saturating_sub(zero_char); let inactive_size = total_chars .saturating_sub(active_size) .saturating_sub(overload_size); (active_size, overload_size, inactive_size) } pub fn render_stereo( meter_area: Rect, buf: &mut Buffer, peaks: Option<(f32, f32)>, config: &Config, ) { let layout = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Fill(2), // meter_left Constraint::Length(2), // meter_live Constraint::Fill(2), // meter_right ]) .spacing(1) .split(meter_area); let meter_left = layout[0]; let meter_live = layout[1]; let meter_right = layout[2]; let (left_peak, right_peak) = peaks.unwrap_or_default(); let area = meter_left; let (active_peak, overload_peak, inactive_peak) = render_peak(left_peak, area); Line::from(vec![ Span::styled( config.char_set.meter_left_inactive.repeat(inactive_peak), config.theme.meter_inactive, ), Span::styled( config.char_set.meter_left_overload.repeat(overload_peak), config.theme.meter_overload, ), Span::styled( config.char_set.meter_left_active.repeat(active_peak), config.theme.meter_active, ), ]) .alignment(Alignment::Right) .render(area, buf); let area = meter_right; let (active_peak, overload_peak, inactive_peak) = render_peak(right_peak, area); Line::from(vec![ Span::styled( config.char_set.meter_right_active.repeat(active_peak), config.theme.meter_active, ), Span::styled( config.char_set.meter_right_overload.repeat(overload_peak), config.theme.meter_overload, ), Span::styled( config.char_set.meter_right_inactive.repeat(inactive_peak), config.theme.meter_inactive, ), ]) .render(area, buf); let live_line = if peaks.is_some() { Line::from(Span::styled( format!( "{}{}", &config.char_set.meter_center_left_active, &config.char_set.meter_center_right_active, ), config.theme.meter_center_active, )) } else { Line::from(Span::styled( format!( "{}{}", &config.char_set.meter_center_left_inactive, &config.char_set.meter_center_right_inactive ), config.theme.meter_center_inactive, )) }; live_line.render(meter_live, buf); } pub fn render_mono( meter_area: Rect, buf: &mut Buffer, peak: Option, config: &Config, ) { let mono_peak = peak.unwrap_or_default(); let layout = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Length(1), // meter_live Constraint::Fill(2), // meter_mono ]) .spacing(1) .split(meter_area); let meter_live = layout[0]; let meter_mono = layout[1]; let area = meter_mono; let (active_peak, overload_peak, inactive_peak) = render_peak(mono_peak, area); Line::from(vec![ Span::styled( config.char_set.meter_right_active.repeat(active_peak), config.theme.meter_active, ), Span::styled( config.char_set.meter_right_overload.repeat(overload_peak), config.theme.meter_overload, ), Span::styled( config.char_set.meter_right_inactive.repeat(inactive_peak), config.theme.meter_inactive, ), ]) .render(area, buf); let live_line = if peak.is_some() { Line::from(Span::styled( &config.char_set.meter_center_right_active, config.theme.meter_center_active, )) } else { Line::from(Span::styled( &config.char_set.meter_center_right_inactive, config.theme.meter_center_inactive, )) }; live_line.render(meter_live, buf); } wiremix-0.7.0/src/node_widget.rs000066400000000000000000000375721504750620600166520ustar00rootroot00000000000000//! A Ratatui widget representing a single PipeWire node in an object list. use ratatui::{ layout::Flex, prelude::{Alignment, Buffer, Constraint, Direction, Layout, Rect}, text::{Line, Span}, widgets::{StatefulWidget, Widget}, }; use crossterm::event::{MouseButton, MouseEventKind}; use smallvec::smallvec; use crate::app::{Action, MouseArea}; use crate::config::{Config, Peaks}; use crate::device_kind::DeviceKind; use crate::meter; use crate::object_list::ObjectList; use crate::view; fn is_default(node: &view::Node, device_kind: Option) -> bool { match device_kind { Some(DeviceKind::Sink) => node.is_default_sink, Some(DeviceKind::Source) => node.is_default_source, None => false, } } fn node_title(node: &view::Node, device_kind: Option) -> &str { match (device_kind, &node.title_source_sink) { ( Some(DeviceKind::Source | DeviceKind::Sink), Some(title_source_sink), ) => title_source_sink, _ => &node.title, } } pub struct NodeWidget<'a> { config: &'a Config, device_kind: Option, node: &'a view::Node, selected: bool, } impl<'a> NodeWidget<'a> { pub fn new( config: &'a Config, device_kind: Option, node: &'a view::Node, selected: bool, ) -> Self { Self { config, device_kind, node, selected, } } /// Height of a full node display. pub fn height() -> u16 { 3 } /// Spacing between nodes pub fn spacing() -> u16 { 2 } /// Area for the target dropdown pub fn dropdown_area( object_list: &ObjectList, list_area: &Rect, object_area: &Rect, ) -> Rect { // Number of items to show at once let max_visible_items = 5; let max_target_length = object_list .targets .iter() .map(|(_, title)| title.len()) .max() .unwrap_or(0); // Add 2 for vertical borders and 2 for highlight symbol let width = max_target_length.saturating_add(4) as u16; let height = std::cmp::min(max_visible_items, object_list.targets.len()) .saturating_add(2) as u16; // Plus 2 for horizontal borders // Align to the right of the list area let x = list_area.right().saturating_sub(width); // Subtract 1 for the top border let y = object_area.top().saturating_sub(1); Rect::new(x, y, width, height) } } impl StatefulWidget for NodeWidget<'_> { type State = Vec; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { let mouse_areas = state; mouse_areas.extend([ ( area, smallvec![MouseEventKind::Down(MouseButton::Left)], smallvec![Action::SelectObject(self.node.object_id)], ), ( area, smallvec![MouseEventKind::Down(MouseButton::Right)], smallvec![ Action::SelectObject(self.node.object_id), Action::SetDefault ], ), ( area, smallvec![MouseEventKind::ScrollLeft], smallvec![ Action::SelectObject(self.node.object_id), Action::SetRelativeVolume(-0.01), ], ), ( area, smallvec![MouseEventKind::ScrollRight], smallvec![ Action::SelectObject(self.node.object_id), Action::SetRelativeVolume(0.01), ], ), ]); // Split area into a selection indicator on the left and the main node // area on the right let layout = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Length(1), // selector_area Constraint::Min(0), // node_area ]) .split(area); let selector_area = layout[0]; let node_area = layout[1]; SelectorWidget::new(self.config, self.selected) .render(selector_area, buf); // Split the main node area into a header line and a line for the // volume bar and peak meter. let layout = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), // header_area Constraint::Length(1), // bar_area ]) .spacing(1) .flex(Flex::Legacy) .split(node_area); let header_area = layout[0]; let bar_area = layout[1]; HeaderWidget::new(self.config, self.device_kind, self.node).render( header_area, buf, mouse_areas, ); // Render volume bar and (if enabled) peak meter let volume = VolumeWidget::new(self.config, self.node); if self.config.peaks == Peaks::Off { let layout = Layout::default() .direction(Direction::Horizontal) .constraints(vec![ Constraint::Length(2), // _padding Constraint::Fill(9), // volume_area Constraint::Fill(1), // _padding ]) .split(bar_area); // index 0 is _padding let volume_area = layout[1]; volume.render(volume_area, buf, mouse_areas); } else { let layout = Layout::default() .direction(Direction::Horizontal) .constraints(vec![ Constraint::Length(2), // _padding Constraint::Fill(4), // volume_area Constraint::Fill(1), // _padding Constraint::Fill(4), // meter_area Constraint::Fill(1), // _padding ]) .split(bar_area); // index 0 is _padding let volume_area = layout[1]; // index 2 is _padding let meter_area = layout[3]; volume.render(volume_area, buf, mouse_areas); MeterWidget::new(self.config, self.node).render(meter_area, buf); } } } struct SelectorWidget<'a> { config: &'a Config, selected: bool, } impl<'a> SelectorWidget<'a> { fn new(config: &'a Config, selected: bool) -> Self { Self { config, selected } } } impl Widget for SelectorWidget<'_> { fn render(self, area: Rect, buf: &mut Buffer) { if self.selected { // Render and indication that this is the selected node. let rows = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ]) .split(area); let style = self.config.theme.selector; // Render the selected node indicator Span::styled(&self.config.char_set.selector_top, style) .render(rows[0], buf); Span::styled(&self.config.char_set.selector_middle, style) .render(rows[1], buf); Span::styled(&self.config.char_set.selector_bottom, style) .render(rows[2], buf); } } } struct HeaderWidget<'a> { config: &'a Config, device_kind: Option, node: &'a view::Node, } impl<'a> HeaderWidget<'a> { fn new( config: &'a Config, device_kind: Option, node: &'a view::Node, ) -> Self { Self { config, device_kind, node, } } fn target_line(&self) -> Line<'_> { match self.node.target { Some(view::Target::Default) => { // Add the default target indicator Line::from(vec![ Span::styled( &self.config.char_set.default_stream, self.config.theme.default_stream, ), Span::from(" "), Span::styled( &self.node.target_title, self.config.theme.node_target, ), ]) } _ => Line::from(Span::styled( &self.node.target_title, self.config.theme.node_target, )), } } fn title_line(&self) -> Line<'_> { let node_title = node_title(self.node, self.device_kind); let default_span = if is_default(self.node, self.device_kind) { Span::styled( &self.config.char_set.default_device, self.config.theme.default_device, ) } else { Span::from(" ") }; Line::from(vec![ default_span, Span::from(" "), Span::styled(node_title, self.config.theme.node_title), ]) } } impl StatefulWidget for HeaderWidget<'_> { type State = Vec; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { let mouse_areas = state; let target_line = self.target_line(); let target_width = target_line.width().try_into().unwrap_or(u16::MAX); // See if we can fit the whole title on the screen. We'll scrap this // layout if it doesn't fit. let layout = Layout::default() .direction(Direction::Horizontal) .constraints([ // Min(1) so we always show the default indicator Constraint::Min(1), // title_area Constraint::Length(target_width), // target_area ]) .horizontal_margin(1) .spacing(1) .split(area); let mut title_area = layout[0]; let mut target_area = layout[1]; let title_line = self.title_line(); if title_line.width() > title_area.width as usize { // It doesn't fit let layout = Layout::default() .direction(Direction::Horizontal) .constraints([ // Min(1) so we always show the default indicator Constraint::Min(1), // title_area Constraint::Length(3), // ellipses_area Constraint::Length(1), // _padding Constraint::Length(target_width), // target_area ]) .horizontal_margin(1) .split(area); title_area = layout[0]; let ellipses_area = layout[1]; target_area = layout[3]; Span::styled("...", self.config.theme.node_title) .render(ellipses_area, buf); } let (title_area, target_area) = (title_area, target_area); target_line .alignment(Alignment::Right) .render(target_area, buf); mouse_areas.push(( target_area, smallvec![MouseEventKind::Down(MouseButton::Left)], smallvec![ Action::SelectObject(self.node.object_id), Action::ActivateDropdown ], )); title_line.render(title_area, buf); } } struct VolumeWidget<'a> { config: &'a Config, node: &'a view::Node, } impl<'a> VolumeWidget<'a> { fn new(config: &'a Config, node: &'a view::Node) -> Self { Self { config, node } } } impl StatefulWidget for VolumeWidget<'_> { type State = Vec; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { let mouse_areas = state; let max_volume = self.config.max_volume_percent / 100.0; let layout = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Length(5), // volume_label Constraint::Min(0), // volume_bar ]) .spacing(1) .split(area); let volume_label = layout[0]; let volume_bar = layout[1]; let volumes = &self.node.volumes; if !volumes.is_empty() { let mean = volumes.iter().sum::() / volumes.len() as f32; let volume = mean.cbrt(); let percent = (volume * 100.0).round() as u32; Line::from(Span::styled( format!("{percent}%"), self.config.theme.volume, )) .alignment(Alignment::Right) .render(volume_label, buf); let count = ((volume.clamp(0.0, max_volume) / max_volume) * volume_bar.width as f32) .round() as usize; let filled = self.config.char_set.volume_filled.repeat(count); let blank = self .config .char_set .volume_empty .repeat((volume_bar.width as usize).saturating_sub(count)); Line::from(vec![ Span::styled(filled, self.config.theme.volume_filled), Span::styled(blank, self.config.theme.volume_empty), ]) .render(volume_bar, buf); } if self.node.mute { Line::from("muted").render(volume_label, buf); } mouse_areas.push(( volume_label, smallvec![MouseEventKind::Down(MouseButton::Left)], smallvec![ Action::SelectObject(self.node.object_id), Action::ToggleMute ], )); // Add mouse areas for setting volume for i in 0..=volume_bar.width { let volume_area = Rect::new( volume_bar.x.saturating_add(i), volume_bar.y, 1, volume_bar.height, ); let volume_step = max_volume / volume_bar.width as f32; let volume = volume_step * i as f32; // Make the volume sticky around 100%. Otherwise it's often not // possible to select by mouse. let sticky_volume = if (1.0 - volume).abs() <= volume_step { 1.0 } else { volume }; mouse_areas.push(( volume_area, smallvec![ MouseEventKind::Down(MouseButton::Left), MouseEventKind::Drag(MouseButton::Left), ], smallvec![ Action::SelectObject(self.node.object_id), Action::SetAbsoluteVolume(sticky_volume), ], )); } } } struct MeterWidget<'a> { config: &'a Config, node: &'a view::Node, } impl<'a> MeterWidget<'a> { fn new(config: &'a Config, node: &'a view::Node) -> Self { Self { config, node } } } impl Widget for MeterWidget<'_> { fn render(self, area: Rect, buf: &mut Buffer) { match self.node.peaks.as_deref() { Some([left, right]) if self.config.peaks != Peaks::Mono => { meter::render_stereo( area, buf, Some((*left, *right)), self.config, ) } Some(peaks @ [..]) => meter::render_mono( area, buf, (!peaks.is_empty()) .then_some(peaks.iter().sum::() / peaks.len() as f32), self.config, ), _ => match self .node .positions .as_ref() .map(|positions| positions.len()) { Some(2) if self.config.peaks != Peaks::Mono => { meter::render_stereo(area, buf, None, self.config) } _ => meter::render_mono(area, buf, None, self.config), }, } } } wiremix-0.7.0/src/object_list.rs000066400000000000000000000474701504750620600166610ustar00rootroot00000000000000//! A Ratatui widget for an interactable list of PipeWire objects. use ratatui::{ prelude::{Alignment, Buffer, Constraint, Direction, Layout, Rect}, text::{Line, Span}, widgets::{ListState, StatefulWidget, Widget}, }; use crossterm::event::{MouseButton, MouseEventKind}; use smallvec::smallvec; use crate::app::{Action, MouseArea}; use crate::config::Config; use crate::device_kind::DeviceKind; use crate::device_widget::DeviceWidget; use crate::dropdown_widget::DropdownWidget; use crate::node_widget::NodeWidget; use crate::view::{self, ListKind, VolumeAdjustment}; use crate::wirehose::ObjectId; /// ObjectList stores information for filtering and displaying a subset of /// objects from a [`View`](`crate::view::View`). /// /// Control operations pertaining to individual objects are handled here. #[derive(Default)] pub struct ObjectList { /// Index of the first object in viewport top: usize, /// ID of the currently selected object pub selected: Option, /// Which set of objects to use from the View list_kind: ListKind, /// Default device type to use for defaults and node rendering device_kind: Option, /// Target dropdown state pub dropdown_state: ListState, /// Targets pub targets: Vec<(view::Target, String)>, } impl ObjectList { pub fn new(list_kind: ListKind, device_kind: Option) -> Self { Self { top: 0, selected: None, list_kind, device_kind, ..Default::default() } } pub fn down(&mut self, view: &view::View) { if self.dropdown_state.selected().is_some() { self.dropdown_state.select_next(); } else { let new_selected = view.next_id(self.list_kind, self.selected); if new_selected.is_some() { self.select(new_selected); } } } pub fn up(&mut self, view: &view::View) { if self.dropdown_state.selected().is_some() { self.dropdown_state.select_previous(); } else { let new_selected = view.previous_id(self.list_kind, self.selected); if new_selected.is_some() { self.select(new_selected); } } } fn dropdown_open(&mut self, view: &view::View) { let targets = match self.list_kind { ListKind::Node(_) => self .selected .and_then(|object_id| view.node_targets(object_id)), ListKind::Device => self .selected .and_then(|object_id| view.device_targets(object_id)), }; if let Some((targets, index)) = targets { if !targets.is_empty() { self.targets = targets; self.dropdown_state.select(Some(index)); } } } fn selected_target(&self) -> Option<&view::Target> { self.dropdown_state .selected() .and_then(|index| self.targets.get(index)) .map(|(target, _)| target) } pub fn dropdown_activate(&mut self, view: &view::View) { // Just open the dropdown if it's not showing yet. if self.dropdown_state.selected().is_none() { self.dropdown_open(view); return; } if let (Some(object_id), Some(&target)) = (self.selected, self.selected_target()) { view.set_target(object_id, target); }; self.dropdown_state.select(None); } pub fn dropdown_close(&mut self) { self.dropdown_state.select(None); } pub fn set_target(&mut self, view: &view::View, target: view::Target) { self.dropdown_state.select(None); if let Some(object_id) = self.selected { view.set_target(object_id, target); }; } pub fn toggle_mute(&mut self, view: &view::View) { if matches!(self.list_kind, ListKind::Device) { return; } if let Some(node_id) = self.selected { view.mute(node_id); } } pub fn set_absolute_volume( &mut self, view: &view::View, volume: f32, max: Option, ) -> bool { if matches!(self.list_kind, ListKind::Device) { return false; } if let Some(node_id) = self.selected { return view.volume( node_id, VolumeAdjustment::Absolute(volume), max, ); } false } pub fn set_relative_volume( &mut self, view: &view::View, volume: f32, max: Option, ) -> bool { if matches!(self.list_kind, ListKind::Device) { return false; } if let Some(node_id) = self.selected { return view.volume( node_id, VolumeAdjustment::Relative(volume), max, ); } false } pub fn set_default(&mut self, view: &view::View) { if matches!(self.list_kind, ListKind::Device) { return; } if let (Some(node_id), Some(device_kind)) = (self.selected, self.device_kind) { view.set_default(node_id, device_kind); } } fn selected_index(&self, view: &view::View) -> Option { self.selected .and_then(|selected| view.position(self.list_kind, selected)) } fn select(&mut self, object_id: Option) { self.selected = object_id; // Close the dropdown in case it is open for the previously-selected // object. This can happen when the object is removed from PipeWire // while the dropdown is open. self.dropdown_close(); } /// Reconciles changes to objects, viewport, and selection. pub fn update(&mut self, area: Rect, view: &view::View) { let selected_index = self.selected_index(view).or_else(|| { // There's nothing selected! Select the first item and try again. self.select(view.next_id(self.list_kind, None)); self.selected_index(view) }); let objects_len = view.len(self.list_kind); let (_, list_area, _) = self.areas(&area); let full_height = match self.list_kind { ListKind::Node(_) => { NodeWidget::height().saturating_add(NodeWidget::spacing()) } ListKind::Device => { DeviceWidget::height().saturating_add(DeviceWidget::spacing()) } }; let objects_visible = (list_area.height / full_height) as usize; // If objects were removed and the viewport is now below the visible // objects, move the viewport up so that the bottom of the object list // is visible. if self.top >= objects_len { self.top = objects_len.saturating_sub(objects_visible); } // Make sure the selected object is visible and adjust the viewport // if necessary. if self.selected.is_some() { match selected_index { Some(selected_index) => { if selected_index >= self.top.saturating_add(objects_visible) { // The selection is below the viewport. Reposition the // viewport so that the selected item is at the bottom. let objects_visible_except_last = objects_visible.saturating_sub(1); self.top = selected_index .saturating_sub(objects_visible_except_last); } else if selected_index < self.top { // The selected item is above the viewport. Reposition // so that it's the first visible item. self.top = selected_index; } } None => self.select(None), // The selected object is gone! } } } fn areas(&self, area: &Rect) -> (Rect, Rect, Rect) { let layout = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), // header_area Constraint::Min(0), // list_area Constraint::Length(1), // footer_area ]) .split(*area); (layout[0], layout[1], layout[2]) } } pub struct ObjectListWidget<'a, 'b> { pub object_list: &'a mut ObjectList, pub view: &'a view::View<'b>, pub config: &'a Config, } struct ObjectListRenderContext<'a> { list_area: Rect, objects_layout: &'a [Rect], objects_visible: usize, } impl ObjectListWidget<'_, '_> { fn render_node_list( &mut self, node_kind: view::NodeKind, context: ObjectListRenderContext, area: Rect, buf: &mut Buffer, mouse_areas: &mut Vec, ) { let all_objects = self.view.full_nodes(node_kind); let objects = all_objects .iter() .skip(self.object_list.top) // Take one extra so we can render a partial node at the bottom of // the area. .take(context.objects_visible.saturating_add(1)); let objects_and_areas: Vec<(&&view::Node, &Rect)> = objects.zip(context.objects_layout.iter()).collect(); for (object, &object_area) in &objects_and_areas { let selected = self .object_list .selected .map(|id| id == object.object_id) .unwrap_or_default(); NodeWidget::new( self.config, self.object_list.device_kind, object, selected, ) .render(object_area, buf, mouse_areas); } // Show the target dropdown? if self.object_list.dropdown_state.selected().is_some() { // Get the area for the selected object if let Some((_, object_area)) = objects_and_areas.iter().find(|(object, _)| { self.object_list .selected .map(|id| id == object.object_id) .unwrap_or_default() }) { DropdownWidget::new( self.object_list, &NodeWidget::dropdown_area( self.object_list, &context.list_area, object_area, ), self.config, ) .render(area, buf, mouse_areas); } } } fn render_device_list( &mut self, context: ObjectListRenderContext, area: Rect, buf: &mut Buffer, mouse_areas: &mut Vec, ) { let all_objects = self.view.full_devices(); let objects = all_objects .iter() .skip(self.object_list.top) // Take one extra so we can render a partial node at the bottom of // the area. .take(context.objects_visible.saturating_add(1)); let objects_and_areas: Vec<(&&view::Device, &Rect)> = objects.zip(context.objects_layout.iter()).collect(); for (object, &object_area) in &objects_and_areas { let selected = self .object_list .selected .map(|id| id == object.object_id) .unwrap_or_default(); DeviceWidget::new(object, selected, self.config).render( object_area, buf, mouse_areas, ); } // Show the target dropdown? if self.object_list.dropdown_state.selected().is_some() { // Get the area for the selected object if let Some((_, object_area)) = objects_and_areas.iter().find(|(object, _)| { self.object_list .selected .map(|id| id == object.object_id) .unwrap_or_default() }) { DropdownWidget::new( self.object_list, &DeviceWidget::dropdown_area( self.object_list, &context.list_area, object_area, ), self.config, ) .render(area, buf, mouse_areas); } } } } impl StatefulWidget for &mut ObjectListWidget<'_, '_> { type State = Vec; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { let mouse_areas = state; let (header_area, list_area, footer_area) = self.object_list.areas(&area); mouse_areas.push(( header_area, smallvec![MouseEventKind::Down(MouseButton::Left)], smallvec![Action::MoveUp], )); mouse_areas.push(( footer_area, smallvec![MouseEventKind::Down(MouseButton::Left)], smallvec![Action::MoveDown], )); mouse_areas.push(( list_area, smallvec![MouseEventKind::ScrollUp], smallvec![Action::MoveUp], )); mouse_areas.push(( list_area, smallvec![MouseEventKind::ScrollDown], smallvec![Action::MoveDown], )); let (spacing, height) = match self.object_list.list_kind { ListKind::Node(_) => (NodeWidget::spacing(), NodeWidget::height()), ListKind::Device => { (DeviceWidget::spacing(), DeviceWidget::height()) } }; let full_object_height = height.saturating_add(spacing); let objects_visible = (list_area.height / full_object_height) as usize; let len = self.view.len(self.object_list.list_kind); // Indicate we can scroll up if there are objects above the viewport. if self.object_list.top > 0 { Line::from(Span::styled( &self.config.char_set.list_more, self.config.theme.list_more, )) .alignment(Alignment::Center) .render(header_area, buf); } // Indicate we can scroll down if there are objects below the // viewport, with an exception for when the last row is partially // rendered but still has all the important parts rendered, // excluding margins, etc. let is_bottom_last = self.object_list.top.saturating_add(objects_visible) == len.saturating_sub(1); let is_bottom_enough = (list_area.height % full_object_height) >= height; if self.object_list.top.saturating_add(objects_visible) < len && !(is_bottom_last && is_bottom_enough) { Line::from(Span::styled( &self.config.char_set.list_more, self.config.theme.list_more, )) .alignment(Alignment::Center) .render(footer_area, buf); } let objects_layout = { let object_height = height; let mut constraints = vec![Constraint::Length(object_height); objects_visible]; // A variable-length constraint for a partial last object constraints.push(Constraint::Max(object_height)); let constraints = constraints; Layout::default() .direction(Direction::Vertical) .constraints(constraints) .spacing(spacing) .split(list_area) }; match self.object_list.list_kind { ListKind::Node(node_kind) => { self.render_node_list( node_kind, ObjectListRenderContext { list_area, objects_layout: &objects_layout, objects_visible, }, area, buf, mouse_areas, ); } ListKind::Device => { self.render_device_list( ObjectListRenderContext { list_area, objects_layout: &objects_layout, objects_visible, }, area, buf, mouse_areas, ); } } } } #[cfg(test)] mod tests { use super::*; use crate::config; use crate::mock; use crate::view::{ListKind, NodeKind, View}; use crate::wirehose::{state::State, PropertyStore, StateEvent}; fn init() -> (State, mock::WirehoseHandle) { let mut state = State::default(); let wirehose = mock::WirehoseHandle::default(); for i in 0..10 { let object_id = ObjectId::from_raw_id(i); let mut props = PropertyStore::default(); props.set_node_description(String::from("Test node")); props.set_media_class(String::from("Stream/Output/Audio")); props.set_media_name(String::from("Media name")); props.set_node_name(String::from("Node name")); props.set_object_serial(i as u64); let props = props; let events = vec![ StateEvent::NodeProperties { object_id, props }, StateEvent::NodePeaks { object_id, peaks: vec![0.0, 0.0], samples: 512, }, StateEvent::NodePositions { object_id, positions: vec![0, 1], }, StateEvent::NodeRate { object_id, rate: 44100, }, StateEvent::NodeVolumes { object_id, volumes: vec![0.0, 0.0], }, StateEvent::NodeMute { object_id, mute: false, }, ]; for event in events { state.update(&wirehose, event); } } (state, wirehose) } #[test] fn object_list_up_overflow() { let (state, wirehose) = init(); let view = View::from(&wirehose, &state, &config::Names::default()); let height = NodeWidget::height() + NodeWidget::spacing(); // + 2 for header and footer let rect = Rect::new(0, 0, 80, height * 3 + 2); let mut object_list = ObjectList::new(ListKind::Node(NodeKind::All), None); // Select first object object_list.down(&view); assert_eq!(object_list.top, 0); assert_eq!(object_list.selected, Some(ObjectId::from_raw_id(0))); object_list.up(&view); object_list.update(rect, &view); assert_eq!(object_list.top, 0); assert_eq!(object_list.selected, Some(ObjectId::from_raw_id(0))); } #[test] fn object_list_down_overflow() { let (state, wirehose) = init(); let view = View::from(&wirehose, &state, &config::Names::default()); let height = NodeWidget::height() + NodeWidget::spacing(); // + 2 for header and footer let rect = Rect::new(0, 0, 80, height * 3 + 2); let mut object_list = ObjectList::new(ListKind::Node(NodeKind::All), None); // Select first object object_list.down(&view); assert_eq!(object_list.top, 0); assert_eq!(object_list.selected, Some(ObjectId::from_raw_id(0))); let nodes_len = view.nodes.len(); for _ in 0..(nodes_len * 2) { object_list.down(&view); } object_list.update(rect, &view); assert_eq!(object_list.top, 7); assert_eq!(object_list.selected, Some(ObjectId::from_raw_id(9))); } } wiremix-0.7.0/src/opt.rs000066400000000000000000000052551504750620600151550ustar00rootroot00000000000000//! Parse command-line arguments. use std::path::PathBuf; use clap::Parser; use crate::app::TabKind; use crate::config; // VERGEN_GIT_DESCRIBE is emitted by build.rs. const VERSION: &str = match option_env!("VERGEN_GIT_DESCRIBE") { Some(version) => version, // VERGEN_GIT_DESCRIBE won't be avilable when publishing, so fall back to // the cargo version. None => concat!("v", env!("CARGO_PKG_VERSION")), }; #[derive(Parser)] #[clap(name = "wiremix", about = "PipeWire mixer")] #[command(version = VERSION)] pub struct Opt { #[clap( short = 'c', long, value_name = "FILE", help = "Override default config file path" )] pub config: Option, #[clap( short, long, value_name = "NAME", help = "The name of the remote to connect to" )] pub remote: Option, #[clap( short, long, help = "Target frames per second (or 0 for unlimited)" )] pub fps: Option, #[clap( short = 's', long, value_name = "NAME", help = "Character set to use [built-in sets: default, compat, extracompat]" )] pub char_set: Option, #[clap( short, long, value_name = "NAME", help = "Theme to use [built-in themes: default, nocolor, plain]" )] pub theme: Option, #[clap( short, long, value_parser = clap::value_parser!(config::Peaks), help = "Audio peak meters" )] pub peaks: Option, #[clap(long, conflicts_with = "mouse", help = "Disable mouse support")] pub no_mouse: bool, #[clap(long, conflicts_with = "no_mouse", help = "Enable mouse support")] pub mouse: bool, #[clap( short = 'v', long, value_enum, value_parser = clap::value_parser!(TabKind), help = "Initial tab view" )] pub tab: Option, #[clap( short = 'm', long, value_name = "PERCENT", help = "Maximum volume for volume sliders" )] pub max_volume_percent: Option, #[clap( long, conflicts_with = "enforce_max_volume", help = "Allow increasing volume past max-volume-percent" )] pub no_enforce_max_volume: bool, #[clap( long, conflicts_with = "no_enforce_max_volume", help = "Prevent increasing volume past max-volume-percent" )] pub enforce_max_volume: bool, #[cfg(debug_assertions)] #[clap(short, long, help = "Dump events without showing interface")] pub dump_events: bool, } impl Opt { pub fn parse() -> Self { ::parse() } } wiremix-0.7.0/src/trace.rs000066400000000000000000000034141504750620600154440ustar00rootroot00000000000000#[cfg(feature = "trace")] use std::path::PathBuf; use anyhow::Result; use tracing_error::ErrorLayer; use tracing_subscriber::{ self, layer::SubscriberExt, util::SubscriberInitExt, Layer, }; pub fn initialize_logging() -> Result<()> { let log_file: String = format!("{}.log", env!("CARGO_PKG_NAME")); let directory = PathBuf::from("."); std::fs::create_dir_all(directory.clone())?; let log_path = directory.join(log_file.clone()); let log_file = std::fs::File::create(log_path)?; std::env::set_var("RUST_LOG", std::env::var("RUST_LOG").unwrap()); let file_subscriber = tracing_subscriber::fmt::layer() .with_file(true) .with_line_number(true) .with_writer(log_file) .with_target(false) .with_ansi(false) .with_filter(tracing_subscriber::filter::EnvFilter::from_default_env()); tracing_subscriber::registry() .with(file_subscriber) .with(ErrorLayer::default()) .init(); Ok(()) } /// Similar to the `std::dbg!` macro, but generates `tracing` events rather /// than printing to stdout. /// /// By default, the verbosity level for the generated events is `DEBUG`, but /// this can be customized. #[macro_export] macro_rules! trace_dbg { (target: $target:expr, level: $level:expr, $ex:expr) => {{ match $ex { value => { tracing::event!(target: $target, $level, ?value, stringify!($ex)); value } } }}; (level: $level:expr, $ex:expr) => { trace_dbg!(target: module_path!(), level: $level, $ex) }; (target: $target:expr, $ex:expr) => { trace_dbg!(target: $target, level: tracing::Level::DEBUG, $ex) }; ($ex:expr) => { trace_dbg!(level: tracing::Level::DEBUG, $ex) }; } wiremix-0.7.0/src/view.rs000066400000000000000000000707001504750620600153220ustar00rootroot00000000000000//! View representing PipeWire state in a convenient format for rendering. use itertools::Itertools; use std::collections::HashMap; use serde_json::json; use crate::config; use crate::device_kind::DeviceKind; use crate::wirehose::{media_class, state, CommandSender, ObjectId}; /// A view for transforming [`State`](`state::State`) into a better format for /// rendering. /// /// This is done in only two ways: /// /// 1. [`Self::from()`] creates a View from scratch from a provided State. /// /// 2. [`Self::update_peaks()`] updates just the provided peaks in an existing /// View. /// /// [`Self::from()`] is a bit expensive, but doesn't happen very often after we /// get the initial state from PipeWire. Peak updates happen very frequently /// though, hence the optimization. /// /// There are also functions like [`Self::mute()`] for executing commands /// against [`wirehose`](`crate::wirehose`). pub struct View<'a> { wirehose: &'a dyn CommandSender, pub nodes: HashMap, pub devices: HashMap, pub nodes_all: Vec, pub nodes_playback: Vec, pub nodes_recording: Vec, pub nodes_output: Vec, pub nodes_input: Vec, pub devices_all: Vec, pub sinks: Vec<(Target, String)>, pub sources: Vec<(Target, String)>, pub default_sink: Option, pub default_source: Option, pub metadata_id: Option, } #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] pub enum Target { Node(ObjectId), Route(ObjectId, i32, i32), Profile(ObjectId, i32), Default, } #[derive(Debug)] pub struct Node { pub object_id: ObjectId, pub object_serial: u64, pub name: String, pub title: String, pub title_source_sink: Option, pub media_class: String, pub routes: Option>, pub target_title: String, pub target: Option, pub volumes: Vec, pub mute: bool, pub peaks: Option>, pub positions: Option>, /// If this is a device/endpoint node, store the (device_id, route_index, /// card_device) here because they are needed for changing volumes and /// muting via [`wirehose`](`crate::wirehose`). pub device_info: Option<(ObjectId, i32, i32)>, pub is_default_sink: bool, pub is_default_source: bool, } #[derive(Debug)] pub struct Device { pub object_id: ObjectId, pub object_serial: u64, pub title: String, pub profiles: Vec<(Target, String)>, pub target_title: String, pub target: Option, } #[derive(Debug, Clone, Copy)] pub enum VolumeAdjustment { Relative(f32), Absolute(f32), } #[derive(Default, Debug, Clone, Copy)] pub enum NodeKind { Playback, Recording, Output, Input, #[default] All, } #[derive(Default, Debug, Clone, Copy)] pub enum ListKind { Node(NodeKind), #[default] Device, } impl ListKind { pub fn is_node(&self) -> bool { matches!(self, ListKind::Node(_)) } pub fn is_device(&self) -> bool { matches!(self, ListKind::Device) } } /// Gets the potential Target::Routes for a device and media class. /// These come from the EnumRoutes where profiles contains the active profile's /// index, and devices contains at least one of the profile's devices for the /// given media class. fn route_targets( device: &state::Device, media_class: &String, ) -> Option> { let profile_index = device.profile_index?; let profile = device.profiles.get(&profile_index)?; let profile_devices = profile .classes .iter() .find_map(|(mc, devices)| (mc == media_class).then_some(devices))?; Some( device .enum_routes .values() .filter_map(|route| { if !route.profiles.contains(&profile_index) { return None; } let route_device = route.devices.iter().find(|route_device| { profile_devices.contains(route_device) })?; let title = if route.available { route.description.clone() } else { format!("{} (unavailable)", route.description) }; Some(( Target::Route(device.object_id, route.index, *route_device), title, )) }) .collect(), ) } /// Get the active route for a device and card device. /// This is the route on a device Node IF the route's profile matches the /// device's current profile. Otherwise, there is no valid route. fn active_route( device: &state::Device, card_device: i32, ) -> Option<&state::Route> { let profile_index = device.profile_index?; device .routes .get(&card_device) .filter(|route| route.profiles.contains(&profile_index)) } impl Node { fn from( state: &state::State, names: &config::Names, sources: &[(Target, String)], sinks: &[(Target, String)], default_sink_name: &Option, default_source_name: &Option, node: &state::Node, ) -> Option { let object_id = node.object_id; let media_class = node.props.media_class()?.clone(); let title = names.resolve(state, node)?; // Nodes can represent either streams or devices. let (volumes, mute, device_info) = if let Some(device_id) = node.props.device_id() { // Nodes for devices should get their volume and mute status // from the associated device's active route which is also used // for changing the volume and mute status. let device = state.devices.get(device_id)?; let card_device = *node.props.card_profile_device()?; if let Some(route) = active_route(device, card_device) { let route_index = route.index; ( route.volumes.clone(), route.mute, Some((*device_id, route_index, card_device)), ) } else { (node.volumes.as_ref()?.clone(), node.mute?, None) } } else { // We can interact with a stream node's volume and mute status // directly. (node.volumes.as_ref()?.clone(), node.mute?, None) }; let (routes, target, target_title) = if let Some(device_id) = node.props.device_id() { // Targets for device nodes are routes for the associated device. let device = state.devices.get(device_id)?; let card_device = *node.props.card_profile_device()?; let mut routes: Vec<_> = route_targets(device, &media_class).unwrap_or_default(); routes.sort_by(|(_, a), (_, b)| a.cmp(b)); let routes = routes; let (target, target_title) = match active_route(device, card_device) { Some(route) => { let target_title = if route.available { route.description.clone() } else { format!("{} (unavailable)", route.description) }; ( Some(Target::Route( device.object_id, route.index, card_device, )), target_title, ) } None => (None, String::from("No route selected")), }; (Some(routes), target, target_title) } else if media_class::is_sink_input(&media_class) { // Targets for output streams are sinks. let outputs = state.outputs(object_id); let sink = sinks.iter().find(|(target, _)| { matches!(target, Target::Node(sink_id) if outputs.contains(sink_id)) }); let (target, target_title) = if !has_target(state, node.object_id) { ( Some(Target::Default), sink.map(|(_, title)| title.clone()) .unwrap_or(String::from("No default")), ) } else { ( sink.map(|&(target, _)| target), sink.map(|(_, title)| title.clone()).unwrap_or_default(), ) }; (None, target, target_title) } else if media_class::is_source_output(&media_class) { // Targets for input streams are sources. let inputs = state.inputs(object_id); let source = sources.iter().find(|(target, _)| { matches!(target, Target::Node(source_id) if inputs.contains(source_id)) }); let (target, target_title) = if !has_target(state, node.object_id) { ( Some(Target::Default), source .map(|(_, title)| title.clone()) .unwrap_or(String::from("No default")), ) } else { ( source.map(|&(target, _)| target), source.map(|(_, title)| title.clone()).unwrap_or_default(), ) }; (None, target, target_title) } else { (None, None, String::from("No route selected")) }; Some(Self { object_id, object_serial: *node.props.object_serial()?, name: node.props.node_name()?.clone(), title, title_source_sink: node.props.media_name().cloned(), media_class, routes, target, target_title, volumes, mute, peaks: node.peaks.clone(), positions: node.positions.clone(), device_info, is_default_sink: default_sink_name.as_ref() == node.props.node_name(), is_default_source: default_source_name.as_ref() == node.props.node_name(), }) } } impl Device { fn from( state: &state::State, device: &state::Device, names: &config::Names, ) -> Option { let object_id = device.object_id; let title = names.resolve(state, device)?; let mut profiles: Vec<_> = device .profiles .values() .map(|profile| { let title = if profile.available { profile.description.clone() } else { format!("{} (unavailable)", profile.description) }; (profile.index, title) }) .collect(); profiles.sort_by_key(|&(index, _)| index); let profiles = profiles .into_iter() .map(|(index, title)| (Target::Profile(object_id, index), title)) .collect(); let target_profile = device.profiles.get(&device.profile_index?)?; let target_title = if target_profile.available { target_profile.description.clone() } else { format!("{} (unavailable)", target_profile.description) }; let target = Some(Target::Profile(object_id, device.profile_index?)); let object_serial = *device.props.object_serial()?; Some(Device { object_id, object_serial, title, profiles, target_title, target, }) } } fn default_for(state: &state::State, which: &str) -> Option { let metadata = state.get_metadata_by_name("default")?; let json = metadata.properties.get(&0)?.get(which)?; let object = serde_json::from_str::(json).ok()?; Some(String::from(object["name"].as_str()?)) } fn target_node(state: &state::State, node_id: ObjectId) -> Option { let metadata = state.get_metadata_by_name("default")?; let json = metadata .properties .get(&node_id.into())? .get("target.node")?; serde_json::from_str(json).ok() } fn target_object(state: &state::State, node_id: ObjectId) -> Option { let metadata = state.get_metadata_by_name("default")?; let json = metadata .properties .get(&node_id.into())? .get("target.object")?; serde_json::from_str(json).ok() } fn has_target(state: &state::State, node_id: ObjectId) -> bool { match (target_node(state, node_id), target_object(state, node_id)) { (Some(node), _) if node != -1 => true, (_, Some(object)) if object != -1 => true, _ => false, } } impl<'a> View<'a> { pub fn new(wirehose: &'a dyn CommandSender) -> View<'a> { Self { wirehose, nodes: Default::default(), devices: Default::default(), nodes_all: Default::default(), nodes_playback: Default::default(), nodes_recording: Default::default(), nodes_output: Default::default(), nodes_input: Default::default(), devices_all: Default::default(), sinks: Default::default(), sources: Default::default(), default_sink: Default::default(), default_source: Default::default(), metadata_id: Default::default(), } } /// Create a View from scratch from a provided State. pub fn from( wirehose: &'a dyn CommandSender, state: &state::State, names: &config::Names, ) -> View<'a> { let default_sink_name = default_for(state, "default.audio.sink"); let default_source_name = default_for(state, "default.audio.source"); let default_sink = default_sink_name.as_ref().and_then(|default_sink_name| { state .nodes .values() .find(|node| { node.props.node_name() == Some(default_sink_name) }) .map(|node| Target::Node(node.object_id)) }); let default_source = default_source_name .as_ref() .and_then(|default_source_name| { state .nodes .values() .find(|node| { node.props.node_name() == Some(default_source_name) }) .map(|node| Target::Node(node.object_id)) }); let mut sinks: Vec<_> = state .nodes .values() .filter_map(|node| { if media_class::is_sink(node.props.media_class()?) { Some(( Target::Node(node.object_id), names.resolve(state, node)?, )) } else { None } }) .collect(); sinks.sort_by(|(_, a), (_, b)| a.cmp(b)); let sinks = sinks; let mut sources: Vec<_> = state .nodes .values() .filter_map(|node| { if media_class::is_source(node.props.media_class()?) { let title = names.resolve(state, node)?; Some((Target::Node(node.object_id), title)) } else if media_class::is_sink(node.props.media_class()?) { let title = names.resolve(state, node)?; Some(( Target::Node(node.object_id), format!("Monitor of {title}"), )) } else { None } }) .collect(); sources.sort_by(|(_, a), (_, b)| a.cmp(b)); let sources = sources; let nodes: HashMap = state .nodes .values() .filter_map(|node| { Node::from( state, names, &sources, &sinks, &default_sink_name, &default_source_name, node, ) }) .map(|node| (node.object_id, node)) .collect(); let devices: HashMap = state .devices .values() .filter_map(|device| Device::from(state, device, names)) .map(|device| (device.object_id, device)) .collect(); let mut nodes_all = Vec::new(); let mut nodes_playback = Vec::new(); let mut nodes_recording = Vec::new(); let mut nodes_output = Vec::new(); let mut nodes_input = Vec::new(); for (id, node) in nodes.iter().sorted_by_key(|(_, node)| node.object_serial) { nodes_all.push(*id); if media_class::is_sink_input(&node.media_class) { nodes_playback.push(*id); } if media_class::is_source_output(&node.media_class) { nodes_recording.push(*id); } if media_class::is_sink(&node.media_class) { nodes_output.push(*id); } if media_class::is_source(&node.media_class) { nodes_input.push(*id); } } let nodes_all = nodes_all; let nodes_playback = nodes_playback; let nodes_recording = nodes_recording; let nodes_output = nodes_output; let nodes_input = nodes_input; let devices_all = devices .iter() .sorted_by_key(|(_, device)| device.object_serial) .map(|(&id, _)| id) .collect(); Self { wirehose, nodes, devices, nodes_all, nodes_playback, nodes_recording, nodes_output, nodes_input, devices_all, sinks, sources, default_sink, default_source, metadata_id: state.metadatas_by_name.get("default").copied(), } } /// Update just the peaks of an existing State. pub fn update_peaks(&mut self, state: &state::State) { for state_node in state.nodes.values() { if let Some(node) = self.nodes.get_mut(&state_node.object_id) { match &state_node.peaks { Some(peaks) => { let peaks_ref = node.peaks.get_or_insert_with(Default::default); peaks_ref.resize(peaks.len(), 0.0); peaks_ref.copy_from_slice(peaks); } _ => node.peaks = None, } } } } /// Sets the provided node as the default source/sink, depending on /// device_kind. pub fn set_default(&self, node_id: ObjectId, device_kind: DeviceKind) { let Some(node) = self.nodes.get(&node_id) else { return; }; let Some(metadata_id) = self.metadata_id else { return; }; let key = match device_kind { DeviceKind::Source => "default.configured.audio.source", DeviceKind::Sink => "default.configured.audio.sink", }; self.wirehose.metadata_set_property( metadata_id, 0, String::from(key), Some(String::from("Spa:String:JSON")), Some(json!({ "name": &node.name }).to_string()), ); } /// Sets the provided node's target to the provided target. pub fn set_target(&self, node_id: ObjectId, target: Target) { let Some(metadata_id) = self.metadata_id else { return; }; match target { Target::Default => { self.wirehose.metadata_set_property( metadata_id, node_id.into(), String::from("target.object"), Some(String::from("Spa:Id")), Some(String::from("-1")), ); self.wirehose.metadata_set_property( metadata_id, node_id.into(), String::from("target.node"), Some(String::from("Spa:Id")), Some(String::from("-1")), ); } Target::Node(target_id) => { self.wirehose.metadata_set_property( metadata_id, node_id.into(), String::from("target.object"), None, None, ); self.wirehose.metadata_set_property( metadata_id, node_id.into(), String::from("target.node"), Some(String::from("Spa:Id")), Some(target_id.to_string()), ); } Target::Route(device_id, route_index, route_device) => { self.wirehose.device_set_route( device_id, route_index, route_device, ); } Target::Profile(device_id, profile_index) => { self.wirehose.device_set_profile(device_id, profile_index); } } } /// Mutes the provided node. pub fn mute(&self, node_id: ObjectId) { let Some(node) = self.nodes.get(&node_id) else { return; }; let mute = !node.mute; if let Some((device_id, route_index, route_device)) = node.device_info { self.wirehose.device_mute( device_id, route_index, route_device, mute, ); } else { self.wirehose.node_mute(node_id, mute); } } /// Changes the volume of the provided node. If max volume is provided, /// won't change volume if result would be greater than max. Returns true /// if volume was changed, otherwise false. pub fn volume( &self, node_id: ObjectId, adjustment: VolumeAdjustment, max: Option, ) -> bool { let Some(node) = self.nodes.get(&node_id) else { return false; }; let mut volumes = node.volumes.clone(); if volumes.is_empty() { return false; } match adjustment { VolumeAdjustment::Relative(delta) => { let avg = volumes.iter().sum::() / volumes.len() as f32; volumes.fill((avg.cbrt() + delta).max(0.0).powi(3)); } VolumeAdjustment::Absolute(volume) => { volumes.fill(volume.max(0.0).powi(3)); } } let volumes = volumes; if let Some(max) = max { if volumes .iter() .any(|volume| (volume.cbrt() * 100.0).round() > max) { return false; } } if let Some((device_id, route_index, route_device)) = node.device_info { self.wirehose.device_volumes( device_id, route_index, route_device, volumes, ); } else { self.wirehose.node_volumes(node_id, volumes); } true } fn object_ids(&self, node_kind: ListKind) -> &[ObjectId] { match node_kind { ListKind::Node(NodeKind::Playback) => &self.nodes_playback, ListKind::Node(NodeKind::Recording) => &self.nodes_recording, ListKind::Node(NodeKind::Output) => &self.nodes_output, ListKind::Node(NodeKind::Input) => &self.nodes_input, ListKind::Node(NodeKind::All) => &self.nodes_all, ListKind::Device => &self.devices_all, } } /// Gets all the nodes without filtering. pub fn full_nodes(&self, node_kind: NodeKind) -> Vec<&Node> { let node_ids = self.object_ids(ListKind::Node(node_kind)); node_ids .iter() .filter_map(|node_id| self.nodes.get(node_id)) .collect() } /// Gets all the devices without filtering. pub fn full_devices(&self) -> Vec<&Device> { let device_ids = self.object_ids(ListKind::Device); device_ids .iter() .filter_map(|device_id| self.devices.get(device_id)) .collect() } /// Returns the next node in the list_kind after a provided node. pub fn next_id( &self, list_kind: ListKind, object_id: Option, ) -> Option { let objects = self.object_ids(list_kind); let next_index = match object_id { Some(object_id) => objects .iter() .position(|&id| id == object_id)? .saturating_add(1), None => 0, }; objects.get(next_index).copied() } /// Returns the previous node in the list_kind before a provided node. pub fn previous_id( &self, list_kind: ListKind, object_id: Option, ) -> Option { let objects = self.object_ids(list_kind); let next_index = match object_id { Some(object_id) => objects .iter() .position(|&id| id == object_id)? .saturating_sub(1), None => 0, }; objects.get(next_index).copied() } /// Returns the index in the list_kind for the provided object. pub fn position( &self, list_kind: ListKind, object_id: ObjectId, ) -> Option { self.object_ids(list_kind) .iter() .position(|&id| id == object_id) } /// Returns length of the list_kind. pub fn len(&self, list_kind: ListKind) -> usize { self.object_ids(list_kind).len() } /// Returns the possible targets for a node. pub fn node_targets( &self, node_id: ObjectId, ) -> Option<(Vec<(Target, String)>, usize)> { let node = self.nodes.get(&node_id)?; // Get the target list appropriate to the node type let (mut targets, default) = if let Some(routes) = &node.routes { (routes.clone(), None) } else if media_class::is_sink_input(&node.media_class) { (self.sinks.clone(), self.default_sink) } else if media_class::is_source_output(&node.media_class) { (self.sources.clone(), self.default_source) } else { (Vec::new(), None) }; // Get and format the name of the default target let default_name = default .and_then(|default| { targets .iter() .find(|(target, _)| *target == default) .map(|(_, name)| format!("Default: {name}")) }) .unwrap_or(String::from("Default: No default")); // Sort targets by name targets.sort_by(|(_, a), (_, b)| a.cmp(b)); // If the targets are nodes, add the default node to the top if media_class::is_sink_input(&node.media_class) || media_class::is_source_output(&node.media_class) { targets.insert(0, (Target::Default, default_name.clone())); }; let targets = targets; // Get, for return, the position of the current target // Default to 0 if for some reason we can't find it let selected_position = node .target .and_then(|node_target| { targets .iter() .position(|&(target, _)| target == node_target) }) .unwrap_or(0); Some((targets, selected_position)) } /// Returns the possible targets for a device. pub fn device_targets( &self, device_id: ObjectId, ) -> Option<(Vec<(Target, String)>, usize)> { let device = self.devices.get(&device_id)?; let targets = device.profiles.clone(); let selected_position = device .target .and_then(|device_target| { targets .iter() .position(|&(target, _)| target == device_target) }) .unwrap_or(0); Some((targets, selected_position)) } } wiremix-0.7.0/src/wirehose.rs000066400000000000000000000010341504750620600161670ustar00rootroot00000000000000//! Event-based wrapper around pipewire-rs. mod client; mod command; mod deserialize; mod device; mod event; mod event_sender; mod execute; mod link; pub mod media_class; mod metadata; mod node; mod object_id; mod property_store; mod proxy_registry; mod session; pub mod state; mod stream; mod stream_registry; mod sync_registry; pub use command::{Command, CommandSender}; pub use event::{Event, StateEvent}; pub use event_sender::EventHandler; pub use object_id::ObjectId; pub use property_store::PropertyStore; pub use session::Session; wiremix-0.7.0/src/wirehose/000077500000000000000000000000001504750620600156235ustar00rootroot00000000000000wiremix-0.7.0/src/wirehose/client.rs000066400000000000000000000027521504750620600174550ustar00rootroot00000000000000use std::rc::Rc; use pipewire::{ client::{Client, ClientChangeMask, ClientInfoRef}, proxy::Listener, registry::{GlobalObject, Registry}, }; use libspa::utils::dict::DictRef; use crate::wirehose::event_sender::EventSender; use crate::wirehose::{ObjectId, PropertyStore, StateEvent}; pub fn monitor_client( registry: &Registry, object: &GlobalObject<&DictRef>, sender: &Rc, ) -> Option<(Rc, Box)> { let object_id = ObjectId::from(object); let client: Client = registry.bind(object).ok()?; let client = Rc::new(client); let listener = client .add_listener_local() .info({ let sender_weak = Rc::downgrade(sender); move |info| { let Some(sender) = sender_weak.upgrade() else { return; }; for change in info.change_mask().iter() { if change == ClientChangeMask::PROPS { client_info_props(&sender, object_id, info); } } } }) .register(); Some((client, Box::new(listener))) } fn client_info_props( sender: &EventSender, object_id: ObjectId, client_info: &ClientInfoRef, ) { let Some(props) = client_info.props() else { return; }; let property_store = PropertyStore::from(props); sender.send(StateEvent::ClientProperties { object_id, props: property_store, }); } wiremix-0.7.0/src/wirehose/command.rs000066400000000000000000000031211504750620600176040ustar00rootroot00000000000000//! PipeWire controls which can be executed by wirehose. use crate::wirehose::ObjectId; #[derive(Debug)] pub enum Command { NodeMute(ObjectId, bool), DeviceMute(ObjectId, i32, i32, bool), NodeVolumes(ObjectId, Vec), DeviceVolumes(ObjectId, i32, i32, Vec), DeviceSetRoute(ObjectId, i32, i32), DeviceSetProfile(ObjectId, i32), NodeCaptureStart(ObjectId, u64, bool), NodeCaptureStop(ObjectId), MetadataSetProperty(ObjectId, u32, String, Option, Option), } /// Trait for sending commands to control PipeWire. The trait exists to /// facilitate mocking. pub trait CommandSender { fn node_capture_start( &self, obj_id: ObjectId, object_serial: u64, capture_sink: bool, ); fn node_capture_stop(&self, obj_id: ObjectId); fn node_mute(&self, obj_id: ObjectId, mute: bool); fn node_volumes(&self, obj_id: ObjectId, volumes: Vec); fn device_mute( &self, obj_id: ObjectId, route_index: i32, route_device: i32, mute: bool, ); fn device_set_profile(&self, obj_id: ObjectId, profile_index: i32); fn device_set_route( &self, obj_id: ObjectId, route_index: i32, route_device: i32, ); fn device_volumes( &self, obj_id: ObjectId, route_index: i32, route_device: i32, volumes: Vec, ); fn metadata_set_property( &self, obj_id: ObjectId, subject: u32, key: String, type_: Option, value: Option, ); } wiremix-0.7.0/src/wirehose/deserialize.rs000066400000000000000000000005661504750620600205000ustar00rootroot00000000000000use libspa::pod::{deserialize::PodDeserializer, Object, Pod, Value}; pub fn deserialize(param: Option<&Pod>) -> Option { param .and_then(|pod| { PodDeserializer::deserialize_any_from(pod.as_bytes()).ok() }) .and_then(|(_, value)| match value { Value::Object(obj) => Some(obj), _ => None, }) } wiremix-0.7.0/src/wirehose/device.rs000066400000000000000000000234151504750620600174350ustar00rootroot00000000000000use std::rc::Rc; use pipewire::{ device::{Device, DeviceChangeMask, DeviceInfoRef}, proxy::Listener, registry::{GlobalObject, Registry}, }; use libspa::{ param::ParamType, pod::{Object, Value, ValueArray}, utils::dict::DictRef, }; use crate::wirehose::event_sender::EventSender; use crate::wirehose::{ deserialize::deserialize, ObjectId, PropertyStore, StateEvent, }; pub fn monitor_device( registry: &Registry, object: &GlobalObject<&DictRef>, sender: &Rc, ) -> Option<(Rc, Box)> { let object_id = ObjectId::from(object); let props = object.props?; let media_class = props.get("media.class")?; match media_class { "Audio/Device" => (), _ => return None, } let device: Device = registry.bind(object).ok()?; let device = Rc::new(device); let params = [ ParamType::EnumRoute, ParamType::Route, ParamType::Profile, ParamType::EnumProfile, ]; let listener = device .add_listener_local() .param({ let sender_weak = Rc::downgrade(sender); move |_seq, id, _index, _next, param| { let Some(sender) = sender_weak.upgrade() else { return; }; if let Some(param) = deserialize(param) { if let Some(event) = match id { ParamType::EnumRoute => { device_enum_route(object_id, param) } ParamType::Route => device_route(object_id, param), ParamType::Profile => device_profile(object_id, param), ParamType::EnumProfile => { device_enum_profile(object_id, param) } _ => None, } { sender.send(event); } } } }) .info({ let sender_weak = Rc::downgrade(sender); let device_weak = Rc::downgrade(&device); move |info| { let Some(sender) = sender_weak.upgrade() else { return; }; for change in info.change_mask().iter() { if change == DeviceChangeMask::PROPS { device_info_props(&sender, object_id, info); } } let Some(device) = device_weak.upgrade() else { return; }; for param in params.into_iter() { device.enum_params(0, Some(param), 0, u32::MAX); } } }) .register(); device.subscribe_params(¶ms); Some((device, Box::new(listener))) } fn device_enum_route(object_id: ObjectId, param: Object) -> Option { let mut index = None; let mut description = None; let mut available = None; let mut profiles = None; let mut devices = None; for prop in param.properties { match prop.key { libspa_sys::SPA_PARAM_ROUTE_index => { if let Value::Int(value) = prop.value { index = Some(value); } } libspa_sys::SPA_PARAM_ROUTE_description => { if let Value::String(value) = prop.value { description = Some(value); } } libspa_sys::SPA_PARAM_ROUTE_available => { if let Value::Id(libspa::utils::Id(value)) = prop.value { available = Some(value != libspa_sys::SPA_PARAM_AVAILABILITY_no); } } libspa_sys::SPA_PARAM_ROUTE_profiles => { if let Value::ValueArray(ValueArray::Int(value)) = prop.value { profiles = Some(value); } } libspa_sys::SPA_PARAM_ROUTE_devices => { if let Value::ValueArray(ValueArray::Int(value)) = prop.value { devices = Some(value); } } _ => {} } } Some(StateEvent::DeviceEnumRoute { object_id, index: index?, description: description?, available: available?, profiles: profiles?, devices: devices?, }) } fn device_route(object_id: ObjectId, param: Object) -> Option { let mut index = None; let mut device = None; let mut profiles = None; let mut description = None; let mut available = None; let mut channel_volumes = None; let mut mute = None; for prop in param.properties { match prop.key { libspa_sys::SPA_PARAM_ROUTE_index => { if let Value::Int(value) = prop.value { index = Some(value); } } libspa_sys::SPA_PARAM_ROUTE_device => { if let Value::Int(value) = prop.value { device = Some(value); } } libspa_sys::SPA_PARAM_ROUTE_profiles => { if let Value::ValueArray(ValueArray::Int(value)) = prop.value { profiles = Some(value); } } libspa_sys::SPA_PARAM_ROUTE_description => { if let Value::String(value) = prop.value { description = Some(value); } } libspa_sys::SPA_PARAM_ROUTE_available => { if let Value::Id(libspa::utils::Id(value)) = prop.value { available = Some(value != libspa_sys::SPA_PARAM_AVAILABILITY_no); } } libspa_sys::SPA_PARAM_ROUTE_props => { if let Value::Object(value) = prop.value { for prop in value.properties { match prop.key { libspa_sys::SPA_PROP_channelVolumes => { if let Value::ValueArray(ValueArray::Float( value, )) = prop.value { channel_volumes = Some(value); } } libspa_sys::SPA_PROP_mute => { if let Value::Bool(value) = prop.value { mute = Some(value); } } _ => {} } } } } _ => {} } } Some(StateEvent::DeviceRoute { object_id, index: index?, device: device?, profiles: profiles?, description: description?, available: available?, channel_volumes: channel_volumes?, mute: mute?, }) } fn device_profile(object_id: ObjectId, param: Object) -> Option { for prop in param.properties { if prop.key == libspa_sys::SPA_PARAM_ROUTE_index { if let Value::Int(value) = prop.value { return Some(StateEvent::DeviceProfile { object_id, index: value, }); } } } None } fn parse_class(value: &Value) -> Option<(String, Vec)> { if let Value::Struct(class) = value { if let [Value::String(name), _, _, Value::ValueArray(ValueArray::Int(devices))] = class.as_slice() { return Some((name.clone(), devices.clone())); } } None } fn device_enum_profile( object_id: ObjectId, param: Object, ) -> Option { let mut index = None; let mut description = None; let mut available = None; let mut classes = None; for prop in param.properties { match prop.key { libspa_sys::SPA_PARAM_PROFILE_index => { if let Value::Int(value) = prop.value { index = Some(value); } } libspa_sys::SPA_PARAM_PROFILE_description => { if let Value::String(value) = prop.value { description = Some(value); } } libspa_sys::SPA_PARAM_PROFILE_available => { if let Value::Id(libspa::utils::Id(value)) = prop.value { available = Some(value != libspa_sys::SPA_PARAM_AVAILABILITY_no); } } libspa_sys::SPA_PARAM_PROFILE_classes => { if let Value::Struct(classes_struct) = prop.value { // Usually the first element is the size, which we skip. let skip = match classes_struct.first() { Some(Value::Int(_)) => 1, _ => 0, }; classes = Some(Vec::new()); for class in classes_struct.iter().skip(skip) { if let Some(classes) = &mut classes { classes.extend(parse_class(class)); } } } } _ => (), } } Some(StateEvent::DeviceEnumProfile { object_id, index: index?, description: description?, available: available?, classes: classes?, }) } fn device_info_props( sender: &EventSender, object_id: ObjectId, device_info: &DeviceInfoRef, ) { let Some(props) = device_info.props() else { return; }; let props = PropertyStore::from(props); sender.send(StateEvent::DeviceProperties { object_id, props }); } wiremix-0.7.0/src/wirehose/event.rs000066400000000000000000000051531504750620600173160ustar00rootroot00000000000000use pipewire::link::LinkInfoRef; use crate::wirehose::{ObjectId, PropertyStore}; /// Events emitted by the PipeWire monitoring thread. #[derive(Debug)] pub enum Event { /// The PipeWire state has changed State(StateEvent), /// An error occurred during monitoring Error(String), /// The [StateEvent]s representing the PipeWire state at the time of /// connection have been sent. wirehose is listening for changes now. Ready, } #[derive(Debug)] /// PipeWire state change events. pub enum StateEvent { DeviceEnumRoute { object_id: ObjectId, index: i32, description: String, available: bool, profiles: Vec, devices: Vec, }, DeviceEnumProfile { object_id: ObjectId, index: i32, description: String, available: bool, classes: Vec<(String, Vec)>, }, DeviceProfile { object_id: ObjectId, index: i32, }, DeviceProperties { object_id: ObjectId, props: PropertyStore, }, DeviceRoute { object_id: ObjectId, index: i32, device: i32, profiles: Vec, description: String, available: bool, channel_volumes: Vec, mute: bool, }, MetadataMetadataName { object_id: ObjectId, metadata_name: String, }, MetadataProperty { object_id: ObjectId, subject: u32, key: Option, value: Option, }, ClientProperties { object_id: ObjectId, props: PropertyStore, }, NodePeaks { object_id: ObjectId, peaks: Vec, samples: u32, }, NodePositions { object_id: ObjectId, positions: Vec, }, NodeProperties { object_id: ObjectId, props: PropertyStore, }, NodeRate { object_id: ObjectId, rate: u32, }, NodeVolumes { object_id: ObjectId, volumes: Vec, }, NodeMute { object_id: ObjectId, mute: bool, }, Link { object_id: ObjectId, output_id: ObjectId, input_id: ObjectId, }, StreamStopped { object_id: ObjectId, }, Removed { object_id: ObjectId, }, } impl From<&LinkInfoRef> for StateEvent { fn from(link_info: &LinkInfoRef) -> Self { StateEvent::Link { object_id: ObjectId::from_raw_id(link_info.id()), output_id: ObjectId::from_raw_id(link_info.output_node_id()), input_id: ObjectId::from_raw_id(link_info.input_node_id()), } } } wiremix-0.7.0/src/wirehose/event_sender.rs000066400000000000000000000031131504750620600206500ustar00rootroot00000000000000use std::cell::RefCell; use pipewire::main_loop::WeakMainLoop; use crate::wirehose::{Event, StateEvent}; /// Trait for handling [`Event`]s. pub trait EventHandler: Send + 'static { /// Returns `true` if the event was handled successfully, `false` if the /// wirehose thread should shut down. fn handle_event(&mut self, event: Event) -> bool; } impl EventHandler for F where F: FnMut(Event) -> bool + Send + 'static, { fn handle_event(&mut self, event: Event) -> bool { self(event) } } pub struct EventSender { handler: RefCell>, main_loop_weak: WeakMainLoop, } impl EventSender { pub fn new( handler: F, main_loop_weak: WeakMainLoop, ) -> Self { Self { handler: RefCell::new(Box::new(handler)), main_loop_weak, } } pub fn send(&self, event: StateEvent) { if !self.handler.borrow_mut().handle_event(Event::State(event)) { if let Some(main_loop) = self.main_loop_weak.upgrade() { main_loop.quit(); } } } pub fn send_ready(&self) { if !self.handler.borrow_mut().handle_event(Event::Ready) { if let Some(main_loop) = self.main_loop_weak.upgrade() { main_loop.quit(); } } } pub fn send_error(&self, error: String) { if !self.handler.borrow_mut().handle_event(Event::Error(error)) { if let Some(main_loop) = self.main_loop_weak.upgrade() { main_loop.quit(); } } } } wiremix-0.7.0/src/wirehose/execute.rs000066400000000000000000000170211504750620600176340ustar00rootroot00000000000000use std::rc::Rc; use crate::wirehose::event_sender::EventSender; use crate::wirehose::proxy_registry::ProxyRegistry; use crate::wirehose::stream_registry::StreamRegistry; use crate::wirehose::{command::Command, stream}; use pipewire::{core::Core, device::Device, node::Node}; use libspa::param::ParamType; use libspa::pod::{ serialize::PodSerializer, Object, Pod, Property, PropertyFlags, Value, ValueArray, }; pub fn execute_command( core: &Core, sender: Rc, streams: &mut StreamRegistry, proxies: &ProxyRegistry, command: Command, ) { match command { Command::NodeMute(obj_id, mute) => { if let Some(node) = proxies.nodes.get(&obj_id) { node_set_mute(node, mute); } } Command::DeviceMute(obj_id, route_index, route_device, mute) => { if let Some(device) = proxies.devices.get(&obj_id) { device_set_mute(device, route_index, route_device, mute); } } Command::NodeVolumes(obj_id, volumes) => { if let Some(node) = proxies.nodes.get(&obj_id) { node_set_volumes(node, volumes); } } Command::DeviceVolumes(obj_id, route_index, route_device, volumes) => { if let Some(device) = proxies.devices.get(&obj_id) { device_set_volumes(device, route_index, route_device, volumes); } } Command::DeviceSetRoute(obj_id, route_index, route_device) => { if let Some(device) = proxies.devices.get(&obj_id) { device_set_route(device, route_index, route_device); } } Command::DeviceSetProfile(obj_id, profile_index) => { if let Some(device) = proxies.devices.get(&obj_id) { device_set_profile(device, profile_index); } } Command::NodeCaptureStart(obj_id, object_serial, capture_sink) => { let result = stream::capture_node( core, &sender, obj_id, &object_serial.to_string(), capture_sink, ); if let Some((stream, listener)) = result { streams.add_stream(obj_id, stream, listener); } } Command::NodeCaptureStop(obj_id) => { streams.remove(obj_id); } Command::MetadataSetProperty(obj_id, subject, key, type_, value) => { if let Some(metadata) = proxies.metadatas.get(&obj_id) { metadata.set_property( subject, &key, type_.as_deref(), value.as_deref(), ); } } } } fn node_set_mute(node: &Node, mute: bool) { node_set_properties( node, vec![ Property { key: libspa_sys::SPA_PROP_mute, flags: PropertyFlags::empty(), value: Value::Bool(mute), }, Property { key: libspa_sys::SPA_PROP_mute, flags: PropertyFlags::empty(), value: Value::Bool(mute), }, ], ); } fn node_set_volumes(node: &Node, volumes: Vec) { node_set_properties( node, vec![Property { key: libspa_sys::SPA_PROP_channelVolumes, flags: PropertyFlags::empty(), value: Value::ValueArray(ValueArray::Float(volumes.clone())), }], ); } fn node_set_properties(node: &Node, properties: Vec) { let values = PodSerializer::serialize( std::io::Cursor::new(Vec::new()), &Value::Object(Object { type_: libspa_sys::SPA_TYPE_OBJECT_Props, id: libspa_sys::SPA_PARAM_Props, properties, }), ); if let Ok((values, _)) = values { if let Some(pod) = Pod::from_bytes(&values.into_inner()) { node.set_param(ParamType::Props, 0, pod); } } } fn device_set_mute( device: &Device, route_index: i32, route_device: i32, mute: bool, ) { device_set_route_properties( device, route_index, route_device, vec![ Property { key: libspa_sys::SPA_PROP_mute, flags: PropertyFlags::empty(), value: Value::Bool(mute), }, Property { key: libspa_sys::SPA_PROP_mute, flags: PropertyFlags::empty(), value: Value::Bool(mute), }, ], ); } fn device_set_volumes( device: &Device, route_index: i32, route_device: i32, volumes: Vec, ) { device_set_route_properties( device, route_index, route_device, vec![Property { key: libspa_sys::SPA_PROP_channelVolumes, flags: PropertyFlags::empty(), value: Value::ValueArray(ValueArray::Float(volumes.clone())), }], ); } fn device_set_route(device: &Device, route_index: i32, route_device: i32) { device_set_route_properties(device, route_index, route_device, Vec::new()); } fn device_set_route_properties( device: &Device, route_index: i32, route_device: i32, properties: Vec, ) { let mut route_properties = Vec::new(); route_properties.push(Property { key: libspa_sys::SPA_PARAM_ROUTE_index, flags: PropertyFlags::empty(), value: Value::Int(route_index), }); route_properties.push(Property { key: libspa_sys::SPA_PARAM_ROUTE_device, flags: PropertyFlags::empty(), value: Value::Int(route_device), }); if !properties.is_empty() { route_properties.push(Property { key: libspa_sys::SPA_PARAM_ROUTE_props, flags: PropertyFlags::empty(), value: Value::Object(Object { type_: libspa_sys::SPA_TYPE_OBJECT_Props, id: libspa_sys::SPA_PARAM_Route, properties, }), }); } route_properties.push(Property { key: libspa_sys::SPA_PARAM_ROUTE_save, flags: PropertyFlags::empty(), value: Value::Bool(true), }); let route_properties = route_properties; let values = PodSerializer::serialize( std::io::Cursor::new(Vec::new()), &Value::Object(Object { type_: libspa_sys::SPA_TYPE_OBJECT_ParamRoute, id: libspa_sys::SPA_PARAM_Route, properties: route_properties, }), ); if let Ok((values, _)) = values { if let Some(pod) = Pod::from_bytes(&values.into_inner()) { device.set_param(ParamType::Route, 0, pod); } } } fn device_set_profile(device: &Device, profile_index: i32) { let properties = vec![ Property { key: libspa_sys::SPA_PARAM_PROFILE_index, flags: PropertyFlags::empty(), value: Value::Int(profile_index), }, Property { key: libspa_sys::SPA_PARAM_PROFILE_save, flags: PropertyFlags::empty(), value: Value::Bool(true), }, ]; let values = PodSerializer::serialize( std::io::Cursor::new(Vec::new()), &Value::Object(Object { type_: libspa_sys::SPA_TYPE_OBJECT_ParamProfile, id: libspa_sys::SPA_PARAM_Profile, properties, }), ); if let Ok((values, _)) = values { if let Some(pod) = Pod::from_bytes(&values.into_inner()) { device.set_param(ParamType::Profile, 0, pod); } } } wiremix-0.7.0/src/wirehose/link.rs000066400000000000000000000023331504750620600171270ustar00rootroot00000000000000use std::rc::Rc; use pipewire::{ link::{Link, LinkChangeMask, LinkInfoRef}, proxy::Listener, registry::{GlobalObject, Registry}, }; use libspa::utils::dict::DictRef; use crate::wirehose::event_sender::EventSender; use crate::wirehose::StateEvent; pub fn monitor_link( registry: &Registry, object: &GlobalObject<&DictRef>, sender: &Rc, ) -> Option<(Rc, Box)> { let link: Link = registry.bind(object).ok()?; let link = Rc::new(link); let listener = link .add_listener_local() .info({ let sender_weak = Rc::downgrade(sender); move |info| { let Some(sender) = sender_weak.upgrade() else { return; }; for change in info.change_mask().iter() { if change == LinkChangeMask::PROPS { link_info_props(&sender, info); } } } }) .register(); Some((link, Box::new(listener))) } fn link_info_props(sender: &EventSender, link_info: &LinkInfoRef) { // Ignore props and get the nodes directly from the link info. sender.send(StateEvent::from(link_info)); } wiremix-0.7.0/src/wirehose/media_class.rs000066400000000000000000000006031504750620600204340ustar00rootroot00000000000000//! Media class classification methods. pub fn is_sink(s: &str) -> bool { matches!(s, "Audio/Sink" | "Audio/Duplex") } pub fn is_source(s: &str) -> bool { matches!(s, "Audio/Source" | "Audio/Duplex" | "Audio/Source/Virtual") } pub fn is_sink_input(s: &str) -> bool { s == "Stream/Output/Audio" } pub fn is_source_output(s: &str) -> bool { s == "Stream/Input/Audio" } wiremix-0.7.0/src/wirehose/metadata.rs000066400000000000000000000027101504750620600177510ustar00rootroot00000000000000use std::rc::Rc; use pipewire::{ metadata::Metadata, proxy::Listener, registry::{GlobalObject, Registry}, }; use libspa::utils::dict::DictRef; use crate::wirehose::event_sender::EventSender; use crate::wirehose::{ObjectId, StateEvent}; pub fn monitor_metadata( registry: &Registry, object: &GlobalObject<&DictRef>, sender: &Rc, ) -> Option<(Rc, Box)> { let object_id = ObjectId::from(object); let props = object.props?; let metadata_name = props.get("metadata.name")?; if metadata_name != "default" { return None; } sender.send(StateEvent::MetadataMetadataName { object_id, metadata_name: String::from(metadata_name), }); let metadata: Metadata = registry.bind(object).ok()?; let metadata = Rc::new(metadata); let listener = metadata .add_listener_local() .property({ let sender_weak = Rc::downgrade(sender); move |subject, key, _type, value| { let Some(sender) = sender_weak.upgrade() else { return 0; }; sender.send(StateEvent::MetadataProperty { object_id, subject, key: key.map(String::from), value: value.map(String::from), }); 0 } }) .register(); Some((metadata, Box::new(listener))) } wiremix-0.7.0/src/wirehose/node.rs000066400000000000000000000107511504750620600171220ustar00rootroot00000000000000use std::rc::Rc; use pipewire::{ node::{Node, NodeChangeMask, NodeInfoRef}, proxy::Listener, registry::{GlobalObject, Registry}, }; use libspa::{ param::ParamType, pod::{Object, Value, ValueArray}, utils::dict::DictRef, }; use crate::wirehose::event_sender::EventSender; use crate::wirehose::{ deserialize::deserialize, ObjectId, PropertyStore, StateEvent, }; pub fn monitor_node( registry: &Registry, object: &GlobalObject<&DictRef>, sender: &Rc, ) -> Option<(Rc, Box)> { let object_id = ObjectId::from(object); let props = object.props?; let media_class = props.get("media.class")?; match media_class { "Audio/Sink" => (), "Audio/Source" => (), "Stream/Output/Audio" => (), "Stream/Input/Audio" => (), _ => return None, } // Don't monitor capture streams to avoid clutter. match props.get("node.name") { // We especially don't want to capture our own capture streams. Some("wiremix-capture") => return None, Some("PulseAudio Volume Control") => return None, Some("ncpamixer") => return None, _ => (), } let node: Node = registry.bind(object).ok()?; let node = Rc::new(node); let listener = node .add_listener_local() .info({ let sender_weak = Rc::downgrade(sender); move |info| { let Some(sender) = sender_weak.upgrade() else { return; }; for change in info.change_mask().iter() { if change == NodeChangeMask::PROPS { node_info_props(&sender, object_id, info); } } } }) .param({ let sender_weak = Rc::downgrade(sender); move |_seq, id, _index, _next, param| { let Some(sender) = sender_weak.upgrade() else { return; }; if let Some(param) = deserialize(param) { match id { ParamType::Props => { node_param_props(&sender, object_id, param); } ParamType::PortConfig => { node_param_port_config(&sender, object_id, param); } _ => {} } } } }) .register(); node.subscribe_params(&[ParamType::Props, ParamType::PortConfig]); Some((node, Box::new(listener))) } fn node_info_props( sender: &EventSender, object_id: ObjectId, node_info: &NodeInfoRef, ) { let Some(props) = node_info.props() else { return; }; let property_store = PropertyStore::from(props); sender.send(StateEvent::NodeProperties { object_id, props: property_store, }); } fn node_param_props(sender: &EventSender, object_id: ObjectId, param: Object) { for prop in param.properties { match prop.key { libspa_sys::SPA_PROP_channelVolumes => { if let Value::ValueArray(ValueArray::Float(value)) = prop.value { sender.send(StateEvent::NodeVolumes { object_id, volumes: value, }); } } libspa_sys::SPA_PROP_mute => { if let Value::Bool(value) = prop.value { sender.send(StateEvent::NodeMute { object_id, mute: value, }); } } _ => {} } } } fn node_param_port_config( sender: &EventSender, object_id: ObjectId, param: Object, ) { let Some(format_prop) = param .properties .into_iter() .find(|prop| prop.key == libspa_sys::SPA_PARAM_PORT_CONFIG_format) else { return; }; let Value::Object(Object { properties, .. }) = format_prop.value else { return; }; let Some(position_prop) = properties .into_iter() .find(|prop| prop.key == libspa_sys::SPA_FORMAT_AUDIO_position) else { return; }; let Value::ValueArray(ValueArray::Id(value)) = position_prop.value else { return; }; let positions = value.into_iter().map(|x| x.0).collect(); sender.send(StateEvent::NodePositions { object_id, positions, }); } wiremix-0.7.0/src/wirehose/object_id.rs000066400000000000000000000013311504750620600201110ustar00rootroot00000000000000//! Type for representing PipeWire object IDs. use libspa::utils::dict::DictRef; use pipewire::registry::GlobalObject; /// A PipeWire object ID. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)] pub struct ObjectId(u32); impl From<&GlobalObject<&DictRef>> for ObjectId { fn from(obj: &GlobalObject<&DictRef>) -> Self { ObjectId(obj.id) } } impl From for u32 { fn from(id: ObjectId) -> u32 { id.0 } } #[allow(clippy::to_string_trait_impl)] // This isn't for end-users impl ToString for ObjectId { fn to_string(&self) -> String { self.0.to_string() } } impl ObjectId { pub fn from_raw_id(id: u32) -> Self { ObjectId(id) } } wiremix-0.7.0/src/wirehose/property_store.rs000066400000000000000000000341351504750620600212770ustar00rootroot00000000000000use std::collections::HashMap; use std::str::FromStr; use libspa::utils::dict::DictRef; use anyhow::{anyhow, Result}; use crate::wirehose::ObjectId; #[derive(Debug, Clone)] enum PropertyValue { String, Bool(bool), U32(u32), U64(u64), I32(i32), ObjectId(ObjectId), } #[derive(Debug, Clone)] struct PropertyEntry { raw: String, parsed: PropertyValue, } /// Stores the "info.props" properties of a PipeWire object. /// /// Provides typed accessors for supported standard PipeWire properties. /// [PropertyStore::raw] can be used to access any property (including /// unsupported ones) as an unparsed string. #[derive(Default, Debug, Clone)] pub struct PropertyStore { properties: HashMap, } impl From for PropertyValue { fn from(_value: String) -> Self { PropertyValue::String } } impl From for PropertyValue { fn from(value: bool) -> Self { PropertyValue::Bool(value) } } impl From for PropertyValue { fn from(value: u32) -> Self { PropertyValue::U32(value) } } impl From for PropertyValue { fn from(value: u64) -> Self { PropertyValue::U64(value) } } impl From for PropertyValue { fn from(value: i32) -> Self { PropertyValue::I32(value) } } impl From for PropertyValue { fn from(value: ObjectId) -> Self { PropertyValue::ObjectId(value) } } trait PropertyValueAccess { fn get_value(&self) -> Option<&T>; } impl PropertyValueAccess for PropertyEntry { fn get_value(&self) -> Option<&String> { match &self.parsed { PropertyValue::String => Some(&self.raw), _ => None, } } } impl PropertyValueAccess for PropertyEntry { fn get_value(&self) -> Option<&bool> { match &self.parsed { PropertyValue::Bool(u) => Some(u), _ => None, } } } impl PropertyValueAccess for PropertyEntry { fn get_value(&self) -> Option<&u32> { match &self.parsed { PropertyValue::U32(u) => Some(u), _ => None, } } } impl PropertyValueAccess for PropertyEntry { fn get_value(&self) -> Option<&u64> { match &self.parsed { PropertyValue::U64(u) => Some(u), _ => None, } } } impl PropertyValueAccess for PropertyEntry { fn get_value(&self) -> Option<&i32> { match &self.parsed { PropertyValue::I32(i) => Some(i), _ => None, } } } impl PropertyValueAccess for PropertyEntry { fn get_value(&self) -> Option<&ObjectId> { match &self.parsed { PropertyValue::ObjectId(id) => Some(id), _ => None, } } } macro_rules! define_properties { ($($name:ident: $type:ty = $key:literal),* $(,)?) => { fn parse_dict_item(key: &str, raw: &str) -> Result { match key { $( $key => { let parsed: $type = raw.parse().map_err(|_| { anyhow!( "Failed to parse '{}' as '{}'", raw, stringify!($type) ) })?; Ok(PropertyEntry { raw: String::from(raw), parsed: parsed.into() }) } )* _ => Err(anyhow!("Unknown key '{}'", key)) } } impl PropertyStore { $( #[doc = "Get a reference to the parsed "] #[doc = stringify!($key)] #[doc = " property."] pub fn $name(&self) -> Option<&$type> { self.properties .get($key) .and_then(|entry| entry.get_value()) } #[cfg(test)] paste::paste! { pub fn [](&mut self, value: $type) { self.properties.insert( String::from($key), PropertyEntry { raw: value.to_string(), parsed: value.into(), }, ); } } )* } // Ensure that all property identifiers match their keys. #[cfg(test)] mod property_tests { #[test] fn ident_and_key_match() { $( assert_eq!( stringify!($name), $key.replace(&['.', '-'], "_") ); )* } } } } impl From<&DictRef> for PropertyStore { fn from(dict: &DictRef) -> Self { let mut properties = HashMap::default(); for (key, value) in dict.iter() { let entry = parse_dict_item(key, value).unwrap_or_else(|_| PropertyEntry { raw: value.to_string(), parsed: PropertyValue::String, }); properties.insert(String::from(key), entry); } PropertyStore { properties } } } impl PropertyStore { /// Get the raw string value for a property. pub fn raw(&self, key: &str) -> Option<&str> { self.properties.get(key).map(|e| e.raw.as_str()) } } impl FromStr for ObjectId { type Err = std::num::ParseIntError; fn from_str(s: &str) -> Result { u32::from_str(s).map(ObjectId::from_raw_id) } } define_properties! { // Key used by wireplumber card_profile_device: i32 = "card.profile.device", // Keys from src/pipewire/keys.h pipewire_protocol: String = "pipewire.protocol", pipewire_access: String = "pipewire.access", pipewire_client_access: String = "pipewire.client.access", pipewire_sec_pid: i32 = "pipewire.sec.pid", pipewire_sec_uid: u32 = "pipewire.sec.uid", pipewire_sec_gid: u32 = "pipewire.sec.gid", pipewire_sec_label: String = "pipewire.sec.label", pipewire_sec_socket: String = "pipewire.sec.socket", pipewire_sec_engine: String = "pipewire.sec.engine", pipewire_sec_app_id: String = "pipewire.sec.app-id", pipewire_sec_instance_id: String = "pipewire.sec.instance-id", library_name_system: String = "library.name.system", library_name_loop: String = "library.name.loop", library_name_dbus: String = "library.name.dbus", object_path: String = "object.path", object_id: ObjectId = "object.id", object_serial: u64 = "object.serial", object_linger: bool = "object.linger", object_register: bool = "object.register", object_export: bool = "object.export", config_prefix: String = "config.prefix", config_name: String = "config.name", config_override_prefix: String = "config.override.prefix", config_override_name: String = "config.override.name", loop_name: String = "loop.name", loop_class: String = "loop.class", loop_rt_prio: i32 = "loop.rt-prio", loop_cancel: bool = "loop.cancel", context_user_name: String = "context.user-name", context_host_name: String = "context.host-name", core_name: String = "core.name", core_version: String = "core.version", core_daemon: bool = "core.daemon", cpu_max_align: u32 = "cpu.max-align", priority_session: i32 = "priority.session", priority_driver: i32 = "priority.driver", remote_name: String = "remote.name", remote_intention: String = "remote.intention", application_name: String = "application.name", application_id: String = "application.id", application_version: String = "application.version", application_icon: String = "application.icon", application_icon_name: String = "application.icon-name", application_language: String = "application.language", application_process_id: u64 = "application.process.id", application_process_binary: String = "application.process.binary", application_process_user: String = "application.process.user", application_process_host: String = "application.process.host", application_process_machine_id: String = "application.process.machine-id", application_process_session_id: ObjectId = "application.process.session-id", window_x11_display: String = "window.x11.display", client_id: ObjectId = "client.id", client_name: String = "client.name", client_api: String = "client.api", node_id: ObjectId = "node.id", node_name: String = "node.name", node_nick: String = "node.nick", node_description: String = "node.description", node_plugged: u64 = "node.plugged", node_session: ObjectId = "node.session", node_group: String = "node.group", node_sync_group: String = "node.sync-group", node_sync: bool = "node.sync", node_transport: bool = "node.transport", node_exclusive: bool = "node.exclusive", node_autoconnect: bool = "node.autoconnect", node_latency: String = "node.latency", node_max_latency: String = "node.max-latency", node_lock_quantum: bool = "node.lock-quantum", node_force_quantum: u32 = "node.force-quantum", node_rate: String = "node.rate", node_lock_rate: bool = "node.lock-rate", node_force_rate: u32 = "node.force-rate", node_dont_reconnect: bool = "node.dont-reconnect", node_always_process: bool = "node.always-process", node_want_driver: bool = "node.want-driver", node_pause_on_idle: bool = "node.pause-on-idle", node_suspend_on_idle: bool = "node.suspend-on-idle", node_cache_params: bool = "node.cache-params", node_transport_sync: bool = "node.transport.sync", node_driver: bool = "node.driver", node_driver_id: ObjectId = "node.driver-id", node_async: bool = "node.async", node_loop_name: String = "node.loop.name", node_loop_class: String = "node.loop.class", node_stream: bool = "node.stream", node_virtual: bool = "node.virtual", node_passive: bool = "node.passive", node_link_group: String = "node.link-group", node_network: bool = "node.network", node_trigger: bool = "node.trigger", node_channel_names: String = "node.channel-names", node_device_port_name_prefix: String = "node.device-port-name-prefix", port_id: ObjectId = "port.id", port_name: String = "port.name", port_direction: String = "port.direction", port_alias: String = "port.alias", port_physical: bool = "port.physical", port_terminal: bool = "port.terminal", port_control: bool = "port.control", port_monitor: bool = "port.monitor", port_cache_params: bool = "port.cache-params", port_extra: String = "port.extra", port_passive: bool = "port.passive", port_ignore_latency: bool = "port.ignore-latency", port_group: String = "port.group", link_id: ObjectId = "link.id", link_input_node: ObjectId = "link.input.node", link_input_port: ObjectId = "link.input.port", link_output_node: ObjectId = "link.output.node", link_output_port: ObjectId = "link.output.port", link_passive: bool = "link.passive", link_feedback: bool = "link.feedback", link_async: bool = "link.async", device_id: ObjectId = "device.id", device_name: String = "device.name", device_plugged: u64 = "device.plugged", device_nick: String = "device.nick", device_string: String = "device.string", device_api: String = "device.api", device_description: String = "device.description", device_bus_path: String = "device.bus-path", device_serial: String = "device.serial", device_vendor_id: String = "device.vendor.id", device_vendor_name: String = "device.vendor.name", device_product_id: String = "device.product.id", device_product_name: String = "device.product.name", device_class: String = "device.class", device_form_factor: String = "device.form-factor", device_bus: String = "device.bus", device_subsystem: String = "device.subsystem", device_sysfs_path: String = "device.sysfs.path", device_icon: String = "device.icon", device_icon_name: String = "device.icon-name", device_intended_roles: String = "device.intended-roles", device_cache_params: bool = "device.cache-params", module_id: ObjectId = "module.id", module_name: String = "module.name", module_author: String = "module.author", module_description: String = "module.description", module_usage: String = "module.usage", module_version: String = "module.version", module_deprecated: String = "module.deprecated", factory_id: ObjectId = "factory.id", factory_name: String = "factory.name", factory_usage: String = "factory.usage", factory_type_name: String = "factory.type.name", factory_type_version: u32 = "factory.type.version", stream_is_live: bool = "stream.is-live", stream_latency_min: String = "stream.latency.min", stream_latency_max: String = "stream.latency.max", stream_monitor: bool = "stream.monitor", stream_dont_remix: bool = "stream.dont-remix", stream_capture_sink: bool = "stream.capture.sink", media_type: String = "media.type", media_category: String = "media.category", media_role: String = "media.role", media_class: String = "media.class", media_name: String = "media.name", media_title: String = "media.title", media_artist: String = "media.artist", media_album: String = "media.album", media_copyright: String = "media.copyright", media_software: String = "media.software", media_language: String = "media.language", media_filename: String = "media.filename", media_icon: String = "media.icon", media_icon_name: String = "media.icon-name", media_comment: String = "media.comment", media_date: String = "media.date", media_format: u32 = "media.format", format_dsp: String = "format.dsp", audio_channel: String = "audio.channel", audio_rate: u32 = "audio.rate", audio_channels: u32 = "audio.channels", audio_format: String = "audio.format", audio_allowed_rates: String = "audio.allowed-rates", target_object: String = "target.object", } wiremix-0.7.0/src/wirehose/proxy_registry.rs000066400000000000000000000151721504750620600213100ustar00rootroot00000000000000use std::collections::HashMap; use std::rc::Rc; use anyhow::Result; use nix::sys::eventfd::{EfdFlags, EventFd}; use pipewire::{ client::Client, device::Device, link::Link, metadata::Metadata, node::Node, proxy::{Listener, ProxyListener, ProxyT}, }; use crate::wirehose::ObjectId; /// Storage for keeping proxies and their listeners alive pub struct ProxyRegistry { /// Storage for keeping devices alive pub devices: HashMap>, /// Storage for keeping clients alive pub clients: HashMap>, /// Storage for keeping nodes alive pub nodes: HashMap>, /// Storage for keeping metadata alive pub metadatas: HashMap>, /// Storage for keeping links alive links: HashMap>, /// Storage for keeping listeners alive listeners: HashMap>>, /// Devices, clients, nodes, links, and metadata pending deletion garbage_proxies_t: Vec>, /// Listeners pending deletion garbage_listeners: Vec>, /// EventFd for signalling to [`wirehose`](`crate::wirehose`) that objects /// are pending deletion and that [`Self::collect_garbage()`] needs to be /// called pub gc_fd: EventFd, } impl Drop for ProxyRegistry { fn drop(&mut self) { // Drop listeners while their proxies are still alive. self.garbage_listeners.clear(); self.listeners.clear(); } } impl ProxyRegistry { pub fn try_new() -> Result { let gc_fd = EventFd::from_value_and_flags(0, EfdFlags::EFD_NONBLOCK)?; Ok(Self { devices: HashMap::new(), clients: HashMap::new(), nodes: HashMap::new(), links: HashMap::new(), metadatas: HashMap::new(), listeners: HashMap::new(), garbage_proxies_t: Vec::new(), garbage_listeners: Vec::new(), gc_fd, }) } /// Clean up proxies and listeners pending deletion. It is unsafe to call /// this from within the PipeWire main loop! pub fn collect_garbage(&mut self) { self.garbage_listeners.clear(); self.garbage_proxies_t.clear(); let _ = self.gc_fd.read(); } /// Register a device and its listener, evicting any with the same ID. pub fn add_device( &mut self, obj_id: ObjectId, device: Rc, listener: Box, ) { if let Some(old) = self.devices.insert(obj_id, device) { self.garbage_proxies_t.push(old); if let Some(listeners) = self.listeners.get_mut(&obj_id) { self.garbage_listeners.append(listeners); } let _ = self.gc_fd.arm(); } let v = self.listeners.entry(obj_id).or_default(); v.push(listener); } /// Register a client and its listener, evicting any with the same ID. pub fn add_client( &mut self, obj_id: ObjectId, client: Rc, listener: Box, ) { if let Some(old) = self.clients.insert(obj_id, client) { self.garbage_proxies_t.push(old); if let Some(listeners) = self.listeners.get_mut(&obj_id) { self.garbage_listeners.append(listeners); } let _ = self.gc_fd.arm(); } let v = self.listeners.entry(obj_id).or_default(); v.push(listener); } /// Register a node and its listener, evicting any with the same ID. pub fn add_node( &mut self, obj_id: ObjectId, node: Rc, listener: Box, ) { if let Some(old) = self.nodes.insert(obj_id, node) { self.garbage_proxies_t.push(old); if let Some(listeners) = self.listeners.get_mut(&obj_id) { self.garbage_listeners.append(listeners); } let _ = self.gc_fd.arm(); } let v = self.listeners.entry(obj_id).or_default(); v.push(listener); } /// Register a link and its listener, evicting any with the same ID. pub fn add_link( &mut self, obj_id: ObjectId, link: Rc, listener: Box, ) { if let Some(old) = self.links.insert(obj_id, link) { self.garbage_proxies_t.push(old); if let Some(listeners) = self.listeners.get_mut(&obj_id) { self.garbage_listeners.append(listeners); } let _ = self.gc_fd.arm(); } let v = self.listeners.entry(obj_id).or_default(); v.push(listener); } /// Register metadata and its listener, evicting any with the same ID. pub fn add_metadata( &mut self, obj_id: ObjectId, metadata: Rc, listener: Box, ) { if let Some(old) = self.metadatas.insert(obj_id, metadata) { self.garbage_proxies_t.push(old); if let Some(listeners) = self.listeners.get_mut(&obj_id) { self.garbage_listeners.append(listeners); } let _ = self.gc_fd.arm(); } let v = self.listeners.entry(obj_id).or_default(); v.push(listener); } /// Register a listener, evicting any with the same ID. pub fn add_proxy_listener( &mut self, obj_id: ObjectId, listener: ProxyListener, ) { let v = self.listeners.entry(obj_id).or_default(); v.push(Box::new(listener)); } /// Remove an object, defering deletion until [`Self::collect_garbage()`] /// is called. pub fn remove(&mut self, obj_id: ObjectId) { if let Some(listeners) = self.listeners.get_mut(&obj_id) { if !listeners.is_empty() { let _ = self.gc_fd.arm(); } self.garbage_listeners.append(listeners); } if let Some(old) = self.devices.remove(&obj_id) { self.garbage_proxies_t.push(old); let _ = self.gc_fd.arm(); } if let Some(old) = self.clients.remove(&obj_id) { self.garbage_proxies_t.push(old); let _ = self.gc_fd.arm(); } if let Some(old) = self.nodes.remove(&obj_id) { self.garbage_proxies_t.push(old); let _ = self.gc_fd.arm(); } if let Some(old) = self.links.remove(&obj_id) { self.garbage_proxies_t.push(old); let _ = self.gc_fd.arm(); } if let Some(old) = self.metadatas.remove(&obj_id) { self.garbage_proxies_t.push(old); let _ = self.gc_fd.arm(); } } } wiremix-0.7.0/src/wirehose/session.rs000066400000000000000000000371071504750620600176640ustar00rootroot00000000000000//! Setup and teardown of PipeWire monitoring. //! //! [`Session::spawn()`] starts a PipeWire monitoring thread. use anyhow::Result; use std::cell::RefCell; use std::rc::Rc; use std::sync::Arc; use std::thread; use nix::sys::eventfd::{EfdFlags, EventFd}; use std::os::fd::AsRawFd; use pipewire::{ main_loop::MainLoop, properties::properties, proxy::ProxyT, types::ObjectType, }; use crate::wirehose::{ client, command::Command, device, event_sender::EventSender, execute, link, metadata, node, proxy_registry::ProxyRegistry, stream_registry::StreamRegistry, sync_registry::SyncRegistry, CommandSender, EventHandler, ObjectId, StateEvent, }; /// Handle for a PipeWire monitoring thread. /// /// On cleanup, the PipeWire [`MainLoop`](`pipewire::main_loop::MainLoop`) will /// be notified to [`quit()`](`pipewire::main_loop::MainLoop::quit()`), and the /// thread will be joined. pub struct Session { fd: Arc, handle: Option>, /// Channel for sending [`Command`]s to be executed tx: pipewire::channel::Sender, } impl Session { /// Spawns a thread to monitor the PipeWire instance. /// /// [`Event`](`crate::wirehose::event::Event`)s from PipeWire are sent to /// the provided `handler`. /// /// Returns a [`Session`] handle for sending commands and for automatically /// cleaning up the thread. pub fn spawn( remote: Option, handler: F, ) -> Result { let shutdown_fd = Arc::new(EventFd::from_value_and_flags(0, EfdFlags::EFD_NONBLOCK)?); let (tx, rx) = pipewire::channel::channel::(); let handle = thread::spawn({ let shutdown_fd = Arc::clone(&shutdown_fd); move || { let _ = run(remote, rx, handler, shutdown_fd); } }); Ok(Self { fd: shutdown_fd, handle: Some(handle), tx, }) } } /// Wrapper for handling PipeWire initialization/deinitialization. fn run( remote: Option, rx: pipewire::channel::Receiver, handler: F, shutdown_fd: Arc, ) -> Result<()> { pipewire::init(); let _guard = scopeguard::guard((), |_| unsafe { pipewire::deinit(); }); let main_loop = MainLoop::new(None)?; let sender = Rc::new(EventSender::new(handler, main_loop.downgrade())); let err_sender = Rc::clone(&sender); monitor_pipewire(remote, main_loop, sender, rx, shutdown_fd) .unwrap_or_else(move |e| { err_sender.send_error(e.to_string()); }); Ok(()) } impl Drop for Session { /// Shut down the PipeWire monitoring thread. fn drop(&mut self) { let _ = self.fd.arm(); if let Some(handle) = self.handle.take() { let _ = handle.join(); } } } /// Commands are sent asynchronously and are executed on the PipeWire monitoring thread. impl CommandSender for Session { /// Start capturing peak levels for a node. Set `capture_sink` to capture /// from a source or a sink. fn node_capture_start( &self, object_id: ObjectId, object_serial: u64, capture_sink: bool, ) { let _ = self.tx.send(Command::NodeCaptureStart( object_id, object_serial, capture_sink, )); } /// Stop capturing peak levels for a node. fn node_capture_stop(&self, object_id: ObjectId) { let _ = self.tx.send(Command::NodeCaptureStop(object_id)); } /// Mute a node. fn node_mute(&self, object_id: ObjectId, mute: bool) { let _ = self.tx.send(Command::NodeMute(object_id, mute)); } /// Set the volumes on a node's channels. fn node_volumes(&self, object_id: ObjectId, volumes: Vec) { let _ = self.tx.send(Command::NodeVolumes(object_id, volumes)); } /// Mute a device. fn device_mute( &self, object_id: ObjectId, route_index: i32, route_device: i32, mute: bool, ) { let _ = self.tx.send(Command::DeviceMute( object_id, route_index, route_device, mute, )); } /// Change a device's profile. fn device_set_profile(&self, object_id: ObjectId, profile_index: i32) { let _ = self .tx .send(Command::DeviceSetProfile(object_id, profile_index)); } /// Change a device's route. fn device_set_route( &self, object_id: ObjectId, route_index: i32, route_device: i32, ) { let _ = self.tx.send(Command::DeviceSetRoute( object_id, route_index, route_device, )); } /// Change the volumes of a device's channels. fn device_volumes( &self, object_id: ObjectId, route_index: i32, route_device: i32, volumes: Vec, ) { let _ = self.tx.send(Command::DeviceVolumes( object_id, route_index, route_device, volumes, )); } /// Set a metadata property. Set `type_` to None to clear all metadata for /// the subject. Set `value` to None to clear the metdata for the key. fn metadata_set_property( &self, object_id: ObjectId, subject: u32, key: String, type_: Option, value: Option, ) { let _ = self.tx.send(Command::MetadataSetProperty( object_id, subject, key, type_, value, )); } } /// Monitors PipeWire. /// /// Sets up core listeners and runs the PipeWire main loop. fn monitor_pipewire( remote: Option, main_loop: MainLoop, sender: Rc, rx: pipewire::channel::Receiver, shutdown_fd: Arc, ) -> Result<()> { let context = pipewire::context::Context::new(&main_loop)?; let props = remote.map(|remote| { properties! { *pipewire::keys::REMOTE_NAME => remote } }); let core = Rc::new(context.connect(props)?); let fd = shutdown_fd.as_raw_fd(); let _shutdown_watch = main_loop .loop_() .add_io(fd, libspa::support::system::IoFlags::IN, { let main_loop_weak = main_loop.downgrade(); move |_status| { if let Some(main_loop) = main_loop_weak.upgrade() { main_loop.quit(); } } }); let syncs = Rc::new(RefCell::new(SyncRegistry::default())); let _core_listener = core .add_listener_local() .done({ let sender_weak = Rc::downgrade(&sender); let syncs_weak = Rc::downgrade(&syncs); move |_id, seq| { let Some(sender) = sender_weak.upgrade() else { return; }; let Some(syncs) = syncs_weak.upgrade() else { return; }; if syncs.borrow_mut().done(seq) { sender.send_ready(); } } }) .error({ let sender_weak = Rc::downgrade(&sender); move |_id, _seq, _res, message| { if let Some(sender) = sender_weak.upgrade() { sender.send_error(message.to_string()); }; } }) .register(); let registry = Rc::new(core.get_registry()?); let registry_weak = Rc::downgrade(®istry); // Proxies and their listeners need to stay alive so store them here let proxies = Rc::new(RefCell::new(ProxyRegistry::try_new()?)); // It's not safe to delete proxies and listeners during PipeWire callbacks, // so registries defer cleanup and use an EventFd to signal that objects // are pending deletion. let _proxy_gc_watch = main_loop.loop_().add_io( proxies.borrow().gc_fd.as_raw_fd(), libspa::support::system::IoFlags::IN, { let proxies = Rc::clone(&proxies); move |_status| { proxies.borrow_mut().collect_garbage(); } }, ); // Proxies and their listeners need to stay alive so store them here let streams = Rc::new(RefCell::new(StreamRegistry::try_new()?)); // It's not safe to delete proxies and listeners during PipeWire callbacks, // so registries defer cleanup and use an EventFd to signal that objects // are pending deletion. let _streams_gc_watch = main_loop.loop_().add_io( streams.borrow().gc_fd.as_raw_fd(), libspa::support::system::IoFlags::IN, { let streams = Rc::clone(&streams); let sender_weak = Rc::downgrade(&sender); move |_status| { let collected = streams.borrow_mut().collect_garbage(); if let Some(sender) = sender_weak.upgrade() { for object_id in collected { sender.send(StateEvent::StreamStopped { object_id }); } } } }, ); let _registry_listener = registry .add_listener_local() .global({ let core_weak = Rc::downgrade(&core); let proxies = Rc::clone(&proxies); let sender_weak = Rc::downgrade(&sender); let streams_weak = Rc::downgrade(&streams); let syncs_weak = Rc::downgrade(&syncs); move |object| { let object_id = ObjectId::from(object); let Some(registry) = registry_weak.upgrade() else { return; }; let Some(sender) = sender_weak.upgrade() else { return; }; let Some(streams) = streams_weak.upgrade() else { return; }; let Some(core) = core_weak.upgrade() else { return; }; let Some(syncs) = syncs_weak.upgrade() else { return; }; let proxy_spe = match object.type_ { ObjectType::Client => { let result = client::monitor_client(®istry, object, &sender); if let Some((node, listener)) = result { proxies.borrow_mut().add_client( object_id, Rc::clone(&node), listener, ); Some(node as Rc) } else { None } } ObjectType::Node => { let result = node::monitor_node(®istry, object, &sender); if let Some((node, listener)) = result { proxies.borrow_mut().add_node( object_id, Rc::clone(&node), listener, ); Some(node as Rc) } else { None } } ObjectType::Device => { let result = device::monitor_device(®istry, object, &sender); match result { Some((device, listener)) => { proxies.borrow_mut().add_device( object_id, Rc::clone(&device), listener, ); Some(device as Rc) } None => None, } } ObjectType::Link => { let result = link::monitor_link(®istry, object, &sender); match result { Some((link, listener)) => { proxies.borrow_mut().add_link( object_id, Rc::clone(&link), listener, ); Some(link as Rc) } None => None, } } ObjectType::Metadata => { let result = metadata::monitor_metadata( ®istry, object, &sender, ); match result { Some((metadata, listener)) => { proxies.borrow_mut().add_metadata( object_id, Rc::clone(&metadata), listener, ); Some(metadata as Rc) } None => None, } } _ => None, }; let Some(proxy_spe) = proxy_spe else { return; }; let proxy = proxy_spe.upcast_ref(); // Use a weak ref to prevent references cycle between Proxy and proxies: // - ref on proxies in the closure, bound to the Proxy lifetime // - proxies owning a ref on Proxy as well let proxies_weak = Rc::downgrade(&proxies); let streams_weak = Rc::downgrade(&streams); let sender_weak = Rc::downgrade(&sender); let listener = proxy .add_listener_local() .removed(move || { if let Some(sender) = sender_weak.upgrade() { sender.send(StateEvent::Removed { object_id }); }; if let Some(proxies) = proxies_weak.upgrade() { proxies.borrow_mut().remove(object_id); }; if let Some(streams) = streams_weak.upgrade() { streams.borrow_mut().remove(object_id); }; }) .register(); proxies.borrow_mut().add_proxy_listener(object_id, listener); syncs.borrow_mut().global(&core); } }) .register(); let proxies = Rc::clone(&proxies); let _receiver = rx.attach(main_loop.loop_(), { let core_weak = Rc::downgrade(&core); let sender_weak = Rc::downgrade(&sender); let streams_weak = Rc::downgrade(&streams); move |command| { let Some(core) = core_weak.upgrade() else { return; }; let Some(sender) = sender_weak.upgrade() else { return; }; let Some(streams) = streams_weak.upgrade() else { return; }; execute::execute_command( &core, sender, &mut streams.borrow_mut(), &Rc::clone(&proxies).borrow(), command, ); } }); main_loop.run(); Ok(()) } wiremix-0.7.0/src/wirehose/state.rs000066400000000000000000000532711504750620600173210ustar00rootroot00000000000000//! Representation of PipeWire state. use std::collections::{HashMap, HashSet}; use crate::wirehose::{ command::Command, media_class, CommandSender, ObjectId, PropertyStore, StateEvent, }; #[derive(Debug)] pub struct Profile { pub index: i32, pub description: String, pub available: bool, pub classes: Vec<(String, Vec)>, } #[derive(Debug)] pub struct EnumRoute { pub index: i32, pub description: String, pub available: bool, pub profiles: Vec, pub devices: Vec, } #[derive(Debug)] pub struct Route { pub index: i32, pub device: i32, pub profiles: Vec, pub description: String, pub available: bool, pub volumes: Vec, pub mute: bool, } #[derive(Default, Debug)] pub struct Device { pub object_id: ObjectId, pub props: PropertyStore, pub profile_index: Option, pub profiles: HashMap, pub routes: HashMap, pub enum_routes: HashMap, } #[derive(Default, Debug)] pub struct Client { pub object_id: ObjectId, pub props: PropertyStore, } #[derive(Default, Debug)] pub struct Node { pub object_id: ObjectId, pub props: PropertyStore, pub volumes: Option>, pub mute: Option, pub peaks: Option>, pub rate: Option, pub positions: Option>, } /// Trait for processing peaks in order to implement effects like ballistics. pub trait PeakProcessor { fn process_peak( &self, current_peak: f32, previous_peak: f32, sample_count: u32, sample_rate: u32, ) -> f32; } impl PeakProcessor for F where F: Fn(f32, f32, u32, u32) -> f32, { fn process_peak( &self, current_peak: f32, previous_peak: f32, sample_count: u32, sample_rate: u32, ) -> f32 { self(current_peak, previous_peak, sample_count, sample_rate) } } impl Node { /// Update peaks with an optional peak processor for ballistics or other /// effects. pub fn update_peaks( &mut self, peaks: &Vec, samples: u32, peak_processor: Option<&dyn PeakProcessor>, ) { let Some(rate) = self.rate else { return; }; // Initialize or resize current peaks. let peaks_ref = self.peaks.get_or_insert_with(Default::default); if peaks_ref.len() != peaks.len() { // New length, clean slate. peaks_ref.clear(); } // Make sure it's the right size. peaks_ref.resize(peaks.len(), 0.0); for (current_peak, new_peak) in peaks_ref.iter_mut().zip(peaks) { match peak_processor { Some(peak_processor) => { *current_peak = peak_processor.process_peak( *current_peak, *new_peak, rate, samples, ); } None => { *current_peak = *new_peak; } } } } } #[derive(Debug)] pub struct Link { pub output_id: ObjectId, pub input_id: ObjectId, } #[derive(Default, Debug)] pub struct Metadata { pub object_id: ObjectId, pub metadata_name: Option, /// Properties for each subject pub properties: HashMap>, } #[derive(Default)] /// PipeWire state, maintained from [`StateEvent`]s from the /// [`wirehose`](`crate::wirehose`) module. /// /// This is primarily for maintaining a representation of the PipeWire state, /// but [`Self::update()`] also handles capture management for starting /// and stopping streaming because the [`wirehose`](`crate::wirehose`) /// callbacks don't individually have enough information to determine when that /// should happen. pub struct State { pub clients: HashMap, pub nodes: HashMap, pub devices: HashMap, pub links: HashMap, pub metadatas: HashMap, pub metadatas_by_name: HashMap, peak_processor: Option>, capturing: Option>, } impl State { /// Provide a peak processor for setting peak levels. pub fn with_peak_processor( mut self, peak_processor: Box, ) -> Self { self.peak_processor = Some(peak_processor); self } /// Enable stream capturing. pub fn with_capture(mut self, enable: bool) -> Self { self.capturing = enable.then_some(Default::default()); self } /// Update the state based on the supplied event. Also handles capture /// management for starting and stopping streaming. pub fn update(&mut self, wirehose: &dyn CommandSender, event: StateEvent) { let mut commands = Vec::::new(); match event { StateEvent::ClientProperties { object_id, props } => { self.client_entry(object_id).props = props; } StateEvent::DeviceProperties { object_id, props } => { self.device_entry(object_id).props = props; } StateEvent::DeviceEnumProfile { object_id, index, description, available, classes, } => { self.device_entry(object_id).profiles.insert( index, Profile { index, description, available, classes, }, ); } StateEvent::DeviceProfile { object_id, index } => { self.device_entry(object_id).profile_index = Some(index); } StateEvent::DeviceRoute { object_id: id, index, device, profiles, description, available, channel_volumes, mute, } => { self.device_entry(id).routes.insert( device, Route { index, device, profiles, description, available, volumes: channel_volumes, mute, }, ); } StateEvent::DeviceEnumRoute { object_id, index, description, available, profiles, devices, } => { self.device_entry(object_id).enum_routes.insert( index, EnumRoute { index, description, available, profiles, devices, }, ); } StateEvent::NodeProperties { object_id, props } => { self.node_entry(object_id).props = props; if let Some(node) = self.nodes.get(&object_id) { commands.extend(self.on_node(node)); } } StateEvent::NodeMute { object_id, mute } => { self.node_entry(object_id).mute = Some(mute); } StateEvent::NodePeaks { object_id, peaks, samples, } => { let node = self.nodes.entry(object_id).or_insert_with(|| Node { object_id, ..Default::default() }); let peak_processor = self.peak_processor.as_deref(); node.update_peaks(&peaks, samples, peak_processor); } StateEvent::NodeRate { object_id, rate } => { self.node_entry(object_id).rate = Some(rate); } StateEvent::NodePositions { object_id, positions, } => { if let Some(node) = self.nodes.get(&object_id) { let changed = node .positions .as_ref() .is_some_and(|p| *p != positions); if changed { commands.extend(self.on_positions_changed(node)); } } self.node_entry(object_id).positions = Some(positions); } StateEvent::NodeVolumes { object_id, volumes } => { self.node_entry(object_id).volumes = Some(volumes); } StateEvent::Link { object_id, output_id, input_id, } => { if !self.inputs(input_id).contains(&output_id) { if let Some(node) = self.nodes.get(&input_id) { commands.extend(self.on_link(node)); } } self.links.insert( object_id, Link { output_id, input_id, }, ); } StateEvent::MetadataMetadataName { object_id, metadata_name, } => { self.metadata_entry(object_id).metadata_name = Some(metadata_name.clone()); self.metadatas_by_name.insert(metadata_name, object_id); } StateEvent::MetadataProperty { object_id, subject, key, value, } => { let properties = self .metadata_entry(object_id) .properties .entry(subject) .or_default(); match key { Some(key) => { match value { Some(value) => { properties.insert(key, value.clone()) } None => properties.remove(&key), }; } None => properties.clear(), }; } StateEvent::StreamStopped { object_id } => { // It's likely that the node doesn't exist anymore. self.nodes .entry(object_id) .and_modify(|node| node.peaks = None); } StateEvent::Removed { object_id } => { // Remove from links and stop capture if the last input link if let Some(Link { input_id, .. }) = self.links.remove(&object_id) { if self.inputs(input_id).len() == 1 { if let Some(node) = self.nodes.get(&input_id) { commands.extend(self.on_removed(node)); } } } self.devices.remove(&object_id); self.clients.remove(&object_id); if let Some(node) = self.nodes.remove(&object_id) { commands.extend(self.on_removed(&node)); } if let Some(metadata) = self.metadatas.remove(&object_id) { if let Some(metadata_name) = metadata.metadata_name { self.metadatas_by_name.remove(&metadata_name); } } } } if let Some(capturing) = &mut self.capturing { for command in commands.into_iter() { match command { Command::NodeCaptureStart( obj_id, object_serial, capture_sink, ) => { capturing.insert(obj_id); wirehose.node_capture_start( obj_id, object_serial, capture_sink, ); } Command::NodeCaptureStop(obj_id) => { capturing.remove(&obj_id); wirehose.node_capture_stop(obj_id); } _ => {} } } } } pub fn get_metadata_by_name( &self, metadata_name: &str, ) -> Option<&Metadata> { self.metadatas .get(self.metadatas_by_name.get(metadata_name)?) } fn client_entry(&mut self, object_id: ObjectId) -> &mut Client { self.clients.entry(object_id).or_insert_with(|| Client { object_id, ..Default::default() }) } fn node_entry(&mut self, object_id: ObjectId) -> &mut Node { self.nodes.entry(object_id).or_insert_with(|| Node { object_id, ..Default::default() }) } fn device_entry(&mut self, object_id: ObjectId) -> &mut Device { self.devices.entry(object_id).or_insert_with(|| Device { object_id, ..Default::default() }) } fn metadata_entry(&mut self, object_id: ObjectId) -> &mut Metadata { self.metadatas.entry(object_id).or_insert_with(|| Metadata { object_id, ..Default::default() }) } /// Returns the objects that the given object outputs to. pub fn outputs(&self, object_id: ObjectId) -> Vec { self.links .iter() .filter(|(_key, l)| l.output_id == object_id) .map(|(_key, l)| l.input_id) .collect() } /// Returns the objects that input to the given object. pub fn inputs(&self, object_id: ObjectId) -> Vec { self.links .iter() .filter(|(_key, l)| l.input_id == object_id) .map(|(_key, l)| l.output_id) .collect() } /// Call when a node's capture eligibility might have changed. fn on_node(&self, node: &Node) -> Option { self.capturing.as_ref()?; if !node .props .media_class() .as_ref() .is_some_and(|media_class| { media_class::is_source(media_class) || media_class::is_sink_input(media_class) || media_class::is_source_output(media_class) }) { return None; } node.props.object_serial()?; if self.capturing.as_ref()?.contains(&node.object_id) { return None; } self.start_capture_command(node) } /// Call when a node gets a new input link. fn on_link(&self, node: &Node) -> Option { self.capturing.as_ref()?; if !node .props .media_class() .as_ref() .is_some_and(|media_class| { media_class::is_sink(media_class) || media_class::is_source(media_class) || media_class::is_sink_input(media_class) || media_class::is_source_output(media_class) }) { return None; } self.start_capture_command(node) } /// Call when a node's output positions have changed. fn on_positions_changed(&self, node: &Node) -> Option { if !self.capturing.as_ref()?.contains(&node.object_id) { return None; } self.start_capture_command(node) } /// Call when a node has no more input links. fn on_removed(&self, node: &Node) -> Option { self.capturing.as_ref()?; self.stop_capture_command(node) } fn start_capture_command(&self, node: &Node) -> Option { self.capturing.as_ref()?; let object_serial = node.props.object_serial()?; let capture_sink = node.props .media_class() .as_ref() .is_some_and(|media_class| { media_class::is_sink(media_class) || media_class::is_source(media_class) }); Some(Command::NodeCaptureStart( node.object_id, *object_serial, capture_sink, )) } fn stop_capture_command(&self, node: &Node) -> Option { self.capturing.as_ref()?; Some(Command::NodeCaptureStop(node.object_id)) } } #[cfg(test)] mod tests { use super::*; use crate::mock; #[test] fn state_metadata_insert() { let mut state = State::default(); let wirehose = mock::WirehoseHandle::default(); let object_id = ObjectId::from_raw_id(0); let metadata_name = String::from("metadata0"); state.update( &wirehose, StateEvent::MetadataMetadataName { object_id, metadata_name: metadata_name.clone(), }, ); let metadata = state.metadatas.get(&object_id).unwrap(); assert_eq!(metadata.metadata_name, Some(metadata_name.clone())); let metadata = state.get_metadata_by_name(&metadata_name).unwrap(); assert_eq!(metadata.metadata_name, Some(metadata_name)); } #[test] fn state_metadata_remove() { let mut state = State::default(); let wirehose = mock::WirehoseHandle::default(); let object_id = ObjectId::from_raw_id(0); let metadata_name = String::from("metadata0"); state.update( &wirehose, StateEvent::MetadataMetadataName { object_id, metadata_name: metadata_name.clone(), }, ); state.update(&wirehose, StateEvent::Removed { object_id }); assert!(!state.metadatas.contains_key(&object_id)); assert!(!state.metadatas_by_name.contains_key(&metadata_name)); assert!(state.get_metadata_by_name(&metadata_name).is_none()); } fn get_metadata_properties<'a>( state: &'a State, object_id: &ObjectId, subject: u32, ) -> &'a HashMap { state .metadatas .get(object_id) .unwrap() .properties .get(&subject) .unwrap() } #[test] fn state_metadata_clear_property() { let mut state = State::default(); let wirehose = mock::WirehoseHandle::default(); let object_id = ObjectId::from_raw_id(0); let metadata_name = String::from("metadata0"); state.update( &wirehose, StateEvent::MetadataMetadataName { object_id, metadata_name: metadata_name.clone(), }, ); let key = String::from("key"); let value = String::from("value"); state.update( &wirehose, StateEvent::MetadataProperty { object_id, subject: 0, key: Some(key.clone()), value: Some(value.clone()), }, ); state.update( &wirehose, StateEvent::MetadataProperty { object_id, subject: 1, key: Some(key.clone()), value: Some(value.clone()), }, ); assert_eq!( get_metadata_properties(&state, &object_id, 0).get(&key), Some(&value) ); assert_eq!( get_metadata_properties(&state, &object_id, 1).get(&key), Some(&value) ); state.update( &wirehose, StateEvent::MetadataProperty { object_id, subject: 0, key: Some(key.clone()), value: None, }, ); assert_eq!( get_metadata_properties(&state, &object_id, 0).get(&key), None ); assert_eq!( get_metadata_properties(&state, &object_id, 1).get(&key), Some(&value) ); } #[test] fn state_metadata_clear_all_properties() { let mut state = State::default(); let wirehose = mock::WirehoseHandle::default(); let object_id = ObjectId::from_raw_id(0); let metadata_name = String::from("metadata0"); state.update( &wirehose, StateEvent::MetadataMetadataName { object_id, metadata_name: metadata_name.clone(), }, ); let key = String::from("key"); let value = String::from("value"); state.update( &wirehose, StateEvent::MetadataProperty { object_id, subject: 0, key: Some(key.clone()), value: Some(value.clone()), }, ); state.update( &wirehose, StateEvent::MetadataProperty { object_id, subject: 1, key: Some(key.clone()), value: Some(value.clone()), }, ); assert!(!get_metadata_properties(&state, &object_id, 0).is_empty()); assert!(!get_metadata_properties(&state, &object_id, 1).is_empty()); state.update( &wirehose, StateEvent::MetadataProperty { object_id, subject: 0, key: None, value: None, }, ); assert!(get_metadata_properties(&state, &object_id, 0).is_empty()); assert!(!get_metadata_properties(&state, &object_id, 1).is_empty()); } } wiremix-0.7.0/src/wirehose/stream.rs000066400000000000000000000120471504750620600174700ustar00rootroot00000000000000use std::mem; use std::rc::Rc; use pipewire::{ core::Core, properties::properties, stream::{Stream, StreamListener}, }; use libspa::{ param::audio::{AudioFormat, AudioInfoRaw}, param::format::{MediaSubtype, MediaType}, param::{format_utils, ParamType}, pod::{Object, Pod}, }; use crate::wirehose::event_sender::EventSender; use crate::wirehose::{ObjectId, StateEvent}; #[derive(Default)] pub struct StreamData { format: AudioInfoRaw, cursor_move: bool, } pub fn capture_node( core: &Core, sender: &Rc, object_id: ObjectId, serial: &str, capture_sink: bool, ) -> Option<(Rc, StreamListener)> { let mut props = properties! { *pipewire::keys::TARGET_OBJECT => String::from(serial), *pipewire::keys::STREAM_MONITOR => "true", *pipewire::keys::NODE_NAME => "wiremix-capture", }; if capture_sink { props.insert(*pipewire::keys::STREAM_CAPTURE_SINK, "true"); } let data = StreamData { format: Default::default(), cursor_move: false, }; let stream = Stream::new(core, "wiremix-capture", props).ok()?; let stream = Rc::new(stream); let listener = stream .add_local_listener_with_user_data(data) .param_changed({ let sender_weak = Rc::downgrade(sender); move |_stream, user_data, id, param| { // NULL means to clear the format let Some(param) = param else { return; }; if id != ParamType::Format.as_raw() { return; } let (media_type, media_subtype) = match format_utils::parse_format(param) { Ok(v) => v, Err(_) => return, }; // only accept raw audio if media_type != MediaType::Audio || media_subtype != MediaSubtype::Raw { return; } // call a helper function to parse the format for us. let _ = user_data.format.parse(param); let Some(sender) = sender_weak.upgrade() else { return; }; sender.send(StateEvent::NodeRate { object_id, rate: user_data.format.rate(), }); } }) .process({ let sender_weak = Rc::downgrade(sender); move |stream, user_data| { let Some(mut buffer) = stream.dequeue_buffer() else { return; }; let Some(sender) = sender_weak.upgrade() else { return; }; let datas = buffer.datas_mut(); if datas.is_empty() { return; } let data = &mut datas[0]; let n_channels = user_data.format.channels(); let n_samples = data.chunk().size() / (mem::size_of::() as u32); if let Some(samples) = data.data() { let mut peaks = Vec::new(); for c in 0..n_channels { let mut max: f32 = 0.0; for n in (c..n_samples).step_by(n_channels as usize) { let start = n as usize * mem::size_of::(); let end = start + mem::size_of::(); let chan = &samples[start..end]; let f = f32::from_le_bytes( chan.try_into().unwrap_or([0; 4]), ); max = max.max(f.abs()); } peaks.push(max); } sender.send(StateEvent::NodePeaks { object_id, peaks, samples: n_samples, }); user_data.cursor_move = true; } } }) .register() .ok()?; let mut audio_info = AudioInfoRaw::new(); audio_info.set_format(AudioFormat::F32LE); let pod_object = Object { type_: pipewire::spa::utils::SpaTypes::ObjectParamFormat.as_raw(), id: ParamType::EnumFormat.as_raw(), properties: audio_info.into(), }; let values: Vec = pipewire::spa::pod::serialize::PodSerializer::serialize( std::io::Cursor::new(Vec::new()), &pipewire::spa::pod::Value::Object(pod_object), ) .ok()? .0 .into_inner(); let mut params = [Pod::from_bytes(&values)?]; stream .connect( libspa::utils::Direction::Input, None, pipewire::stream::StreamFlags::AUTOCONNECT | pipewire::stream::StreamFlags::MAP_BUFFERS, &mut params, ) .ok()?; Some((stream, listener)) } wiremix-0.7.0/src/wirehose/stream_registry.rs000066400000000000000000000063231504750620600214200ustar00rootroot00000000000000use std::collections::{HashMap, HashSet}; use std::rc::Rc; use anyhow::Result; use nix::sys::eventfd::{EfdFlags, EventFd}; use pipewire::stream::{Stream, StreamListener}; use crate::wirehose::ObjectId; /// Storage for keeping streams and their listeners alive pub struct StreamRegistry { /// Storage for keeping streams streams: HashMap>, /// Storage for keeping listeners alive listeners: HashMap>>, /// Streams pending deletion garbage_streams: Vec>, /// Listeners pending deletion garbage_listeners: Vec>, /// Track garbage node IDs so [`Self::collect_garbage()`] can report on who /// was collected. garbage_ids: HashSet, /// EventFd for signalling to [`wirehose`](`crate::wirehose`) that objects /// are pending deletion and that [`Self::collect_garbage()`] needs to be /// called pub gc_fd: EventFd, } impl Drop for StreamRegistry { fn drop(&mut self) { // Drop listeners while the stream is still alive. self.garbage_listeners.clear(); self.listeners.clear(); } } impl StreamRegistry { pub fn try_new() -> Result { let gc_fd = EventFd::from_value_and_flags(0, EfdFlags::EFD_NONBLOCK)?; Ok(Self { streams: HashMap::new(), listeners: HashMap::new(), garbage_streams: Vec::new(), garbage_listeners: Vec::new(), garbage_ids: HashSet::new(), gc_fd, }) } /// Clean up streams and listeners pending deletion. It is unsafe to call /// this from within the PipeWire main loop! /// /// Returns the IDs of the streams deleted. pub fn collect_garbage(&mut self) -> Vec { self.garbage_listeners.clear(); self.garbage_streams.clear(); let _ = self.gc_fd.read(); self.garbage_ids.drain().collect() } /// Register a stream and its listener, evicting any with the same ID. pub fn add_stream( &mut self, stream_id: ObjectId, stream: Rc, listener: StreamListener, ) { if let Some(old) = self.streams.insert(stream_id, stream) { self.garbage_streams.push(old); if let Some(listeners) = self.listeners.get_mut(&stream_id) { self.garbage_listeners.append(listeners); } let _ = self.gc_fd.arm(); } let v = self.listeners.entry(stream_id).or_default(); v.push(listener); } /// Remove a stream, deferring deletion until [`Self::collect_garbage()`] /// is called. pub fn remove(&mut self, stream_id: ObjectId) { if let Some(stream) = self.streams.remove(&stream_id) { let _ = stream.disconnect(); self.garbage_streams.push(stream); self.garbage_ids.insert(stream_id); let _ = self.gc_fd.arm(); } if let Some(listeners) = self.listeners.get_mut(&stream_id) { if !listeners.is_empty() { let _ = self.gc_fd.arm(); self.garbage_ids.insert(stream_id); } self.garbage_listeners.append(listeners); } } } wiremix-0.7.0/src/wirehose/sync_registry.rs000066400000000000000000000015501504750620600210760ustar00rootroot00000000000000use std::collections::HashSet; use libspa::utils::result::AsyncSeq; use pipewire::core::Core; /// Track pending syncs in order to determine when wirehose has all initial /// information and is waiting for new events. #[derive(Default)] pub struct SyncRegistry { pending: HashSet, done: bool, } impl SyncRegistry { /// Register a pending sync. pub fn global(&mut self, core: &Core) { if !self.done { if let Ok(seq) = core.sync(0) { self.pending.insert(seq.seq()); } } } /// Mark a sync as done, return true when all are done for the first time. pub fn done(&mut self, seq: AsyncSeq) -> bool { if self.done { return false; } self.pending.remove(&seq.seq()); self.done |= self.pending.is_empty(); self.pending.is_empty() } } wiremix-0.7.0/wiremix.toml000066400000000000000000000374251504750620600156030ustar00rootroot00000000000000# This file documents wiremix's configuration file. It is also itself a wiremix # configuration file in which wiremix's default configuration is specified. # # It is recommended to start with an empty configuration file and to use this # file only as a reference. Anything specified in the configuration file will # be merged with wiremix's defaults. # Main Options # PipeWire remote to connect to #remote = "pipewire-0" # Limit rendering frames per second (unlimited if unset) #fps = 60.0 # # Enable mouse support mouse = true # Peak meter mode # "off" - not meters # "mono" - all mono meters # "auto" - left/right meters for stereo streams, otherwise mono peaks = "auto" # Character set to use (see Character Sets section) char_set = "default" # Theme to use (see Themes section) theme = "default" # Initial tab tab = "playback" # Maximum percentage for volume sliders max_volume_percent = 150.0 # Whether to prevent increasing volume past max_volume enforce_max_volume = false # Keybindings # # A keybinding consists of a key, modifiers, and a UI action to be performed. # # Keybindings you define in your configuration will be merged with the default # keybindings (listed below for reference). You can effectively delete a # default keybinding by setting its action to "Nothing". # # A keybinding key can be one of: # 1. A character: { Char = "x" } # for the 'x' key # 2. An F-key: { F = 1 } # for F1 # 3. A media key: { Media = "MediaKeyCode" } # where MediaKeyCode is one of: # Play Pause PlayPause Reverse Stop FastForward Rewind TrackNext # TrackPrevious Record LowerVolume RaiseVolume MuteVolume # 4. A special key: "SpecialKey" # where SpecialKey is one of: # Backspace Enter Left Right Up Down Home End PageUp PageDown Tab BackTab # Delete Insert Null Esc CapsLock ScrollLock NumLock PrintScreen Pause # Menu KeypadBegin # # A keybinding modifier can be one or more of SHIFT CONTROL ALT SUPER HYPER # META NONE combined with |. It defaults to NONE if omitted. # # For example: # # keybindings = [ # # Demonstrate modifiers # { key = "End", modifier = "CTRL | ALT", action = "Exit" }, # ] # # Each of the available keybinding actions are documented below. keybindings = [ # Exit the program { key = { Char = "q" }, action = "Exit" }, # Toggle mute for the selected item { key = { Char = "m" }, action = "ToggleMute" }, # Make the selected item in Input/Output Devices the default endpoint { key = { Char = "d" }, action = "SetDefault" }, # Increase the volume of the selected item by 1% { key = { Char = "l" }, action = { SetRelativeVolume = 0.01 } }, { key = "Right", action = { SetRelativeVolume = 0.01 } }, # Decrease the volume of the selected item by 1% { key = { Char = "h" }, action = { SetRelativeVolume = -0.01 } }, { key = "Left", action = { SetRelativeVolume = -0.01 } }, # Open a dropdown for the selected item or chose an item in the dropdown { key = { Char = "c" }, action = "ActivateDropdown" }, { key = "Enter", action = "ActivateDropdown" }, # Close an open dropdown { key = "Esc", action = "CloseDropdown" }, # Select the next item { key = { Char = "j" }, action = "MoveDown" }, { key = "Down", action = "MoveDown" }, # Select the previous item { key = { Char = "k" }, action = "MoveUp" }, { key = "Up", action = "MoveUp" }, # Select the next tab { key = { Char = "L" }, action = "TabRight" }, { key = "Tab", action = "TabRight" }, # Select the previous tab { key = { Char = "H" }, action = "TabLeft" }, { key = "BackTab", modifiers = "SHIFT", action = "TabLeft" }, # Set the volume of the selected item in 10% increments from 0% to 100% { key = { Char = "`" }, action = { SetAbsoluteVolume = 0.00 } }, { key = { Char = "1" }, action = { SetAbsoluteVolume = 0.10 } }, { key = { Char = "2" }, action = { SetAbsoluteVolume = 0.20 } }, { key = { Char = "3" }, action = { SetAbsoluteVolume = 0.30 } }, { key = { Char = "4" }, action = { SetAbsoluteVolume = 0.40 } }, { key = { Char = "5" }, action = { SetAbsoluteVolume = 0.50 } }, { key = { Char = "6" }, action = { SetAbsoluteVolume = 0.60 } }, { key = { Char = "7" }, action = { SetAbsoluteVolume = 0.70 } }, { key = { Char = "8" }, action = { SetAbsoluteVolume = 0.80 } }, { key = { Char = "9" }, action = { SetAbsoluteVolume = 0.90 } }, { key = { Char = "0" }, action = { SetAbsoluteVolume = 1.00 } }, # Open the help menu { key = { Char = "?" }, action = "Help" }, # There are two actions which don't have default bindings: # 1. "Nothing": Do nothing - can effectively delete a default keybinding # 2. { SelectTab = N }: Open the Nth tab ] # Names # # You can customize how streams, endpoints, and devices are named in the user # interface using a template system to generate names based on PipeWire # properties. # # Name templates are composed of property tags enclosed in { } and literal # text. For example: # # "Application {client:application.name} playing {node:media.name}" # # wiremix will replace the property tags with the properties from the PipeWire # object being displayed. # # The first part of a tag specifies the object type - device, node, or client, # and the second part specifies the property. # # You can use pw-dump(1) to inspect the available properties. # # Literal curly braces can be escaped by doubling them: {{ become { and }} # becomes }. # # Streams can have linked clients, so node and client properties are valid for # stream. Similarly, endpoint can use either node or device properties. Only # device properties are valid for device. # # Each option in names is an array - if a tempalte can't be resolved because it # uses a property which doesn't exit on a given object, wiremix tries the next # template in the sequence. If none of them can be resolved, it falls back on # node.name for nodes or device.name for devices. # # The overall order of precedence for name resolution is: # 1. Matching override templates, if any (see the Name Overrides section) # 2. Configured templates for the object type # 3. Fall back to the object's name property [names] # Streams in the Playback/Recording tabs stream = [ "{node:node.name}: {node:media.name}" ] # Endpoints in the Input/Output Devices tabs endpoint = [ "{device:device.nick}", "{node:node.description}" ] # Devices in the Configuration tab device = [ "{device:device.nick}", "{device:device.description}" ] # Name Overrides # # Name overrides define alternate templates that will be used for objects # matching a given criterion. # # An override is matched by type, which contains a list of one or more of # stream, endpoint, or device (see the Name section for more details), and a # property value. Any node or device property that can be used in the names # section can be used to match an override. # # There are no default overrides, but here is an example. This causes all # streams whose node.name is "spotify" to use just "{node:node.name}" as its # name. # # [[names.overrides]] # # Which object types this override applies to # types = [ "stream" ] # # The property to match # property = "node:node.name" # # The value to match # value = "spotify" # # Templates to use when the property value matches # templates = [ "{node:node.name}" ] # # You can have multiple name overrides, each in its own [[names.overrides]] # section. # Themes # # Themes determine the styling of user interface elements. # # Theme styles are based on ratatui's Style struct. # https://docs.rs/ratatui/latest/ratatui/style/struct.Style.html # # Each style can have an fg color, a bg color, and modifiers. Any property not # specified will inherit from the default style for your terminal. # # fg and bg can be an RGB hex value in the form "#RRGGBB" or named ANSI colors: # Black Red Green Yellow Blue Magenta Cyan Gray DarkGray LightRed LightGreen # LightYellow LightBlue LightMagenta LightCyan White # # add_modifier can be one or more of BOLD DIM ITALIC UNDERLINED SLOW_BLINK # RAPID_BLINK REVERSED HIDDEN CROSSED_OUT combined with |. # # For example: # # # Red foreground on a black background with bold, underlined text # { fg = "#FF0000", bg = "Black", add_modifier = "BOLD | UNDERLINE" } # # An empty style with no properties ({ }) corresponds to the default style. # # You can modify built-in themes. Anything you don't specify will remain # unchanged. For example: # # # Modify the "default" theme to make the selection indicator blink. # [themes.default] # selector = { fg = "LightCyan", add_modifier = "SLOW_BLINK" } # # And you can create a new theme that inherits unspecified styles from a # built-in theme. For example: # # # Create a new theme called "my_custom_theme" based on "nocolor" # [themes.my_custom_theme] # inherit = "nocolor" # tab_selected = { fg = "LightCyan", add_modifier = "SLOW_BLINK" } # # The "inherit" option is optional. If not present, the new theme will inherit # from the "default" theme. # # The following is the default theme with each themeable property described. [themes.default] # The symbol marking the default device on the Input/Output Devices tabs default_device = { } # The symbol marking the default endpoint on the Playback/Recording tabs default_stream = { } # The selection indicator in a tab selector = { fg = "LightCyan" } # The name of a tab in the tab menu tab = { } # The name of the selected tab in the tab menu tab_selected = { fg = "LightCyan" } # The symbols surrounding the selected tab in the tab menu tab_marker = { fg = "LightCyan" } # The symbol at the top/bottom of a tab indicating that there are more items list_more = { fg = "DarkGray" } # The name of a PipeWire node node_title = { } # The name of the selected target for a node node_target = { } # The volume percentage label volume = { } # Volume bar volume_empty = { fg = "DarkGray" } volume_filled = { fg = "LightBlue" } # Peak meter. Inactive = unlit, active = lit, overload = greater than 0.0 dB meter_inactive = { fg = "DarkGray" } meter_active = { fg = "LightGreen" } meter_overload = { fg = "Red" } # The "live" indicator in the center of the meter meter_center_inactive = { fg = "DarkGray" } meter_center_active = { fg = "LightGreen" } # The name of a device in the Configuration tab config_device = { } # The name of the selected profile in the Configuration tab config_profile = { } # Dropdown marker next to the profiles in the Conifguration tab dropdown_icon = { } # Border around dropdowns dropdown_border = { } # The name of an item in a dropdown dropdown_item = { } # The name of the currently-selected item in a dropdown dropdown_selected = { fg = "LightCyan", add_modifier = "REVERSED" } # The symbol at the top/bottom of a dropdown indicating that there are more items dropdown_more = { fg = "DarkGray" } # Border around help menu help_border = { } # The name of an item in a the help menu help_item = { } # The symbol at the top/bottom of the help menu indicating that there are more items help_more = { fg = "DarkGray" } # Character Sets # # Character sets define the symbols used in the user interface. # # You can modify built-in characters sets. Anything you don't specify will # remain unchanged. For example: # # # Modify the "default" character set to use parentheses around the selected # # tab name # [char_sets.default] # tab_marker_left = "(" # tab_marker_right = ")" # # And you can create a new character set that inherits unspecified symbols from # a built-in character set. For example: # # # Create a new character set called "my_custom_char_set" based on "compat" # [char_sets.my_custom_char_set] # inherit = "compat" # Inherit from "compat" (omit to inherit from "default") # tab_marker_left = "(" # tab_marker_right = ")" # # The following is the default character set which the options described. [char_sets.default] # Marks the default device on the Input/Output Devices tabs default_device = "◇" # Marks the default endpoint on the Playback/Recording tabs default_stream = "◇" # The selection indicator in a tab selector_top = "░" selector_middle = "▒" selector_bottom = "░" # Surround the selected tab in the tab menu tab_marker_left = "[" tab_marker_right = "]" # Displayed at the top/bottom of a tab when there are more items list_more = "•••" # Volume bar volume_empty = "╌" volume_filled = "━" # Peak meter. Inactive = unlit, active = lit, overload = greater than 0.0 dB # Mono meters use only the right side characters meter_left_inactive = "▮" meter_left_active = "▮" meter_left_overload = "▮" meter_right_inactive = "▮" meter_right_active = "▮" meter_right_overload = "▮" # The "live" indicator in the center of the meter # Mono meters use only the right side meter_center_left_inactive = "▮" meter_center_left_active = "▮" meter_center_right_inactive = "▮" meter_center_right_active = "▮" # Dropdown marker next to the profiles in the Configuration tab dropdown_icon = "▼" # Indicates the selected item in a dropdown dropdown_selector = ">" # Displayed at the top/bottom of a dropdown when there are more items dropdown_more = "•••" # Border around dropdowns # One of "Plain", "Rounded", "Double", "Thick", "QuadrantInside", "QuadrantOutside" dropdown_border = "Rounded" # Displayed at the top/bottom of the help menu when there are more items help_more = "•••" # Border around help menu # One of "Plain", "Rounded", "Double", "Thick", "QuadrantInside", "QuadrantOutside" help_border = "Rounded" # Appendix A # # The other built-in themes and character sets are defined for reference here. [themes.nocolor] default_device = { } default_stream = { } selector = { add_modifier = "BOLD" } tab = { } tab_selected = { add_modifier = "BOLD" } tab_marker = { add_modifier = "BOLD" } list_more = { } node_title = { } node_target = { } volume = { } volume_empty = { add_modifier = "DIM" } volume_filled = { add_modifier = "BOLD" } meter_inactive = { add_modifier = "DIM" } meter_active = { add_modifier = "BOLD" } meter_overload = { add_modifier = "BOLD" } meter_center_inactive = { add_modifier = "DIM" } meter_center_active = { add_modifier = "BOLD" } config_device = { } config_profile = { } dropdown_icon = { } dropdown_border = { } dropdown_item = { } dropdown_selected = { add_modifier = "BOLD | REVERSED" } dropdown_more = { } help_border = { } help_item = { } help_more = { } [themes.plain] default_device = { } default_stream = { } selector = { } tab = { } tab_selected = { } tab_marker = { } list_more = { } node_title = { } node_target = { } volume = { } volume_empty = { } volume_filled = { } meter_inactive = { } meter_active = { } meter_overload = { } meter_center_inactive = { } meter_center_active = { } config_device = { } config_profile = { } dropdown_icon = { } dropdown_border = { } dropdown_item = { } dropdown_selected = { } dropdown_more = { } help_border = { } help_item = { } help_more = { } [char_sets.compat] default_device = "◊" default_stream = "◊" selector_top = "░" selector_middle = "▒" selector_bottom = "░" tab_marker_left = "[" tab_marker_right = "]" list_more = "•••" volume_empty = "─" volume_filled = "━" meter_left_inactive = "┃" meter_left_active = "┃" meter_left_overload = "┃" meter_right_inactive = "┃" meter_right_active = "┃" meter_right_overload = "┃" meter_center_left_inactive = "█" meter_center_left_active = "█" meter_center_right_inactive = "█" meter_center_right_active = "█" dropdown_icon = "▼" dropdown_selector = ">" dropdown_more = "•••" dropdown_border = "Plain" help_more = "•••" help_border = "Plain" [char_sets.extracompat] default_device = "*" default_stream = "*" selector_top = "-" selector_middle = "=" selector_bottom = "-" tab_marker_left = "[" tab_marker_right = "]" list_more = "~~~" volume_empty = "-" volume_filled = "=" meter_left_inactive = "=" meter_left_active = "#" meter_left_overload = "!" meter_right_inactive = "=" meter_right_active = "#" meter_right_overload = "!" meter_center_left_inactive = "[" meter_center_left_active = "[" meter_center_right_inactive = "]" meter_center_right_active = "]" dropdown_icon = "\\" dropdown_selector = ">" dropdown_more = "~~~" dropdown_border = "Plain" help_more = "~~~" help_border = "Plain"