feat: rewrite audiomenu in Python
Some checks failed
/ check (push) Successful in 9s
/ build-packages (push) Failing after 15s
/ build-vm (push) Has been skipped
/ report-size (push) Has been skipped

There is no reason for it to be a Rust program
This commit is contained in:
Jalil David Salamé Messina 2025-05-17 14:13:57 +02:00
parent c65e793a85
commit 66cd7b90b6
Signed by: jalil
GPG key ID: F016B9E770737A0B
12 changed files with 290 additions and 857 deletions

2
scripts/audiomenu/.envrc Normal file
View file

@ -0,0 +1,2 @@
source_up
source .venv/bin/activate

View file

@ -1 +1 @@
/target
.venv

View file

@ -0,0 +1 @@
3.12

View file

@ -1,553 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "addr2line"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
dependencies = [
"gimli",
]
[[package]]
name = "adler2"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
[[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",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e"
dependencies = [
"anstyle",
"once_cell",
"windows-sys",
]
[[package]]
name = "audiomenu"
version = "0.1.0"
dependencies = [
"clap",
"duct",
"miette",
"serde",
"serde_json",
]
[[package]]
name = "backtrace"
version = "0.3.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002"
dependencies = [
"addr2line",
"cfg-if",
"libc",
"miniz_oxide",
"object",
"rustc-demangle",
"windows-targets",
]
[[package]]
name = "backtrace-ext"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50"
dependencies = [
"backtrace",
]
[[package]]
name = "bitflags"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "clap"
version = "4.5.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7"
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 = "duct"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6ce170a0e8454fa0f9b0e5ca38a6ba17ed76a50916839d217eb5357e05cdfde"
dependencies = [
"libc",
"os_pipe",
"shared_child",
"shared_thread",
]
[[package]]
name = "errno"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "gimli"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "is_ci"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "libc"
version = "0.2.172"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
[[package]]
name = "linux-raw-sys"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
[[package]]
name = "memchr"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "miette"
version = "7.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
dependencies = [
"backtrace",
"backtrace-ext",
"cfg-if",
"miette-derive",
"owo-colors",
"supports-color",
"supports-hyperlinks",
"supports-unicode",
"terminal_size",
"textwrap",
"unicode-width 0.1.14",
]
[[package]]
name = "miette-derive"
version = "7.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "miniz_oxide"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a"
dependencies = [
"adler2",
]
[[package]]
name = "object"
version = "0.36.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "os_pipe"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ffd2b0a5634335b135d5728d84c5e0fd726954b87111f7506a61c502280d982"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "owo-colors"
version = "4.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26995317201fa17f3656c36716aed4a7c81743a9634ac4c99c0eeda495db0cec"
[[package]]
name = "proc-macro2"
version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustc-demangle"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[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",
"windows-sys",
]
[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "serde"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.140"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
]
[[package]]
name = "shared_child"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e297bd52991bbe0686c086957bee142f13df85d1e79b0b21630a99d374ae9dc"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "shared_thread"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7a6f98357c6bb0ebace19b22220e5543801d9de90ffe77f8abb27c056bac064"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "supports-color"
version = "3.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6"
dependencies = [
"is_ci",
]
[[package]]
name = "supports-hyperlinks"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "804f44ed3c63152de6a9f90acbea1a110441de43006ea51bcce8f436196a288b"
[[package]]
name = "supports-unicode"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2"
[[package]]
name = "syn"
version = "2.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "terminal_size"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed"
dependencies = [
"rustix",
"windows-sys",
]
[[package]]
name = "textwrap"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
dependencies = [
"unicode-linebreak",
"unicode-width 0.2.0",
]
[[package]]
name = "unicode-ident"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "unicode-linebreak"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[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 = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[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"

View file

@ -1,18 +0,0 @@
[package]
name = "audiomenu"
description = "fuzzel script to select the default audio device for pipewire+wireplumber"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4.5.23", features = ["derive", "env"] }
duct = "1.0.0"
miette = { version = "7.4.0", features = ["fancy"] }
serde = { version = "1.0.215", features = ["derive"] }
serde_json = "1.0.133"
[profile.release]
lto = true
opt-level = 's'
panic = "abort"
strip = true

View file

View file

@ -0,0 +1,236 @@
# pyright: strict, reportAny=false
from dataclasses import dataclass
import json
import subprocess
from typing import Self
import typing
import click
def expect[T](typ: type[T], value: object) -> T:
if not isinstance(value, typ):
raise ValueError(
f"expected value to be of type {typ} but was of type {type(value)}"
)
return value
@dataclass(slots=True)
class PWNodeProps:
object_id: int
node_description: str
node_name: str
media_class: str
@classmethod
def from_json(cls, data: dict[str, object]) -> Self:
return cls(
object_id=expect(int, data["object.id"]),
node_description=expect(str, data.get("node.description", "(unknown)")),
node_name=expect(str, data["node.name"]),
media_class=expect(str, data.get("media.class", "(unknown)")),
)
@dataclass(slots=True)
class PWNodeInfo:
props: PWNodeProps
@classmethod
def from_json(cls, data: dict[str, object]) -> Self:
props = typing.cast(dict[str, object], expect(dict, data["props"]))
return cls(PWNodeProps.from_json(props))
@dataclass(slots=True)
class PWNode:
node_type: str
info: PWNodeInfo | None
@classmethod
def from_json(cls, data: dict[str, object]) -> Self:
info = data.get("info", None)
if info is not None:
info = PWNodeInfo.from_json(
typing.cast(dict[str, object], expect(dict, info))
)
return cls(node_type=expect(str, data["type"]), info=info)
@dataclass(slots=True)
class AudioDevice:
id: int
name: str
volume: float
muted: bool
default: bool
@staticmethod
def get_volume(id: int | str) -> tuple[float, bool]:
wpctl_output = subprocess.run(
["wpctl", "get-volume", str(id)],
encoding="UTF-8",
check=True,
capture_output=True,
)
match wpctl_output.stdout.strip().split(sep=" "):
case ["Volume:", value]:
return (float(value), False)
case ["Volume:", value, "[MUTED]"]:
return (float(value), True)
case _:
raise ValueError(f"Unexpected wpctl output: {wpctl_output.stdout}")
@classmethod
def from_pw_node(cls, node: PWNode, default: str) -> Self:
if node.info is None:
raise ValueError(f"Node is not a valid audio device {node}")
id = node.info.props.object_id
volume, muted = cls.get_volume(id)
return cls(
id=id,
name=node.info.props.node_description,
volume=volume,
muted=muted,
default=node.info.props.node_name == default,
)
def menu_item(self) -> str:
id = f"id={self.id:<3}"
if self.default:
id = f"[{id}]"
else:
id = f" {id} "
if self.muted:
return f"{id} {self.volume:>4.0%} [MUTED] {self.name}"
else:
return f"{id} {self.volume:>4.0%} {self.name}"
def get_nodes(data: list[dict[str, object]]) -> list[PWNode]:
def is_audio_node(node: object) -> bool:
if not isinstance(node, dict):
return False
node = typing.cast(dict[str, object], node)
if node["type"] != "PipeWire:Interface:Node":
return False
info = node.get("info", None)
if info is None or not isinstance(info, dict):
return False
info = typing.cast(dict[str, object], info)
props = info.get("props", None)
if props is None or not isinstance(props, dict):
return False
props = typing.cast(dict[str, object], props)
if (media_class := props.get("media.class", None)) is not None:
return isinstance(media_class, str) and media_class.startswith("Audio")
return False
return [
PWNode.from_json(typing.cast(dict[str, object], expect(dict, node)))
for node in data
if is_audio_node(node)
]
def pw_dump() -> list[dict[str, object]]:
dump_output = subprocess.run(
["pw-dump"], encoding="UTF-8", check=True, capture_output=True
)
data = json.loads(dump_output.stdout)
return typing.cast(list[dict[str, object]], expect(list, data))
def get_defaults_metadata(data: list[dict[str, object]]) -> list[dict[str, object]]:
return typing.cast(
list[dict[str, object]],
expect(
list,
next(
node
for node in data
if node["type"] == "PipeWire:Interface:Metadata"
and expect(dict, node["props"])["metadata.name"] == "default"
)["metadata"],
),
)
def get_sinks() -> list[AudioDevice]:
data = pw_dump()
default = next(
typing.cast(dict[str, str], expect(dict, data["value"]))["name"]
for data in get_defaults_metadata(data)
if data["key"] == "default.audio.sink"
)
return [
AudioDevice.from_pw_node(node, default)
for node in get_nodes(data)
if node.info is not None and node.info.props.media_class == "Audio/Sink"
]
def get_sources() -> list[AudioDevice]:
data = pw_dump()
default = next(
typing.cast(dict[str, str], expect(dict, data["value"]))["name"]
for data in get_defaults_metadata(data)
if data["key"] == "default.audio.source"
)
return [
AudioDevice.from_pw_node(node, default)
for node in get_nodes(data)
if node.info is not None and node.info.props.media_class == "Audio/Source"
]
@click.group(name="audiomenu")
def main() -> None:
pass
def select(options: list[str], prompt:str) -> int | None:
menu_output = subprocess.run(
["fuzzel", "--dmenu", f"--prompt={prompt}"],
input="\n".join(options),
encoding="UTF-8",
capture_output=True,
)
if menu_output.returncode == 2:
return None
menu_output.check_returncode()
selected = menu_output.stdout.rstrip()
return options.index(selected)
@main.command()
def select_sink() -> None:
devices = get_sinks()
selected = select([device.menu_item() for device in devices], prompt="Select Sink>")
if selected is None:
click.echo("No sink selected")
return
device = devices[selected]
_ = subprocess.run(["wpctl", "set-default", str(device.id)], check=True)
@main.command()
def select_source() -> None:
devices = get_sources()
selected = select([device.menu_item() for device in devices], prompt="Select Source>")
if selected is None:
click.echo("No source selected")
return
device = devices[selected]
_ = subprocess.run(["wpctl", "set-default", str(device.id)], check=True)
if __name__ == "__main__":
main()

View file

@ -1,22 +1,6 @@
{
lib,
rustPlatform,
cleanRustSrc,
}:
let
cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
inherit (cargoToml.package) name version description;
pname = name;
src = cleanRustSrc ./.;
in
rustPlatform.buildRustPackage {
inherit pname version src;
cargoLock.lockFile = ./Cargo.lock;
useNextest = true;
meta = {
inherit description;
license = lib.licenses.mit;
homepage = "https://github.com/jalil-salame/configuration.nix";
mainProgram = name;
};
}
{ writers, python3Packages }:
writers.writePython3 "audiomenu" {
libraries = [ python3Packages.click ];
# line too long, but I like my code well documented
flakeIgnore = [ "E501" ];
} ./jpassmenu.py

View file

@ -0,0 +1,9 @@
[project]
name = "audiomenu"
version = "0.1.0"
description = "fuzzel script to select the default audio device for pipewire+wireplumber"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"click>=8.1.7",
]

View file

@ -1,231 +0,0 @@
use std::{
fmt::{Display, Write as _},
io::{Read, Write as _},
process::{Command, Stdio},
};
use clap::Parser;
use duct::cmd;
use miette::{bail, Context, IntoDiagnostic, Result};
use serde::Deserialize;
fn main() -> Result<()> {
miette::set_panic_hook();
Opts::parse().run()
}
/// fuzzel script to select the default audio device for pipewire+wireplumber
#[derive(Debug, Parser)]
struct Opts {
#[clap(subcommand)]
cmd: Cmd,
}
impl Opts {
fn run(self) -> Result<()> {
self.cmd.run()
}
}
#[derive(Debug, clap::Subcommand)]
enum Cmd {
/// Select audio sink (speakers/headphones)
SelectSink,
/// Select audio source (microphone)
SelectSource,
}
impl Cmd {
fn run(self) -> Result<()> {
let id = match self {
Cmd::SelectSink => {
let devices = get_sinks().wrap_err("failed to get sinks")?;
let selected = select(
devices.iter().map(|dev| dev.name.as_ref()),
Some("Select input>"),
)
.wrap_err("failed to select a default sink")?;
if selected.is_empty() {
eprintln!("did not select a device");
return Ok(());
}
let Some(dev) = devices.into_iter().find(|dev| dev.name == selected) else {
bail!("couldn't find a device matching `{selected}`");
};
dev.id
}
Cmd::SelectSource => {
let devices = get_sources().wrap_err("failed to get sinks")?;
let selected = select(
devices.iter().map(|dev| dev.name.as_ref()),
Some("Select output>"),
)
.wrap_err("failed to select a default source")?;
if selected.is_empty() {
eprintln!("did not select a device");
return Ok(());
}
let Some(dev) = devices.into_iter().find(|dev| dev.name == selected) else {
bail!("couldn't find a device matching `{selected}`");
};
dev.id
}
};
cmd!("wpctl", "set-default", id.to_string())
.run()
.map(drop)
.into_diagnostic()
.wrap_err("failed to set default input")
}
}
#[derive(Debug, Deserialize)]
struct PWNode {
#[serde(rename = "type")]
node_type: Box<str>,
#[serde(default)]
info: PWNodeInfo,
// json ignores the rest of the fields by default
}
#[derive(Debug, Deserialize, Default)]
struct PWNodeInfo {
props: PWNodeProps,
// json ignores the rest of the fields by default
}
#[derive(Debug, Deserialize, Default)]
struct PWNodeProps {
#[serde(rename = "object.id")]
object_id: u32,
#[serde(rename = "node.description", default)]
node_description: Box<str>,
#[serde(rename = "media.class", default)]
media_class: Box<str>,
// json ignores the rest of the fields by default
}
struct AudioDevice<S> {
id: u32,
name: Box<str>,
_side: S,
}
/// Output (e.g. speakers)
struct AudioSink;
/// Input (e.g. microphone)
struct AudioSource;
fn get_sinks() -> Result<Vec<AudioDevice<AudioSink>>> {
get_devices()
}
fn get_sources() -> Result<Vec<AudioDevice<AudioSource>>> {
get_devices()
}
fn get_devices<S>() -> Result<Vec<AudioDevice<S>>>
where
AudioDevice<S>: TryFrom<PWNode>,
{
Ok(get_nodes()?
.into_iter()
.filter_map(|node| AudioDevice::<S>::try_from(node).ok())
.collect())
}
impl TryFrom<PWNode> for AudioDevice<AudioSource> {
type Error = miette::Report;
fn try_from(value: PWNode) -> std::result::Result<Self, Self::Error> {
if value.node_type.as_ref() != "PipeWire:Interface:Node" {
bail!(
"invalid type: `{}`, expected `PipeWire:Interface:Node`",
value.node_type
)
}
let class = value.info.props.media_class;
match class.as_ref() {
"Audio/Source" => Ok(Self {
id: value.info.props.object_id,
name: value.info.props.node_description,
_side: AudioSource,
}),
_ => bail!("invalid media.class: `{class}`, expected `Audio/Source`"),
}
}
}
impl TryFrom<PWNode> for AudioDevice<AudioSink> {
type Error = miette::Report;
fn try_from(value: PWNode) -> std::result::Result<Self, Self::Error> {
if value.node_type.as_ref() != "PipeWire:Interface:Node" {
bail!(
"invalid type: `{}`, expected `PipeWire:Interface:Node`",
value.node_type
)
}
let class = value.info.props.media_class;
match class.as_ref() {
"Audio/Sink" => Ok(Self {
id: value.info.props.object_id,
name: value.info.props.node_description,
_side: AudioSink,
}),
_ => bail!("invalid media.class: `{class}`, expected `Audio/Sink`"),
}
}
}
fn get_nodes() -> Result<Vec<PWNode>> {
let dump = cmd!("pw-dump")
.read()
.into_diagnostic()
.wrap_err("failed to get devices with pw-dump")?;
serde_json::from_str(&dump)
.into_diagnostic()
.wrap_err("failed to parse pw-dump output")
}
fn select<T, It>(options: It, prompt: Option<&str>) -> Result<Box<str>>
where
T: Display,
It: IntoIterator<Item = T>,
{
let append_line = |mut s: String, it| {
writeln!(s, "{it}").unwrap();
s
};
let options = options.into_iter().fold(String::new(), append_line);
let mut menu = Command::new("fuzzel");
menu.arg("--dmenu");
if let Some(prompt) = prompt {
menu.arg(format!("--prompt={prompt}"));
}
Ok(pipe_to_stdin_and_return_stdout(&mut menu, options)?
.trim()
.into())
}
fn pipe_to_stdin_and_return_stdout(cmd: &mut Command, data: impl Display) -> Result<String> {
let mut child = cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.into_diagnostic()
.wrap_err_with(|| format!("failed to run {cmd:?}"))?;
let mut stdin = child.stdin.take().expect("stdin not piped");
write!(stdin, "{data}")
.into_diagnostic()
.wrap_err("failed to send data to process' stdin")?;
drop(stdin);
let mut stdout = child.stdout.take().expect("stdout not piped");
let mut buf = String::new();
stdout
.read_to_string(&mut buf)
.into_diagnostic()
.wrap_err("failed to retrieve output from process")?;
Ok(buf)
}

34
scripts/audiomenu/uv.lock generated Normal file
View file

@ -0,0 +1,34 @@
version = 1
requires-python = ">=3.12"
[[package]]
name = "audiomenu"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "click" },
]
[package.metadata]
requires-dist = [{ name = "click", specifier = ">=8.1.7" }]
[[package]]
name = "click"
version = "8.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "platform_system == 'Windows'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/0f/62ca20172d4f87d93cf89665fbaedcd560ac48b465bd1d92bfc7ea6b0a41/click-8.2.0.tar.gz", hash = "sha256:f5452aeddd9988eefa20f90f05ab66f17fce1ee2a36907fd30b05bbb5953814d", size = 235857 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/58/1f37bf81e3c689cc74ffa42102fa8915b59085f54a6e4a80bc6265c0f6bf/click-8.2.0-py3-none-any.whl", hash = "sha256:6b303f0b2aa85f1cb4e5303078fadcbcd4e476f114fab9b5007005711839325c", size = 102156 },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]

