318b2a8443
The tile or block grows into its menu; the panel fades its content and takes the menu's size underneath. Wi-Fi lists networks (join saved/open, disconnect, stop iwd), Bluetooth lists devices, VPN has a switch per openvpn service, Sound has every output with its own slider and a default picker. A second Sound slider for the output named by extra_output in shell.conf (NR515). Screen and keyboard light are written to sysfs directly: logind refuses a shell without a seat.
629 lines
22 KiB
Rust
629 lines
22 KiB
Rust
// plain text parsing, kept free of amane so `rustc --test parse.rs` checks it on its own
|
|
|
|
// the equalizer's bands, in Hz
|
|
pub const BANDS: [u32; 10] = [31, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
|
|
|
|
// node names of the island's own equalizer sinks start with this
|
|
pub const EQ_PREFIX: &str = "magic_island_eq.";
|
|
|
|
// one sound stream from `LC_ALL=C pactl list sink-inputs`
|
|
#[derive(Debug, Clone, PartialEq, Default)]
|
|
pub struct Stream {
|
|
pub id: u32,
|
|
|
|
// the sink it plays into
|
|
pub sink: u32,
|
|
|
|
// first channel, in percent; pactl allows more than 100
|
|
pub volume: u8,
|
|
|
|
pub muted: bool,
|
|
|
|
// application.process.id, 0 when the stream doesn't say
|
|
pub pid: u32,
|
|
|
|
// application.process.binary and application.name, lowercased for matching
|
|
pub binary: String,
|
|
pub name: String,
|
|
|
|
// node.name, which tells the island's own equalizer streams apart
|
|
pub node_name: String,
|
|
}
|
|
|
|
// one output device from `LC_ALL=C pactl list sinks`
|
|
#[derive(Debug, Clone, PartialEq, Default)]
|
|
pub struct Sink {
|
|
pub index: u32,
|
|
pub name: String,
|
|
pub description: String,
|
|
pub bluetooth: bool,
|
|
|
|
// first channel, in percent
|
|
pub volume: u8,
|
|
pub muted: bool,
|
|
}
|
|
|
|
pub fn sink_inputs(text: &str) -> Vec<Stream> {
|
|
let mut streams: Vec<Stream> = Vec::new();
|
|
|
|
for line in text.lines() {
|
|
let line = line.trim();
|
|
|
|
if let Some(id) = line.strip_prefix("Sink Input #") {
|
|
streams.push(Stream { id: id.parse().unwrap_or(0), ..Stream::default() });
|
|
|
|
continue;
|
|
}
|
|
|
|
let Some(stream) = streams.last_mut() else {
|
|
continue;
|
|
};
|
|
|
|
if let Some(sink) = line.strip_prefix("Sink: ") {
|
|
stream.sink = sink.parse().unwrap_or(0);
|
|
} else if let Some(mute) = line.strip_prefix("Mute: ") {
|
|
stream.muted = mute == "yes";
|
|
} else if let Some(volume) = line.strip_prefix("Volume: ") {
|
|
stream.volume = first_percent(volume);
|
|
} else if let Some((key, value)) = line.split_once(" = ") {
|
|
let value = value.trim_matches('"');
|
|
|
|
match key {
|
|
"application.process.id" => stream.pid = value.parse().unwrap_or(0),
|
|
"application.process.binary" => stream.binary = value.to_lowercase(),
|
|
"application.name" => stream.name = value.to_lowercase(),
|
|
"node.name" => stream.node_name = value.to_string(),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
streams
|
|
}
|
|
|
|
pub fn sinks(text: &str) -> Vec<Sink> {
|
|
let mut sinks: Vec<Sink> = Vec::new();
|
|
|
|
for line in text.lines() {
|
|
let line = line.trim();
|
|
|
|
if let Some(index) = line.strip_prefix("Sink #") {
|
|
sinks.push(Sink { index: index.parse().unwrap_or(0), ..Sink::default() });
|
|
|
|
continue;
|
|
}
|
|
|
|
let Some(sink) = sinks.last_mut() else {
|
|
continue;
|
|
};
|
|
|
|
if let Some(name) = line.strip_prefix("Name: ") {
|
|
sink.name = name.to_string();
|
|
} else if let Some(description) = line.strip_prefix("Description: ") {
|
|
sink.description = description.to_string();
|
|
} else if let Some(mute) = line.strip_prefix("Mute: ") {
|
|
sink.muted = mute == "yes";
|
|
} else if let Some(volume) = line.strip_prefix("Volume: ") {
|
|
sink.volume = first_percent(volume);
|
|
} else if line == "device.api = \"bluez5\"" {
|
|
sink.bluetooth = true;
|
|
}
|
|
}
|
|
|
|
sinks
|
|
}
|
|
|
|
/*
|
|
* what a person calls an output: the monitor or receiver an hdmi port names in brackets,
|
|
* "... DisplayPort 1 Output [TX-NR515] (Stereo)" gives "TX-NR515", else the description
|
|
*/
|
|
pub fn short_sink_name(description: &str) -> &str {
|
|
match (description.find('['), description.find(']')) {
|
|
(Some(open), Some(close)) if open < close => &description[open + 1..close],
|
|
_ => description,
|
|
}
|
|
}
|
|
|
|
// "front-left: 52429 / 80% / -5.81 dB, front-right: ..." gives 80
|
|
fn first_percent(volume: &str) -> u8 {
|
|
let percent = volume.split('/').nth(1).map(|part| part.trim().trim_end_matches('%'));
|
|
|
|
percent.and_then(|part| part.parse::<u32>().ok()).map_or(0, |part| part.min(255) as u8)
|
|
}
|
|
|
|
// one frame of cava's raw ascii output, "12;80;3;" with ascii_max_range 100, as 0 to 1
|
|
pub fn levels(line: &str) -> Vec<f32> {
|
|
line.split(';')
|
|
.filter_map(|value| value.trim().parse::<f32>().ok())
|
|
.map(|value| (value / 100.0).clamp(0.0, 1.0))
|
|
.collect()
|
|
}
|
|
|
|
/*
|
|
* the streams a player owns: the ones its process (or a child, like a browser's
|
|
* audio process) plays, else the ones named after it, for sandboxes that hide pids
|
|
*/
|
|
pub fn owned<'a>(
|
|
streams: &'a [Stream],
|
|
player_pid: u32,
|
|
app: &str,
|
|
ancestry: impl Fn(u32) -> Vec<u32>,
|
|
) -> Vec<&'a Stream> {
|
|
let by_process: Vec<&Stream> = streams
|
|
.iter()
|
|
.filter(|stream| player_pid > 1 && stream.pid > 1 && ancestry(stream.pid).contains(&player_pid))
|
|
.collect();
|
|
|
|
if !by_process.is_empty() || app.is_empty() {
|
|
return by_process;
|
|
}
|
|
|
|
streams
|
|
.iter()
|
|
.filter(|stream| stream.binary.contains(app) || stream.name.contains(app))
|
|
.collect()
|
|
}
|
|
|
|
// "org.mpris.MediaPlayer2.chromium.instance123" gives "chromium"
|
|
pub fn app_of(bus_name: &str) -> String {
|
|
let rest = bus_name.trim_start_matches("org.mpris.MediaPlayer2.");
|
|
|
|
rest.split('.').next().unwrap_or("").to_lowercase()
|
|
}
|
|
|
|
// "org.mpris.MediaPlayer2.chromium.instance123" gives "chromium_instance123", safe in node names and paths
|
|
pub fn key_of(bus_name: &str) -> String {
|
|
let rest = bus_name.trim_start_matches("org.mpris.MediaPlayer2.");
|
|
|
|
rest.chars().map(|letter| if letter.is_ascii_alphanumeric() { letter } else { '_' }).collect()
|
|
}
|
|
|
|
// the parent pid in /proc/<pid>/stat; the name in parentheses may hold spaces and ')'
|
|
pub fn parent_of(stat: &str) -> Option<u32> {
|
|
let after_name = &stat[stat.rfind(')')? + 1..];
|
|
|
|
after_name.split_whitespace().nth(1)?.parse().ok()
|
|
}
|
|
|
|
// "file:///home/me/My%20Music/cover.jpg" gives "/home/me/My Music/cover.jpg"
|
|
pub fn file_path(url: &str) -> Option<String> {
|
|
let bytes = url.strip_prefix("file://")?.as_bytes();
|
|
|
|
let mut decoded = Vec::with_capacity(bytes.len());
|
|
let mut index = 0;
|
|
|
|
while index < bytes.len() {
|
|
let escaped = bytes
|
|
.get(index + 1..index + 3)
|
|
.and_then(|hex| std::str::from_utf8(hex).ok())
|
|
.and_then(|hex| u8::from_str_radix(hex, 16).ok());
|
|
|
|
match (bytes[index], escaped) {
|
|
(b'%', Some(byte)) => {
|
|
decoded.push(byte);
|
|
index += 3;
|
|
}
|
|
(byte, _) => {
|
|
decoded.push(byte);
|
|
index += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
Some(String::from_utf8_lossy(&decoded).into_owned())
|
|
}
|
|
|
|
// what may go inside a quoted pipewire config string: player names come from other programs
|
|
fn config_text(text: &str) -> String {
|
|
text.chars().filter(|letter| letter.is_alphanumeric() || " .-_:".contains(*letter)).collect()
|
|
}
|
|
|
|
/*
|
|
* cuts the middle, not the end: "700 Series Chipset Family HD Audio HDMI / DisplayPort 3 Output"
|
|
* and its "... 2 Output" sibling only differ at the end
|
|
*/
|
|
pub fn middle_ellipsis(label: &str, max: usize) -> String {
|
|
let letters: Vec<char> = label.chars().collect();
|
|
|
|
if letters.len() <= max || max < 3 {
|
|
return label.to_string();
|
|
}
|
|
|
|
let head = (max - 1) / 2;
|
|
let tail = max - 1 - head;
|
|
|
|
letters[..head].iter().chain(['…'].iter()).chain(letters[letters.len() - tail..].iter()).collect()
|
|
}
|
|
|
|
/*
|
|
* a standalone `pipewire -c` config: one 10-band filter-chain sink named
|
|
* magic_island_eq.<key>, whose output plays into `target` (empty: the default)
|
|
*/
|
|
pub fn eq_config(key: &str, description: &str, gains: &[f32; 10], target: &str) -> String {
|
|
let mut nodes = String::new();
|
|
|
|
for (index, (band, gain)) in BANDS.iter().zip(gains).enumerate() {
|
|
let (label, q) = match index {
|
|
0 => ("bq_lowshelf", 0.7),
|
|
9 => ("bq_highshelf", 0.7),
|
|
_ => ("bq_peaking", 1.4),
|
|
};
|
|
|
|
nodes += &format!(
|
|
" {{ type = builtin name = b{} label = {label} control = {{ \"Freq\" = {band}.0 \"Q\" = {q} \"Gain\" = {gain:.1} }} }}\n",
|
|
index + 1
|
|
);
|
|
}
|
|
|
|
let links: String = (1..BANDS.len())
|
|
.map(|index| format!(" {{ output = \"b{index}:Out\" input = \"b{}:In\" }}\n", index + 1))
|
|
.collect();
|
|
|
|
let target = config_text(target);
|
|
let target = if target.is_empty() { String::new() } else { format!("target.object = \"{target}\"") };
|
|
|
|
format!(
|
|
"context.spa-libs = {{ audio.convert.* = audioconvert/libspa-audioconvert support.* = support/libspa-support }}
|
|
context.modules = [
|
|
{{ name = libpipewire-module-rt args = {{ }} flags = [ ifexists nofail ] }}
|
|
{{ name = libpipewire-module-protocol-native }}
|
|
{{ name = libpipewire-module-client-node }}
|
|
{{ name = libpipewire-module-adapter }}
|
|
{{ name = libpipewire-module-filter-chain
|
|
args = {{
|
|
node.description = \"{description} EQ\"
|
|
media.name = \"{description} EQ\"
|
|
filter.graph = {{
|
|
nodes = [
|
|
{nodes} ]
|
|
links = [
|
|
{links} ]
|
|
}}
|
|
audio.channels = 2
|
|
audio.position = [ FL FR ]
|
|
capture.props = {{ node.name = \"{EQ_PREFIX}{key}\" media.class = Audio/Sink }}
|
|
playback.props = {{ node.name = \"{EQ_PREFIX}{key}.out\" node.passive = true {target} }}
|
|
}}
|
|
}}
|
|
]
|
|
",
|
|
description = config_text(description),
|
|
)
|
|
}
|
|
|
|
// the `pw-cli set-param <node> Props` value that sets every band at once
|
|
pub fn eq_params(gains: &[f32; 10]) -> String {
|
|
let params: Vec<String> = gains.iter().enumerate().map(|(index, gain)| format!("\"b{}:Gain\" {gain:.1}", index + 1)).collect();
|
|
|
|
format!("{{ params = [ {} ] }}", params.join(" "))
|
|
}
|
|
|
|
// saved equalizers, one per line: identity, preset name, ten gains
|
|
pub fn eq_store(text: &str) -> Vec<(String, String, [f32; 10])> {
|
|
let mut saved = Vec::new();
|
|
|
|
for line in text.lines() {
|
|
let mut fields = line.split('\t');
|
|
|
|
let (Some(identity), Some(preset), Some(gains)) = (fields.next(), fields.next(), fields.next()) else {
|
|
continue;
|
|
};
|
|
|
|
let values: Vec<f32> = gains.split_whitespace().filter_map(|gain| gain.parse().ok()).collect();
|
|
|
|
if let Ok(gains) = <[f32; 10]>::try_from(values) {
|
|
saved.push((identity.to_string(), preset.to_string(), gains));
|
|
}
|
|
}
|
|
|
|
saved
|
|
}
|
|
|
|
pub fn eq_line(identity: &str, preset: &str, gains: &[f32; 10]) -> String {
|
|
let clean = |text: &str| text.replace(['\t', '\n'], " ");
|
|
|
|
let gains: Vec<String> = gains.iter().map(|gain| format!("{gain:.1}")).collect();
|
|
|
|
format!("{}\t{}\t{}\n", clean(identity), clean(preset), gains.join(" "))
|
|
}
|
|
|
|
// "key = value" lines, as in shell.conf and the matugen theme; blank lines, # comments and lines without = are skipped
|
|
pub fn settings(text: &str) -> Vec<(String, String)> {
|
|
text.lines()
|
|
.map(str::trim)
|
|
.filter(|line| !line.starts_with('#'))
|
|
.filter_map(|line| line.split_once('='))
|
|
.map(|(key, value)| (key.trim().to_string(), value.trim().to_string()))
|
|
.collect()
|
|
}
|
|
|
|
// "#rrggbb" as red, green, blue; anything else is none
|
|
pub fn rgb(hex: &str) -> Option<[u8; 3]> {
|
|
let digits = hex.strip_prefix('#').filter(|digits| digits.len() == 6)?;
|
|
|
|
let channel = |at: usize| u8::from_str_radix(digits.get(at..at + 2)?, 16).ok();
|
|
|
|
Some([channel(0)?, channel(2)?, channel(4)?])
|
|
}
|
|
|
|
// a wi-fi signal in dBm as 0 to 3 lit arcs; a connection, however weak, keeps one
|
|
pub fn wifi_bars(dbm: f64) -> u8 {
|
|
match dbm {
|
|
level if level >= -60.0 => 3,
|
|
level if level >= -70.0 => 2,
|
|
_ => 1,
|
|
}
|
|
}
|
|
|
|
// names that go to rc-service: openrc's own service names, never anything with spaces or quotes
|
|
pub fn safe_name(name: &str) -> bool {
|
|
!name.is_empty() && name.chars().all(|letter| letter.is_ascii_alphanumeric() || "._-".contains(letter))
|
|
}
|
|
|
|
// one gpu client from /proc/<pid>/fdinfo/<fd>, see the kernel's drm-usage-stats
|
|
#[derive(Debug, Clone, PartialEq, Default)]
|
|
pub struct DrmClient {
|
|
pub id: u64,
|
|
pub pdev: String,
|
|
pub vram_kib: u64,
|
|
|
|
// nanoseconds spent on every engine together; none when the driver reports no engine time
|
|
pub engine_ns: Option<u64>,
|
|
}
|
|
|
|
pub fn drm_client(fdinfo: &str) -> Option<DrmClient> {
|
|
let mut client = DrmClient::default();
|
|
let mut id = None;
|
|
let mut resident = None;
|
|
|
|
for line in fdinfo.lines() {
|
|
let Some((key, value)) = line.split_once(':') else {
|
|
continue;
|
|
};
|
|
|
|
let value = value.trim();
|
|
|
|
match key {
|
|
"drm-client-id" => id = value.parse().ok(),
|
|
"drm-pdev" => client.pdev = value.to_string(),
|
|
// the older name of resident vram
|
|
"drm-memory-vram" => client.vram_kib = kib(value),
|
|
"drm-resident-vram" => resident = Some(kib(value)),
|
|
_ if key.starts_with("drm-engine-") && !key.starts_with("drm-engine-capacity-") => {
|
|
let nanoseconds = value.trim_end_matches("ns").trim().parse::<u64>().unwrap_or(0);
|
|
|
|
client.engine_ns = Some(client.engine_ns.unwrap_or(0) + nanoseconds);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
client.id = id?;
|
|
|
|
if client.vram_kib == 0 {
|
|
client.vram_kib = resident.unwrap_or(0);
|
|
}
|
|
|
|
Some(client)
|
|
}
|
|
|
|
// "12 KiB", "2 MiB", or plain bytes, as KiB
|
|
fn kib(value: &str) -> u64 {
|
|
let (number, unit) = value.split_once(' ').unwrap_or((value, ""));
|
|
let number: u64 = number.trim().parse().unwrap_or(0);
|
|
|
|
match unit.trim() {
|
|
"KiB" => number,
|
|
"MiB" => number * 1024,
|
|
"GiB" => number * 1024 * 1024,
|
|
_ => number / 1024,
|
|
}
|
|
}
|
|
|
|
// `lspci -mm -s <address>`: the device field, its marketing name if bracketed, the first of several
|
|
// "Navi 23 [Radeon RX 6600/6600 XT/6600M]" gives "Radeon RX 6600"
|
|
pub fn gpu_name(lspci: &str) -> String {
|
|
let fields: Vec<&str> = lspci.split('"').skip(1).step_by(2).collect();
|
|
|
|
let device = fields.get(2).copied().unwrap_or("");
|
|
|
|
let name = match (device.find('['), device.rfind(']')) {
|
|
(Some(open), Some(close)) if open < close => &device[open + 1..close],
|
|
_ => device,
|
|
};
|
|
|
|
name.split('/').next().unwrap_or("").trim().to_string()
|
|
}
|
|
|
|
// minutes until empty (discharging) or full (charging), from energy or charge readings and their rate
|
|
pub fn battery_minutes(status: &str, now: u64, full: u64, rate: u64) -> Option<u32> {
|
|
if rate == 0 {
|
|
return None;
|
|
}
|
|
|
|
let left = match status {
|
|
"Discharging" => now,
|
|
"Charging" => full.saturating_sub(now),
|
|
_ => return None,
|
|
};
|
|
|
|
Some((left as f64 / rate as f64 * 60.0).round() as u32)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const PACTL: &str = "Sink Input #42
|
|
\tDriver: PipeWire
|
|
\tSink: 65
|
|
\tFormat: pcm, format.sample_format = \"\\\"float32le\\\"\" format.rate = \"48000\"
|
|
\tMute: no
|
|
\tVolume: front-left: 52429 / 80% / -5.81 dB, front-right: 52429 / 80% / -5.81 dB
|
|
\t balance 0.00
|
|
\tSink Latency: 0 usec
|
|
\tProperties:
|
|
\t\tapplication.name = \"Chromium\"
|
|
\t\tapplication.process.id = \"5003\"
|
|
\t\tapplication.process.binary = \"chrome\"
|
|
\t\tnode.name = \"Chromium\"
|
|
|
|
Sink Input #57
|
|
\tSink: 3479
|
|
\tMute: yes
|
|
\tVolume: mono: 98304 / 150% / 10.57 dB
|
|
\tProperties:
|
|
\t\tapplication.name = \"spotify\"
|
|
\t\tapplication.process.binary = \"spotify\"
|
|
";
|
|
|
|
const SINKS: &str = "Sink #65
|
|
\tState: RUNNING
|
|
\tName: alsa_output.usb-Yamaha_AG03MK2-00.analog-stereo
|
|
\tDescription: Yamaha AG03MK2 Analog Stereo
|
|
\tMute: no
|
|
\tVolume: front-left: 52428 / 80% / -5.81 dB, front-right: 52428 / 80% / -5.81 dB
|
|
\tBase Volume: 65536 / 100% / 0.00 dB
|
|
\tProperties:
|
|
\t\tdevice.api = \"alsa\"
|
|
\tPorts:
|
|
\t\tanalog-output: Analog Output (type: Line, priority: 9900, availability unknown)
|
|
|
|
Sink #80
|
|
\tName: bluez_output.00_1B_66_AA_BB_CC.1
|
|
\tDescription: JBL Tune 720BT
|
|
\tMute: yes
|
|
\tProperties:
|
|
\t\tdevice.api = \"bluez5\"
|
|
";
|
|
|
|
#[test]
|
|
fn reads_streams() {
|
|
let streams = sink_inputs(PACTL);
|
|
|
|
assert_eq!(streams.len(), 2);
|
|
assert_eq!((streams[0].id, streams[0].sink, streams[0].volume, streams[0].muted), (42, 65, 80, false));
|
|
assert_eq!((streams[0].pid, streams[0].binary.as_str(), streams[0].node_name.as_str()), (5003, "chrome", "Chromium"));
|
|
assert_eq!((streams[1].id, streams[1].sink, streams[1].volume, streams[1].muted), (57, 3479, 150, true));
|
|
assert_eq!((streams[1].pid, streams[1].name.as_str()), (0, "spotify"));
|
|
}
|
|
|
|
#[test]
|
|
fn reads_sinks() {
|
|
let sinks = sinks(SINKS);
|
|
|
|
assert_eq!(sinks.len(), 2);
|
|
assert_eq!((sinks[0].index, sinks[0].bluetooth), (65, false));
|
|
assert_eq!(sinks[0].description, "Yamaha AG03MK2 Analog Stereo");
|
|
assert_eq!((sinks[0].volume, sinks[0].muted, sinks[1].muted), (80, false, true));
|
|
assert_eq!((sinks[1].name.as_str(), sinks[1].description.as_str(), sinks[1].bluetooth), ("bluez_output.00_1B_66_AA_BB_CC.1", "JBL Tune 720BT", true));
|
|
|
|
assert_eq!(short_sink_name("700 Series Chipset Family HD Audio HDMI / DisplayPort 1 Output [TX-NR515] (Stereo)"), "TX-NR515");
|
|
assert_eq!(short_sink_name("Yamaha AG03MK2 Analog Stereo"), "Yamaha AG03MK2 Analog Stereo");
|
|
}
|
|
|
|
#[test]
|
|
fn matches_children_then_names() {
|
|
let streams = sink_inputs(PACTL);
|
|
|
|
// chrome's audio process 5003 is a child of the browser 5000 that owns the mpris name
|
|
let ancestry = |pid: u32| if pid == 5003 { vec![5003, 5000, 1] } else { vec![pid] };
|
|
|
|
let chromium = owned(&streams, 5000, "chromium", ancestry);
|
|
assert_eq!(chromium.iter().map(|stream| stream.id).collect::<Vec<_>>(), [42]);
|
|
|
|
// no pid on spotify's stream, so its name decides
|
|
let spotify = owned(&streams, 777, "spotify", ancestry);
|
|
assert_eq!(spotify.iter().map(|stream| stream.id).collect::<Vec<_>>(), [57]);
|
|
|
|
assert!(owned(&streams, 9999, "mpv", ancestry).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn reads_names_paths_and_levels() {
|
|
assert_eq!(app_of("org.mpris.MediaPlayer2.chromium.instance123"), "chromium");
|
|
assert_eq!(key_of("org.mpris.MediaPlayer2.chromium.instance123"), "chromium_instance123");
|
|
|
|
assert_eq!(parent_of("5003 (Web (Content)) S 5000 5003 0"), Some(5000));
|
|
|
|
assert_eq!(file_path("file:///home/me/My%20Music/a%2Fb.jpg").as_deref(), Some("/home/me/My Music/a/b.jpg"));
|
|
assert_eq!(file_path("file:///x/100%"), Some(String::from("/x/100%")));
|
|
assert_eq!(file_path("https://i.scdn.co/image/ab"), None);
|
|
|
|
assert_eq!(levels("0;50;100;250;\n"), [0.0, 0.5, 1.0, 1.0]);
|
|
|
|
let hdmi = "700 Series Chipset Family HD Audio HDMI / DisplayPort 3 Output";
|
|
assert_eq!(middle_ellipsis(hdmi, 30), "700 Series Chi…ayPort 3 Output");
|
|
assert_eq!(middle_ellipsis(hdmi, 30).chars().count(), 30);
|
|
assert_eq!(middle_ellipsis("JBL Tune 720BT", 30), "JBL Tune 720BT");
|
|
}
|
|
|
|
#[test]
|
|
fn writes_equalizers() {
|
|
let gains = [6.0, 5.0, 4.0, 2.5, 1.0, 0.0, 0.0, -1.0, 0.0, 3.5];
|
|
|
|
let config = eq_config("fake0", "Lo\"-Fi ${x} Lab", &gains, "bluez_output.00_1B.1");
|
|
|
|
assert!(config.contains("node.name = \"magic_island_eq.fake0\" media.class = Audio/Sink"));
|
|
assert!(config.contains("target.object = \"bluez_output.00_1B.1\""));
|
|
assert!(config.contains("name = b1 label = bq_lowshelf control = { \"Freq\" = 31.0 \"Q\" = 0.7 \"Gain\" = 6.0 }"));
|
|
assert!(config.contains("name = b10 label = bq_highshelf"));
|
|
assert!(config.contains("{ output = \"b9:Out\" input = \"b10:In\" }"));
|
|
|
|
// a hostile player name can't close the quoted string
|
|
assert!(config.contains("node.description = \"Lo-Fi x Lab EQ\""));
|
|
assert!(!eq_config("fake0", "x", &gains, "").contains("target.object"));
|
|
|
|
assert!(eq_params(&gains).starts_with("{ params = [ \"b1:Gain\" 6.0 \"b2:Gain\" 5.0"));
|
|
|
|
let line = eq_line("Lo-Fi\tLab", "Bass", &gains);
|
|
assert_eq!(eq_store(&line), [(String::from("Lo-Fi Lab"), String::from("Bass"), gains)]);
|
|
assert!(eq_store("broken\tline\n").is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn reads_settings_and_colours() {
|
|
let text = "# a comment\ncover_colors = true\n\naccent=#ffb3af\nbroken\n";
|
|
|
|
let expected = [(String::from("cover_colors"), String::from("true")), (String::from("accent"), String::from("#ffb3af"))];
|
|
|
|
assert_eq!(settings(text), expected);
|
|
|
|
assert_eq!(rgb("#ffb3af"), Some([255, 179, 175]));
|
|
assert_eq!(rgb("#ffb3a"), None);
|
|
assert_eq!(rgb("ffb3af"), None);
|
|
assert_eq!(rgb("#gg0000"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn reads_gpu_clients_and_names() {
|
|
let idle = "drm-driver:\tamdgpu\ndrm-client-id:\t593\ndrm-pdev:\t0000:27:00.0\ndrm-total-vram:\t12 KiB\ndrm-resident-vram:\t12 KiB\ndrm-memory-vram:\t12 KiB\ndrm-memory-gtt: \t2048 KiB\n";
|
|
|
|
assert_eq!(drm_client(idle), Some(DrmClient { id: 593, pdev: String::from("0000:27:00.0"), vram_kib: 12, engine_ns: None }));
|
|
|
|
let busy = "drm-client-id:\t7\ndrm-pdev:\t0000:27:00.0\ndrm-resident-vram:\t3 MiB\ndrm-engine-gfx:\t1500 ns\ndrm-engine-compute:\t500 ns\ndrm-engine-capacity-gfx:\t2\n";
|
|
|
|
assert_eq!(drm_client(busy).map(|client| (client.vram_kib, client.engine_ns)), Some((3072, Some(2000))));
|
|
assert_eq!(drm_client("drm-pdev:\t0000:27:00.0\n"), None);
|
|
|
|
let lspci = "27:00.0 \"VGA compatible controller\" \"Advanced Micro Devices, Inc. [AMD/ATI]\" \"Navi 23 [Radeon RX 6600/6600 XT/6600M]\" -rc7 \"Sapphire\" \"Device e448\"";
|
|
|
|
assert_eq!(gpu_name(lspci), "Radeon RX 6600");
|
|
assert_eq!(gpu_name("00:02.0 \"VGA\" \"Intel Corporation\" \"Raptor Lake-S UHD Graphics\""), "Raptor Lake-S UHD Graphics");
|
|
}
|
|
|
|
#[test]
|
|
fn reads_power_and_names() {
|
|
// 40 Wh left at 20 W: two hours; 10 Wh to go at 30 W: twenty minutes
|
|
assert_eq!(battery_minutes("Discharging", 40_000_000, 80_000_000, 20_000_000), Some(120));
|
|
assert_eq!(battery_minutes("Charging", 70_000_000, 80_000_000, 30_000_000), Some(20));
|
|
assert_eq!(battery_minutes("Not charging", 70_000_000, 80_000_000, 0), None);
|
|
|
|
assert_eq!((wifi_bars(-55.0), wifi_bars(-66.0), wifi_bars(-91.0)), (3, 2, 1));
|
|
|
|
assert!(safe_name("openvpn.swiss"));
|
|
assert!(!safe_name("openvpn.x; reboot"));
|
|
assert!(!safe_name(""));
|
|
}
|
|
}
|