View file

@ -1,38 +1,7 @@
{ lib, ... }:
let
# Clean the package source leaving only the relevant rust files
cleanRustSrc =
pname: src:
lib.cleanSourceWith {
inherit src;
name = "${pname}-source";
# Adapted from <https://github.com/ipetkov/crane/blob/master/lib/filterCargoSources.nix>
# no need to pull in crane for just this
filter =
orig_path: type:
let
path_str = toString orig_path;
base = baseNameOf path_str;
parentDir = baseNameOf (dirOf path_str);
matchesSuffix = lib.any (suffix: lib.hasSuffix suffix base) [
# Rust sources
".rs"
# TOML files are often used to configure cargo based tools (e.g. .cargo/config.toml)
".toml"
];
isCargoLock = base == "Cargo.lock";
# .cargo/config.toml is captured above
isOldStyleCargoConfig = parentDir == ".cargo" && base == "config";
in
type == "directory" || matchesSuffix || isCargoLock || isOldStyleCargoConfig;
};
# callPackage but for my rust Packages
callRustPackage =
pkgs: pname: nixSrc:
pkgs.callPackage nixSrc { cleanRustSrc = cleanRustSrc pname; };
packages = pkgs: {
jpassmenu = pkgs.callPackage ./jpassmenu/package.nix { };
audiomenu = callRustPackage pkgs "audiomenu" ./audiomenu/package.nix;
audiomenu = pkgs.callPackage ./audiomenu/package.nix { };
};
in
{