├── .envrc ├── .gitattributes ├── .gitignore ├── src ├── test_utils.rs ├── macros.rs ├── wrapper │ ├── termination.rs │ ├── date_time.rs │ ├── uom.rs │ ├── file.rs │ ├── channel.rs │ ├── libnotify.rs │ ├── inotify.rs │ ├── thread.rs │ ├── xsetroot.rs │ ├── dbus │ │ ├── data.rs │ │ └── message.rs │ ├── config.rs │ ├── dbus.rs │ ├── process.rs │ └── battery.rs ├── wrapper.rs ├── communication.rs ├── features │ ├── time │ │ ├── updater.rs │ │ ├── notifier.rs │ │ ├── data.rs │ │ └── config.rs │ ├── backlight │ │ ├── updater.rs │ │ ├── notifier.rs │ │ ├── config.rs │ │ ├── device.rs │ │ └── data.rs │ ├── audio │ │ ├── notifier.rs │ │ ├── config.rs │ │ ├── updater.rs │ │ └── data.rs │ ├── battery │ │ ├── updater.rs │ │ ├── config.rs │ │ ├── data.rs │ │ ├── notifier.rs │ │ └── dbus.rs │ ├── time.rs │ ├── cpu_load.rs │ ├── cpu_load │ │ ├── notifier.rs │ │ ├── config.rs │ │ ├── updater.rs │ │ └── data.rs │ ├── audio.rs │ ├── backlight.rs │ ├── network │ │ ├── notifier.rs │ │ ├── data.rs │ │ ├── updater.rs │ │ └── config.rs │ ├── battery.rs │ └── network.rs ├── features.rs ├── resume.rs ├── test_utils │ ├── config.rs │ └── log.rs ├── main.rs ├── feature.rs ├── status_bar.rs ├── expression_parser.rs ├── utils.rs ├── lib.rs ├── error.rs └── settings.rs ├── examples ├── images │ └── preview.png ├── icon-settings │ └── nerd-font.toml └── default-settings │ ├── defaults.yml │ ├── defaults.toml │ ├── defaults.hjson │ └── defaults.json ├── renovate.json ├── release.toml ├── .editorconfig ├── rustfmt.toml ├── release-hook.sh ├── shell.nix ├── default.nix ├── LICENSE ├── Cargo.toml ├── flake.lock ├── flake.nix ├── .github └── workflows │ └── ci.yml ├── README.md └── CHANGELOG.md /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.png binary 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | result 2 | target/ 3 | test-config.toml 4 | **/*.rs.bk 5 | -------------------------------------------------------------------------------- /src/test_utils.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "mocking")] 2 | pub(crate) mod config; 3 | pub(crate) mod log; 4 | -------------------------------------------------------------------------------- /examples/images/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gerschtli/dwm-status/HEAD/examples/images/preview.png -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:recommended" 4 | ], 5 | "ignoreDeps": [ 6 | "config", 7 | "dbus", 8 | "uom" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /release.toml: -------------------------------------------------------------------------------- 1 | pre-release-commit-message = "Prepare {{version}} release" 2 | pre-release-hook = ["./release-hook.sh"] 3 | tag-message = "Version {{version}}" 4 | tag-name = "{{version}}" 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.{rs,sh}] 11 | indent_size = 4 12 | 13 | [*.{hjson,json,md,nix,yml}] 14 | indent_size = 2 15 | -------------------------------------------------------------------------------- /src/macros.rs: -------------------------------------------------------------------------------- 1 | macro_rules! map { 2 | ( $( $k: expr => $v: expr, )* ) => {{ 3 | use std::collections::HashMap; 4 | 5 | let mut map: HashMap = HashMap::new(); 6 | $( map.insert($k.into(), $v.into()); )* 7 | map 8 | }} 9 | } 10 | -------------------------------------------------------------------------------- /src/wrapper/termination.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::error::WrapErrorExt; 3 | 4 | pub(crate) fn register_handler(handler: T) -> Result<()> 5 | where 6 | T: Fn() + 'static + Send, 7 | { 8 | ctrlc::set_handler(handler).wrap_error("termination", "failed to set termination handler") 9 | } 10 | -------------------------------------------------------------------------------- /src/wrapper.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod battery; 2 | pub(crate) mod channel; 3 | pub(crate) mod config; 4 | pub(crate) mod date_time; 5 | pub(crate) mod dbus; 6 | pub(crate) mod file; 7 | pub(crate) mod inotify; 8 | pub(crate) mod libnotify; 9 | pub(crate) mod process; 10 | pub(crate) mod termination; 11 | pub(crate) mod thread; 12 | pub(crate) mod uom; 13 | pub(crate) mod xsetroot; 14 | -------------------------------------------------------------------------------- /examples/icon-settings/nerd-font.toml: -------------------------------------------------------------------------------- 1 | separator = "  " 2 | 3 | [audio] 4 | mute = "ﱝ" 5 | template = "{ICO} {VOL}%" 6 | icons = ["奄", "奔", "墳"] 7 | 8 | [backlight] 9 | template = "{ICO} {BL}%" 10 | icons = ["", "", ""] 11 | 12 | [battery] 13 | charging = "" 14 | discharging = "" 15 | no_battery = "" 16 | icons = ["", "", "", "", "", "", "", "", "", "", ""] 17 | -------------------------------------------------------------------------------- /src/communication.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::wrapper::channel; 3 | 4 | #[derive(Clone, Copy, Debug)] 5 | pub(crate) enum Message { 6 | FeatureUpdate(usize), 7 | Kill, 8 | UpdateAll, 9 | } 10 | 11 | pub(crate) fn send_message(id: usize, sender: &channel::Sender) -> Result<()> { 12 | let message = Message::FeatureUpdate(id); 13 | 14 | sender.send(message) 15 | } 16 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | combine_control_expr = false 2 | comment_width = 100 3 | condense_wildcard_suffixes = true 4 | format_macro_bodies = true 5 | format_strings = true 6 | match_block_trailing_comma = true 7 | max_width = 100 8 | normalize_comments = true 9 | normalize_doc_attributes = true 10 | reorder_impl_items = true 11 | use_field_init_shorthand = true 12 | use_try_shorthand = true 13 | style_edition = "2021" 14 | wrap_comments = true 15 | -------------------------------------------------------------------------------- /release-hook.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | if [[ "${DRY_RUN}" != "false" ]]; then 5 | exit 0 6 | fi 7 | 8 | CHANGELOG="CHANGELOG.md" 9 | CURRENT_DATE="$(date "+%Y-%m-%d")" 10 | 11 | sed -E -i \ 12 | -e "s,^(## \[Unreleased\])$,\1\n\n## [${NEW_VERSION}] - ${CURRENT_DATE}," \ 13 | -e "s,^(\[Unreleased\]:)(.*?)${PREV_VERSION}(\.\.\.HEAD)$,\1\2${NEW_VERSION}\3\n[${NEW_VERSION}]:\2${PREV_VERSION}...${NEW_VERSION}," \ 14 | "${CHANGELOG}" 15 | -------------------------------------------------------------------------------- /src/wrapper/date_time.rs: -------------------------------------------------------------------------------- 1 | pub(crate) struct DateTime { 2 | date_time: chrono::DateTime, 3 | } 4 | 5 | #[cfg_attr(all(test, feature = "mocking"), mocktopus::macros::mockable)] 6 | impl DateTime { 7 | pub(crate) fn now() -> Self { 8 | Self { 9 | date_time: chrono::Local::now(), 10 | } 11 | } 12 | 13 | pub(crate) fn format(&self, format: &str) -> String { 14 | self.date_time.format(format).to_string() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/features/time/updater.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::feature; 3 | use crate::wrapper::date_time; 4 | 5 | use super::Data; 6 | 7 | pub(super) struct Updater { 8 | data: Data, 9 | } 10 | 11 | impl Updater { 12 | pub(super) const fn new(data: Data) -> Self { 13 | Self { data } 14 | } 15 | } 16 | 17 | impl feature::Updatable for Updater { 18 | fn renderable(&self) -> &dyn feature::Renderable { 19 | &self.data 20 | } 21 | 22 | fn update(&mut self) -> Result<()> { 23 | self.data.update(&date_time::DateTime::now()); 24 | 25 | Ok(()) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/wrapper/uom.rs: -------------------------------------------------------------------------------- 1 | use uom::si::f32::Ratio; 2 | use uom::si::f32::Time; 3 | use uom::si::ratio::percent; 4 | use uom::si::time::hour; 5 | use uom::si::time::minute; 6 | 7 | pub(crate) fn create_ratio_by_percentage(value: f32) -> Ratio { 8 | Ratio::new::(value) 9 | } 10 | 11 | pub(crate) fn get_raw_percent(percentage: Ratio) -> f32 { 12 | percentage.round::().get::() 13 | } 14 | 15 | pub(crate) fn get_raw_hours(time: Time) -> f32 { 16 | time.floor::().get::() 17 | } 18 | 19 | pub(crate) fn get_raw_minutes(time: Time) -> f32 { 20 | time.fract::().floor::().get::() 21 | } 22 | -------------------------------------------------------------------------------- /src/features/backlight/updater.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::feature; 3 | 4 | use super::BacklightDevice; 5 | use super::Data; 6 | 7 | pub(super) struct Updater { 8 | data: Data, 9 | device: BacklightDevice, 10 | } 11 | 12 | impl Updater { 13 | pub(super) const fn new(data: Data, device: BacklightDevice) -> Self { 14 | Self { data, device } 15 | } 16 | } 17 | 18 | impl feature::Updatable for Updater { 19 | fn renderable(&self) -> &dyn feature::Renderable { 20 | &self.data 21 | } 22 | 23 | fn update(&mut self) -> Result<()> { 24 | self.data.update(self.device.value()?); 25 | 26 | Ok(()) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/default-settings/defaults.yml: -------------------------------------------------------------------------------- 1 | debug: false 2 | order: [] 3 | separator: " / " 4 | 5 | audio: 6 | control: Master 7 | mute: MUTE 8 | template: S {VOL}% 9 | icons: [] 10 | 11 | backlight: 12 | device: intel_backlight 13 | template: L {BL}% 14 | icons: [] 15 | 16 | battery: 17 | charging: ▲ 18 | discharging: ▼ 19 | enable_notifier: true 20 | no_battery: NO BATT 21 | notifier_critical: 10 22 | notifier_levels: [2, 5, 10, 15, 20] 23 | separator: " · " 24 | icons: [] 25 | 26 | cpu_load: 27 | template: "{CL1} {CL5} {CL15}" 28 | update_interval: 20 29 | 30 | network: 31 | no_value: NA 32 | template: "{LocalIPv4} · {ESSID}" 33 | 34 | time: 35 | format: "%Y-%m-%d %H:%M" 36 | update_seconds: false 37 | -------------------------------------------------------------------------------- /examples/default-settings/defaults.toml: -------------------------------------------------------------------------------- 1 | debug = false 2 | order = [] 3 | separator = " / " 4 | 5 | [audio] 6 | control = "Master" 7 | mute = "MUTE" 8 | template = "S {VOL}%" 9 | icons = [] 10 | 11 | [backlight] 12 | device = "intel_backlight" 13 | template = "L {BL}%" 14 | icons = [] 15 | 16 | [battery] 17 | charging = "▲" 18 | discharging = "▼" 19 | enable_notifier = true 20 | no_battery = "NO BATT" 21 | notifier_critical = 10 22 | notifier_levels = [2, 5, 10, 15, 20] 23 | separator = " · " 24 | icons = [] 25 | 26 | [cpu_load] 27 | template = "{CL1} {CL5} {CL15}" 28 | update_interval = 20 29 | 30 | [network] 31 | no_value = "NA" 32 | template = "{LocalIPv4} · {ESSID}" 33 | 34 | [time] 35 | format = "%Y-%m-%d %H:%M" 36 | update_seconds = false 37 | -------------------------------------------------------------------------------- /src/features/audio/notifier.rs: -------------------------------------------------------------------------------- 1 | use crate::communication; 2 | use crate::error::Result; 3 | use crate::wrapper::channel; 4 | use crate::wrapper::process; 5 | use crate::wrapper::thread; 6 | 7 | pub(super) struct Notifier { 8 | id: usize, 9 | sender: channel::Sender, 10 | } 11 | 12 | impl Notifier { 13 | pub(super) const fn new(id: usize, sender: channel::Sender) -> Self { 14 | Self { id, sender } 15 | } 16 | } 17 | 18 | impl thread::Runnable for Notifier { 19 | fn run(&self) -> Result<()> { 20 | let command = process::Command::new("stdbuf", &["-oL", "alsactl", "monitor"]); 21 | 22 | command.listen_stdout(|| communication::send_message(self.id, &self.sender)) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import {} }: 2 | 3 | let 4 | buildInputs = with pkgs; [ 5 | # build dependencies 6 | dbus 7 | gdk-pixbuf 8 | glib 9 | libnotify 10 | pkg-config 11 | xorg.libX11 12 | 13 | # run-time dependencies 14 | alsa-utils 15 | coreutils 16 | dnsutils 17 | iproute2 18 | wirelesstools 19 | 20 | # dev tools 21 | cargo-edit 22 | cargo-release 23 | nixpkgs-fmt 24 | rustup 25 | 26 | # tarpaulin 27 | # run RUSTFLAGS="--cfg procmacro2_semver_exempt" cargo install cargo-tarpaulin -f 28 | openssl 29 | zlib 30 | ]; 31 | in 32 | 33 | pkgs.mkShell { 34 | inherit buildInputs; 35 | 36 | LD_LIBRARY_PATH = "${pkgs.lib.makeLibraryPath buildInputs}"; 37 | 38 | # RUST_BACKTRACE = 1; 39 | } 40 | -------------------------------------------------------------------------------- /src/wrapper/file.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::io; 3 | use std::io::Read; 4 | use std::path; 5 | use std::str; 6 | 7 | pub(crate) fn read

(path: P) -> io::Result 8 | where 9 | P: AsRef, 10 | { 11 | let mut s = String::new(); 12 | let mut file = fs::File::open(path)?; 13 | file.read_to_string(&mut s)?; 14 | Ok(s) 15 | } 16 | 17 | #[cfg_attr(all(test, feature = "mocking"), mocktopus::macros::mockable)] 18 | pub(crate) fn parse_file_content(path: P) -> io::Result 19 | where 20 | P: AsRef, 21 | T: str::FromStr, 22 | { 23 | read(&path)?.trim().parse().map_err(|_| { 24 | io::Error::other(format!( 25 | "contents of file '{}' failed to parse", 26 | path.as_ref().display() 27 | )) 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /src/features/battery/updater.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::feature; 3 | use crate::wrapper::battery::all_batteries; 4 | 5 | use super::BatteryNotifier; 6 | use super::Data; 7 | 8 | pub(super) struct Updater { 9 | data: Data, 10 | notifier: BatteryNotifier, 11 | } 12 | 13 | impl Updater { 14 | pub(super) const fn new(data: Data, notifier: BatteryNotifier) -> Self { 15 | Self { data, notifier } 16 | } 17 | } 18 | 19 | impl feature::Updatable for Updater { 20 | fn renderable(&self) -> &dyn feature::Renderable { 21 | &self.data 22 | } 23 | 24 | fn update(&mut self) -> Result<()> { 25 | let batteries = all_batteries()?; 26 | 27 | self.notifier.update(&batteries); 28 | self.data.update(&batteries); 29 | 30 | Ok(()) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/features/time.rs: -------------------------------------------------------------------------------- 1 | use crate::communication; 2 | use crate::error::Result; 3 | use crate::feature; 4 | use crate::wrapper::channel; 5 | 6 | pub(crate) use self::config::ConfigEntry; 7 | use self::data::Data; 8 | use self::notifier::Notifier; 9 | use self::updater::Updater; 10 | 11 | mod config; 12 | mod data; 13 | mod notifier; 14 | mod updater; 15 | 16 | pub(super) const FEATURE_NAME: &str = "time"; 17 | 18 | #[allow(clippy::unnecessary_wraps)] 19 | pub(super) fn create( 20 | id: usize, 21 | sender: &channel::Sender, 22 | settings: &ConfigEntry, 23 | ) -> Result> { 24 | let data = Data::new(settings.format.clone()); 25 | 26 | Ok(Box::new(feature::Composer::new( 27 | FEATURE_NAME, 28 | Notifier::new(id, sender.clone(), settings.update_seconds), 29 | Updater::new(data), 30 | ))) 31 | } 32 | -------------------------------------------------------------------------------- /src/features/cpu_load.rs: -------------------------------------------------------------------------------- 1 | use crate::communication; 2 | use crate::error::Result; 3 | use crate::feature; 4 | use crate::wrapper::channel; 5 | 6 | pub(crate) use self::config::ConfigEntry; 7 | use self::data::Data; 8 | use self::notifier::Notifier; 9 | use self::updater::Updater; 10 | 11 | mod config; 12 | mod data; 13 | mod notifier; 14 | mod updater; 15 | 16 | pub(super) const FEATURE_NAME: &str = "cpu_load"; 17 | 18 | #[allow(clippy::unnecessary_wraps)] 19 | pub(super) fn create( 20 | id: usize, 21 | sender: &channel::Sender, 22 | settings: &ConfigEntry, 23 | ) -> Result> { 24 | let data = Data::new(settings.template.clone()); 25 | 26 | Ok(Box::new(feature::Composer::new( 27 | FEATURE_NAME, 28 | Notifier::new(id, sender.clone(), settings.update_interval), 29 | Updater::new(data), 30 | ))) 31 | } 32 | -------------------------------------------------------------------------------- /examples/default-settings/defaults.hjson: -------------------------------------------------------------------------------- 1 | { 2 | debug: false 3 | order: [] 4 | separator: " / " 5 | audio: { 6 | control: Master 7 | mute: MUTE 8 | template: S {VOL}% 9 | icons: [] 10 | } 11 | backlight: { 12 | device: intel_backlight 13 | template: L {BL}% 14 | icons: [] 15 | } 16 | battery: { 17 | charging: ▲ 18 | discharging: ▼ 19 | enable_notifier: true 20 | no_battery: NO BATT 21 | notifier_critical: 10 22 | notifier_levels: [ 23 | 2 24 | 5 25 | 10 26 | 15 27 | 20 28 | ] 29 | separator: " · " 30 | icons: [] 31 | } 32 | cpu_load: { 33 | template: "{CL1} {CL5} {CL15}" 34 | update_interval: 20 35 | } 36 | network: { 37 | no_value: NA 38 | template: "{LocalIPv4} · {ESSID}" 39 | } 40 | time: { 41 | format: %Y-%m-%d %H:%M 42 | update_seconds: false 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/features/cpu_load/notifier.rs: -------------------------------------------------------------------------------- 1 | use crate::communication; 2 | use crate::error::Result; 3 | use crate::wrapper::channel; 4 | use crate::wrapper::thread; 5 | 6 | pub(super) struct Notifier { 7 | id: usize, 8 | sender: channel::Sender, 9 | update_interval: u64, 10 | } 11 | 12 | impl Notifier { 13 | pub(super) const fn new( 14 | id: usize, 15 | sender: channel::Sender, 16 | update_interval: u64, 17 | ) -> Self { 18 | Self { 19 | id, 20 | sender, 21 | update_interval, 22 | } 23 | } 24 | } 25 | 26 | impl thread::Runnable for Notifier { 27 | fn run(&self) -> Result<()> { 28 | loop { 29 | thread::sleep_secs(self.update_interval); 30 | 31 | communication::send_message(self.id, &self.sender)?; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/features/audio.rs: -------------------------------------------------------------------------------- 1 | use crate::communication; 2 | use crate::error::Result; 3 | use crate::feature; 4 | use crate::wrapper::channel; 5 | 6 | pub(crate) use self::config::ConfigEntry; 7 | use self::config::RenderConfig; 8 | use self::data::Data; 9 | use self::notifier::Notifier; 10 | use self::updater::Updater; 11 | 12 | mod config; 13 | mod data; 14 | mod notifier; 15 | mod updater; 16 | 17 | pub(super) const FEATURE_NAME: &str = "audio"; 18 | 19 | #[allow(clippy::unnecessary_wraps)] 20 | pub(super) fn create( 21 | id: usize, 22 | sender: &channel::Sender, 23 | settings: &ConfigEntry, 24 | ) -> Result> { 25 | let data = Data::new(settings.render.clone()); 26 | 27 | Ok(Box::new(feature::Composer::new( 28 | FEATURE_NAME, 29 | Notifier::new(id, sender.clone()), 30 | Updater::new(data, settings.clone()), 31 | ))) 32 | } 33 | -------------------------------------------------------------------------------- /src/wrapper/channel.rs: -------------------------------------------------------------------------------- 1 | use std::sync::mpsc; 2 | 3 | use crate::error::Result; 4 | use crate::error::WrapErrorExt; 5 | 6 | pub(crate) fn create() -> (Sender, Receiver) { 7 | let (tx, rx) = mpsc::channel(); 8 | 9 | (Sender { sender: tx }, Receiver { receiver: rx }) 10 | } 11 | 12 | pub(crate) struct Receiver { 13 | receiver: mpsc::Receiver, 14 | } 15 | 16 | impl Receiver { 17 | pub(crate) fn read_blocking(&self) -> Result { 18 | self.receiver 19 | .recv() 20 | .wrap_error("channel receiver", "read blocking failed") 21 | } 22 | } 23 | 24 | #[derive(Clone)] 25 | pub(crate) struct Sender { 26 | sender: mpsc::Sender, 27 | } 28 | 29 | impl Sender { 30 | pub(crate) fn send(&self, message: M) -> Result<()> { 31 | self.sender 32 | .send(message) 33 | .wrap_error("channel sender", "notify thread killed") 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/wrapper/libnotify.rs: -------------------------------------------------------------------------------- 1 | pub(crate) use libnotify::Urgency; 2 | 3 | use crate::error::Result; 4 | use crate::error::WrapErrorExt; 5 | 6 | const ERROR_NAME: &str = "libnotify"; 7 | 8 | pub(crate) struct LibNotify; 9 | 10 | impl LibNotify { 11 | pub(crate) fn init() -> Result { 12 | libnotify::init("dwm-status").wrap_error(ERROR_NAME, "init failed")?; 13 | 14 | Ok(Self {}) 15 | } 16 | 17 | pub(crate) fn send_notification( 18 | &self, 19 | summary: &str, 20 | body: Option<&str>, 21 | urgency: Urgency, 22 | ) -> Result<()> { 23 | let notification = libnotify::Notification::new(summary, body, None); 24 | notification.set_urgency(urgency); 25 | notification 26 | .show() 27 | .wrap_error(ERROR_NAME, "send notification failed") 28 | } 29 | } 30 | 31 | impl Drop for LibNotify { 32 | fn drop(&mut self) { 33 | libnotify::uninit(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { naersk-lib, pkgs, useGlobalAlsaUtils ? false }: 2 | 3 | let 4 | binPath = pkgs.lib.makeBinPath ( 5 | (with pkgs; [ 6 | coreutils # audio: stdbuf 7 | dnsutils # network: dig 8 | iproute2 # network: ip 9 | wirelesstools # network: iwgetid 10 | ]) 11 | ++ pkgs.lib.optional (!useGlobalAlsaUtils) pkgs.alsa-utils # audio: alsactl, amixer 12 | ); 13 | 14 | name = "dwm-status"; 15 | in 16 | 17 | naersk-lib.buildPackage { 18 | pname = name; 19 | 20 | src = builtins.filterSource 21 | (path: type: type != "directory" || baseNameOf path != "target") 22 | ./.; 23 | 24 | nativeBuildInputs = with pkgs; [ makeWrapper pkg-config ]; 25 | buildInputs = with pkgs; [ dbus gdk-pixbuf libnotify xorg.libX11 ]; 26 | 27 | postInstall = '' 28 | # run only when building the final package 29 | if [[ -x $out/bin/${name} ]]; then 30 | wrapProgram $out/bin/${name} --prefix "PATH" : "${binPath}" 31 | fi 32 | ''; 33 | } 34 | -------------------------------------------------------------------------------- /examples/default-settings/defaults.json: -------------------------------------------------------------------------------- 1 | { 2 | "debug": false, 3 | "order": [], 4 | "separator": " / ", 5 | "audio": { 6 | "control": "Master", 7 | "mute": "MUTE", 8 | "template": "S {VOL}%", 9 | "icons": [] 10 | }, 11 | "backlight": { 12 | "device": "intel_backlight", 13 | "template": "L {BL}%", 14 | "icons": [] 15 | }, 16 | "battery": { 17 | "charging": "▲", 18 | "discharging": "▼", 19 | "enable_notifier": true, 20 | "no_battery": "NO BATT", 21 | "notifier_critical": 10, 22 | "notifier_levels": [ 23 | 2, 24 | 5, 25 | 10, 26 | 15, 27 | 20 28 | ], 29 | "separator": " · ", 30 | "icons": [] 31 | }, 32 | "cpu_load": { 33 | "template": "{CL1} {CL5} {CL15}", 34 | "update_interval": 20 35 | }, 36 | "network": { 37 | "no_value": "NA", 38 | "template": "{LocalIPv4} · {ESSID}" 39 | }, 40 | "time": { 41 | "format": "%Y-%m-%d %H:%M", 42 | "update_seconds": false 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/features/backlight.rs: -------------------------------------------------------------------------------- 1 | use crate::communication; 2 | use crate::error::Result; 3 | use crate::feature; 4 | use crate::wrapper::channel; 5 | 6 | pub(crate) use self::config::ConfigEntry; 7 | use self::config::RenderConfig; 8 | use self::data::Data; 9 | use self::device::BacklightDevice; 10 | use self::notifier::Notifier; 11 | use self::updater::Updater; 12 | 13 | mod config; 14 | mod data; 15 | mod device; 16 | mod notifier; 17 | mod updater; 18 | 19 | pub(super) const FEATURE_NAME: &str = "backlight"; 20 | 21 | pub(super) fn create( 22 | id: usize, 23 | sender: &channel::Sender, 24 | settings: &ConfigEntry, 25 | ) -> Result> { 26 | let data = Data::new(settings.render.clone()); 27 | let device = BacklightDevice::init(settings)?; 28 | 29 | Ok(Box::new(feature::Composer::new( 30 | FEATURE_NAME, 31 | Notifier::new(id, sender.clone(), device.brightness_file()), 32 | Updater::new(data, device), 33 | ))) 34 | } 35 | -------------------------------------------------------------------------------- /src/features/network/notifier.rs: -------------------------------------------------------------------------------- 1 | use crate::communication; 2 | use crate::error::Result; 3 | use crate::wrapper::channel; 4 | use crate::wrapper::process; 5 | use crate::wrapper::thread; 6 | 7 | pub(super) struct Notifier { 8 | id: usize, 9 | sender: channel::Sender, 10 | } 11 | 12 | impl Notifier { 13 | pub(super) const fn new(id: usize, sender: channel::Sender) -> Self { 14 | Self { id, sender } 15 | } 16 | } 17 | 18 | impl thread::Runnable for Notifier { 19 | fn run(&self) -> Result<()> { 20 | let command = process::Command::new("ip", &["monitor", "address", "link", "route"]); 21 | 22 | command.listen_stdout(|| { 23 | // check 2 times for updates with a 2 seconds delay 24 | for _ in 0..2 { 25 | thread::sleep_secs(2); 26 | communication::send_message(self.id, &self.sender)?; 27 | } 28 | 29 | Ok(()) 30 | })?; 31 | 32 | Ok(()) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/features/battery.rs: -------------------------------------------------------------------------------- 1 | use crate::communication; 2 | use crate::error::Result; 3 | use crate::feature; 4 | use crate::wrapper::channel; 5 | 6 | pub(crate) use self::config::ConfigEntry; 7 | use self::config::NotifierConfig; 8 | use self::config::RenderConfig; 9 | use self::data::Data; 10 | use self::dbus::DbusWatcher; 11 | use self::notifier::BatteryNotifier; 12 | use self::updater::Updater; 13 | 14 | mod config; 15 | mod data; 16 | mod dbus; 17 | mod notifier; 18 | mod updater; 19 | 20 | pub(super) const FEATURE_NAME: &str = "battery"; 21 | 22 | pub(super) fn create( 23 | id: usize, 24 | sender: &channel::Sender, 25 | settings: &ConfigEntry, 26 | ) -> Result> { 27 | let data = Data::new(settings.render.clone()); 28 | let notifier = BatteryNotifier::init(settings.notifier.clone())?; 29 | 30 | Ok(Box::new(feature::Composer::new( 31 | FEATURE_NAME, 32 | DbusWatcher::new(id, sender.clone()), 33 | Updater::new(data, notifier), 34 | ))) 35 | } 36 | -------------------------------------------------------------------------------- /src/features/time/notifier.rs: -------------------------------------------------------------------------------- 1 | use chrono::Timelike; 2 | 3 | use crate::communication; 4 | use crate::error::Result; 5 | use crate::wrapper::channel; 6 | use crate::wrapper::thread; 7 | 8 | pub(super) struct Notifier { 9 | id: usize, 10 | sender: channel::Sender, 11 | update_seconds: bool, 12 | } 13 | 14 | impl Notifier { 15 | pub(super) const fn new( 16 | id: usize, 17 | sender: channel::Sender, 18 | update_seconds: bool, 19 | ) -> Self { 20 | Self { 21 | id, 22 | sender, 23 | update_seconds, 24 | } 25 | } 26 | } 27 | 28 | impl thread::Runnable for Notifier { 29 | fn run(&self) -> Result<()> { 30 | loop { 31 | let update_interval = if self.update_seconds { 32 | 1 33 | } else { 34 | 60 - u64::from(chrono::Local::now().second()) 35 | }; 36 | 37 | thread::sleep_secs(update_interval); 38 | 39 | communication::send_message(self.id, &self.sender)?; 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/features/backlight/notifier.rs: -------------------------------------------------------------------------------- 1 | use crate::communication; 2 | use crate::error::Result; 3 | use crate::wrapper::channel; 4 | use crate::wrapper::inotify; 5 | use crate::wrapper::thread; 6 | 7 | pub(super) struct Notifier { 8 | id: usize, 9 | sender: channel::Sender, 10 | brightness_file: String, 11 | } 12 | 13 | impl Notifier { 14 | pub(super) const fn new( 15 | id: usize, 16 | sender: channel::Sender, 17 | brightness_file: String, 18 | ) -> Self { 19 | Self { 20 | id, 21 | sender, 22 | brightness_file, 23 | } 24 | } 25 | } 26 | 27 | impl thread::Runnable for Notifier { 28 | fn run(&self) -> Result<()> { 29 | let mut inotify = inotify::Inotify::init()?; 30 | 31 | inotify.add_watch(&self.brightness_file, inotify::WatchMask::MODIFY)?; 32 | 33 | inotify.listen_for_any_events(|| { 34 | communication::send_message(self.id, &self.sender)?; 35 | 36 | thread::sleep_prevent_spam(); 37 | 38 | Ok(()) 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Tobias Happ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/features/network.rs: -------------------------------------------------------------------------------- 1 | use crate::communication; 2 | use crate::error::Result; 3 | use crate::feature; 4 | use crate::wrapper::channel; 5 | 6 | pub(crate) use self::config::ConfigEntry; 7 | use self::config::RenderConfig; 8 | use self::config::UpdateConfig; 9 | use self::data::Data; 10 | use self::notifier::Notifier; 11 | use self::updater::Updater; 12 | 13 | mod config; 14 | mod data; 15 | mod notifier; 16 | mod updater; 17 | 18 | pub(super) const FEATURE_NAME: &str = "network"; 19 | const PLACEHOLDER_ESSID: &str = "{ESSID}"; 20 | const PLACEHOLDER_IPV4: &str = "{IPv4}"; 21 | const PLACEHOLDER_IPV6: &str = "{IPv6}"; 22 | const PLACEHOLDER_LOCAL_IPV4: &str = "{LocalIPv4}"; 23 | const PLACEHOLDER_LOCAL_IPV6: &str = "{LocalIPv6}"; 24 | 25 | #[allow(clippy::unnecessary_wraps)] 26 | pub(super) fn create( 27 | id: usize, 28 | sender: &channel::Sender, 29 | settings: &ConfigEntry, 30 | ) -> Result> { 31 | let data = Data::new(settings.render.clone()); 32 | 33 | Ok(Box::new(feature::Composer::new( 34 | FEATURE_NAME, 35 | Notifier::new(id, sender.clone()), 36 | Updater::new(data, settings.update.clone()), 37 | ))) 38 | } 39 | -------------------------------------------------------------------------------- /src/wrapper/inotify.rs: -------------------------------------------------------------------------------- 1 | pub(crate) use inotify::WatchMask; 2 | 3 | use crate::error::Result; 4 | use crate::error::WrapErrorExt; 5 | 6 | const ERROR_NAME: &str = "inotify"; 7 | 8 | pub(crate) struct Inotify { 9 | inotify: inotify::Inotify, 10 | } 11 | 12 | impl Inotify { 13 | pub(crate) fn init() -> Result { 14 | let inotify = inotify::Inotify::init().wrap_error(ERROR_NAME, "failed to start inotify")?; 15 | 16 | Ok(Self { inotify }) 17 | } 18 | 19 | pub(crate) fn add_watch(&self, path: &str, mask: WatchMask) -> Result<()> { 20 | self.inotify 21 | .watches() 22 | .add(path, mask) 23 | .wrap_error(ERROR_NAME, format!("failed to watch '{}'", path))?; 24 | 25 | Ok(()) 26 | } 27 | 28 | pub(crate) fn listen_for_any_events(&mut self, handler: F) -> Result<()> 29 | where 30 | F: Fn() -> Result<()>, 31 | { 32 | let mut buffer = [0; 1024]; 33 | loop { 34 | self.inotify 35 | .read_events_blocking(&mut buffer) 36 | .wrap_error(ERROR_NAME, "error while reading inotify events")?; 37 | 38 | handler()?; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/wrapper/thread.rs: -------------------------------------------------------------------------------- 1 | use std::thread; 2 | use std::time; 3 | 4 | use crate::error::Result; 5 | use crate::error::ResultExt; 6 | use crate::error::WrapErrorExt; 7 | 8 | pub(crate) trait Runnable: Send + 'static { 9 | fn run(&self) -> Result<()>; 10 | } 11 | 12 | pub(crate) struct Thread { 13 | name: &'static str, 14 | runnable: R, 15 | } 16 | 17 | impl Thread 18 | where 19 | R: Runnable, 20 | { 21 | #[allow(clippy::missing_const_for_fn)] // not supported by stable 22 | pub(crate) fn new(name: &'static str, runnable: R) -> Self { 23 | Self { name, runnable } 24 | } 25 | 26 | pub(crate) fn run(self) -> Result<()> { 27 | thread::Builder::new() 28 | .name(self.name.to_owned()) 29 | .spawn(move || loop { 30 | self.runnable.run().show_error_and_ignore(); 31 | sleep_secs(2); 32 | }) 33 | .wrap_error("thread start", "failed to create thread")?; 34 | 35 | Ok(()) 36 | } 37 | } 38 | 39 | pub(crate) fn sleep_secs(seconds: u64) { 40 | thread::sleep(time::Duration::from_secs(seconds)); 41 | } 42 | 43 | pub(crate) fn sleep_prevent_spam() { 44 | thread::sleep(time::Duration::from_millis(100)); 45 | } 46 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["Tobias Happ "] 3 | description = "Displays system information for dwm status bar." 4 | keywords = ["dwm", "dwm-status"] 5 | license = "MIT" 6 | name = "dwm-status" 7 | readme = "README.md" 8 | repository = "https://github.com/Gerschtli/dwm-status" 9 | version = "1.11.0" 10 | edition = "2021" 11 | [badges.travis-ci] 12 | branch = "master" 13 | repository = "Gerschtli/dwm-status" 14 | 15 | [dependencies] 16 | battery = "0.7.8" 17 | chrono = "0.4.42" 18 | clap = { version = "4.5.48", features = [ "derive" ] } 19 | config = "0.11.0" 20 | dbus = "0.8.4" 21 | inotify = "0.11.0" 22 | libnotify = "1.0.3" 23 | log = "0.4.28" 24 | serde = "1.0.228" 25 | serde_derive = "1.0.228" 26 | simplelog = "0.12.2" 27 | uom = { version = "0.30.0", features = ["autoconvert", "f32", "si"] } 28 | glob = "0.3.3" 29 | serde_json = "1.0.145" 30 | regex = "1.11.3" 31 | evalexpr = "13.0.0" 32 | 33 | [dependencies.ctrlc] 34 | features = ["termination"] 35 | version = "3.5.0" 36 | 37 | [dependencies.mocktopus] 38 | optional = true 39 | version = "0.8.0" 40 | 41 | [dependencies.x11] 42 | features = ["xlib"] 43 | version = "2.21.0" 44 | 45 | [dev-dependencies] 46 | hamcrest2 = "0.3.0" 47 | 48 | [features] 49 | mocking = ["mocktopus"] 50 | -------------------------------------------------------------------------------- /src/features.rs: -------------------------------------------------------------------------------- 1 | use crate::communication; 2 | use crate::error::Error; 3 | use crate::error::Result; 4 | use crate::feature; 5 | use crate::settings; 6 | use crate::wrapper::channel; 7 | 8 | pub(super) mod audio; 9 | pub(super) mod backlight; 10 | pub(super) mod battery; 11 | pub(super) mod cpu_load; 12 | pub(super) mod network; 13 | pub(super) mod time; 14 | 15 | macro_rules! features { 16 | ( $id:expr, $name:expr, $sender:expr, $settings:expr; $( $mod:ident, )* ) => { 17 | match &$name.to_lowercase()[..] { 18 | $( 19 | $mod::FEATURE_NAME => $mod::create($id, $sender, &$settings.$mod), 20 | )* 21 | _ => Err(Error::new_custom( 22 | "create feature", 23 | format!("feature {} does not exist", $name), 24 | )), 25 | } 26 | } 27 | } 28 | 29 | pub(super) fn create_feature( 30 | id: usize, 31 | name: &str, 32 | sender: &channel::Sender, 33 | settings: &settings::Settings, 34 | ) -> Result> { 35 | features!(id, name, sender, settings; 36 | audio, 37 | backlight, 38 | battery, 39 | cpu_load, 40 | network, 41 | time, 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "naersk": { 4 | "inputs": { 5 | "nixpkgs": [ 6 | "nixpkgs" 7 | ] 8 | }, 9 | "locked": { 10 | "lastModified": 1745925850, 11 | "narHash": "sha256-cyAAMal0aPrlb1NgzMxZqeN1mAJ2pJseDhm2m6Um8T0=", 12 | "owner": "nmattia", 13 | "repo": "naersk", 14 | "rev": "38bc60bbc157ae266d4a0c96671c6c742ee17a5f", 15 | "type": "github" 16 | }, 17 | "original": { 18 | "owner": "nmattia", 19 | "repo": "naersk", 20 | "type": "github" 21 | } 22 | }, 23 | "nixpkgs": { 24 | "locked": { 25 | "lastModified": 1748248602, 26 | "narHash": "sha256-LanRAm0IRpL36KpCKSknEwkBFvTLc9mDHKeAmfTrHwg=", 27 | "owner": "NixOS", 28 | "repo": "nixpkgs", 29 | "rev": "ad331efcaf680eb1c838cb339472399ea7b3cdab", 30 | "type": "github" 31 | }, 32 | "original": { 33 | "owner": "NixOS", 34 | "ref": "nixpkgs-unstable", 35 | "repo": "nixpkgs", 36 | "type": "github" 37 | } 38 | }, 39 | "root": { 40 | "inputs": { 41 | "naersk": "naersk", 42 | "nixpkgs": "nixpkgs" 43 | } 44 | } 45 | }, 46 | "root": "root", 47 | "version": 7 48 | } 49 | -------------------------------------------------------------------------------- /src/resume.rs: -------------------------------------------------------------------------------- 1 | use crate::communication; 2 | use crate::error::Result; 3 | use crate::wrapper::channel; 4 | use crate::wrapper::dbus; 5 | use crate::wrapper::thread; 6 | 7 | const ERROR_NAME: &str = "resume watcher"; 8 | const INTERFACE_LOGIN1: &str = "org.freedesktop.login1.Manager"; 9 | const MEMBER_PREPARE_FOR_SLEEP: &str = "PrepareForSleep"; 10 | const PATH_LOGIN1: &str = "/org/freedesktop/login1"; 11 | 12 | pub(super) fn init_resume_notifier(sender: &channel::Sender) -> Result<()> { 13 | let notifier = Notifier { 14 | sender: sender.clone(), 15 | }; 16 | 17 | thread::Thread::new(ERROR_NAME, notifier).run() 18 | } 19 | 20 | struct Notifier { 21 | sender: channel::Sender, 22 | } 23 | 24 | impl thread::Runnable for Notifier { 25 | fn run(&self) -> Result<()> { 26 | let connection = dbus::Connection::init()?; 27 | 28 | connection.add_match(dbus::Match::new( 29 | INTERFACE_LOGIN1, 30 | MEMBER_PREPARE_FOR_SLEEP, 31 | PATH_LOGIN1, 32 | ))?; 33 | 34 | connection.listen_for_signals(|signal| { 35 | // return value is true if going to sleep, false if waking up 36 | if signal.is_interface(INTERFACE_LOGIN1)? && !signal.return_value::()? { 37 | self.sender.send(communication::Message::UpdateAll)?; 38 | } 39 | 40 | Ok(()) 41 | }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/wrapper/xsetroot.rs: -------------------------------------------------------------------------------- 1 | #![allow(unsafe_code)] 2 | 3 | use std::ffi::CString; 4 | use std::ptr; 5 | 6 | use x11::xlib; 7 | 8 | use crate::error::Error; 9 | use crate::error::Result; 10 | use crate::error::WrapErrorExt; 11 | 12 | pub(crate) struct XSetRoot { 13 | display: *mut xlib::Display, 14 | root_window: xlib::Window, 15 | } 16 | 17 | impl XSetRoot { 18 | pub(crate) fn init() -> Result { 19 | unsafe { 20 | let display = xlib::XOpenDisplay(ptr::null()); 21 | 22 | if display.is_null() { 23 | return Err(Error::new_custom("render", "cannot open display")); 24 | } 25 | 26 | let screen = xlib::XDefaultScreen(display); 27 | let root_window = xlib::XRootWindow(display, screen); 28 | 29 | Ok(Self { 30 | display, 31 | root_window, 32 | }) 33 | } 34 | } 35 | 36 | pub(crate) fn render(&self, text: String) -> Result<()> { 37 | let status_c = CString::new(text) 38 | .wrap_error("render", "status text could not be converted to CString")?; 39 | 40 | unsafe { 41 | xlib::XStoreName(self.display, self.root_window, status_c.as_ptr().cast_mut()); 42 | 43 | xlib::XFlush(self.display); 44 | } 45 | 46 | Ok(()) 47 | } 48 | } 49 | 50 | impl Drop for XSetRoot { 51 | fn drop(&mut self) { 52 | unsafe { 53 | xlib::XCloseDisplay(self.display); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/test_utils/config.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use hamcrest2::assert_that; 4 | use hamcrest2::prelude::*; 5 | use mocktopus::mocking::*; 6 | 7 | use crate::error::Error; 8 | use crate::error::Result; 9 | use crate::settings; 10 | use crate::wrapper::config; 11 | use crate::wrapper::config::Value; 12 | 13 | pub(crate) fn test_set_default_ok( 14 | name: &'static str, 15 | default_map_builder: fn() -> HashMap, 16 | ) { 17 | test_set_default::(name, default_map_builder, Ok(())); 18 | } 19 | 20 | pub(crate) fn test_set_default_err( 21 | name: &'static str, 22 | default_map_builder: fn() -> HashMap, 23 | ) { 24 | test_set_default::( 25 | name, 26 | default_map_builder, 27 | Err(Error::new_custom("name", "description")), 28 | ); 29 | } 30 | 31 | fn test_set_default( 32 | name: &'static str, 33 | default_map_builder: fn() -> HashMap, 34 | result: Result<()>, 35 | ) { 36 | let result_ = result.clone(); 37 | 38 | config::Config::set_default.mock_safe(move |_, key, value: HashMap| { 39 | assert_that!(key, is(equal_to(name))); 40 | assert_that!(value, is(equal_to(default_map_builder()))); 41 | 42 | MockResult::Return(result_.clone()) 43 | }); 44 | 45 | let mut config = config::Config::new(); 46 | 47 | assert_that!(T::set_default(&mut config), is(equal_to(result))); 48 | } 49 | -------------------------------------------------------------------------------- /src/features/time/data.rs: -------------------------------------------------------------------------------- 1 | use crate::feature::Renderable; 2 | use crate::wrapper::date_time; 3 | 4 | #[derive(Debug)] 5 | pub(super) struct Data { 6 | cache: String, 7 | format: String, 8 | } 9 | 10 | impl Data { 11 | pub(super) const fn new(format: String) -> Self { 12 | Self { 13 | cache: String::new(), 14 | format, 15 | } 16 | } 17 | 18 | pub(super) fn update(&mut self, date_time: &date_time::DateTime) { 19 | self.cache = date_time.format(&self.format); 20 | } 21 | } 22 | 23 | impl Renderable for Data { 24 | fn render(&self) -> &str { 25 | &self.cache 26 | } 27 | } 28 | 29 | #[cfg(test)] 30 | mod tests { 31 | use hamcrest2::assert_that; 32 | use hamcrest2::prelude::*; 33 | #[cfg(feature = "mocking")] 34 | use mocktopus::mocking::*; 35 | 36 | use super::*; 37 | 38 | #[test] 39 | fn render_with_default() { 40 | let object = Data::new("format".to_owned()); 41 | 42 | assert_that!(object.render(), is(equal_to(""))); 43 | } 44 | 45 | #[cfg(feature = "mocking")] 46 | #[test] 47 | fn render_with_update() { 48 | let mut object = Data::new("format".to_owned()); 49 | 50 | date_time::DateTime::format.mock_safe(|_, format| { 51 | assert_that!(format, is(equal_to("format"))); 52 | 53 | MockResult::Return("formatted date time".to_owned()) 54 | }); 55 | 56 | object.update(&date_time::DateTime::now()); 57 | 58 | assert_that!(object.render(), is(equal_to("formatted date time"))); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/features/cpu_load/config.rs: -------------------------------------------------------------------------------- 1 | use serde_derive::Deserialize; 2 | 3 | use crate::error::Result; 4 | use crate::settings::ConfigType; 5 | use crate::wrapper::config; 6 | use crate::wrapper::config::Value; 7 | 8 | use super::FEATURE_NAME; 9 | 10 | #[derive(Clone, Debug, Deserialize)] 11 | pub(crate) struct ConfigEntry { 12 | pub(super) template: String, 13 | pub(super) update_interval: u64, 14 | } 15 | 16 | impl ConfigType for ConfigEntry { 17 | fn set_default(config: &mut config::Config) -> Result<()> { 18 | config.set_default( 19 | FEATURE_NAME, 20 | map!( 21 | "template" => "{CL1} {CL5} {CL15}", 22 | "update_interval" => 20, 23 | ), 24 | ) 25 | } 26 | } 27 | 28 | #[cfg(test)] 29 | #[cfg(feature = "mocking")] 30 | mod tests { 31 | use std::collections::HashMap; 32 | 33 | use crate::test_utils::config::test_set_default_err; 34 | use crate::test_utils::config::test_set_default_ok; 35 | 36 | use super::*; 37 | 38 | #[test] 39 | fn config_type_set_default_when_ok() { 40 | test_set_default_ok::("cpu_load", default_map); 41 | } 42 | 43 | #[test] 44 | fn config_type_set_default_when_err() { 45 | test_set_default_err::("cpu_load", default_map); 46 | } 47 | 48 | fn default_map() -> HashMap { 49 | let mut map = HashMap::new(); 50 | map.insert("template".to_owned(), "{CL1} {CL5} {CL15}".into()); 51 | map.insert("update_interval".to_owned(), 20.into()); 52 | 53 | map 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![deny( 2 | anonymous_parameters, 3 | bare_trait_objects, 4 | clippy::all, 5 | clippy::complexity, 6 | clippy::correctness, 7 | clippy::nursery, 8 | clippy::pedantic, 9 | clippy::perf, 10 | clippy::style, 11 | elided_lifetimes_in_paths, 12 | missing_copy_implementations, 13 | missing_debug_implementations, 14 | single_use_lifetimes, 15 | trivial_casts, 16 | trivial_numeric_casts, 17 | unreachable_pub, 18 | unsafe_code, 19 | unused_import_braces, 20 | unused_qualifications, 21 | variant_size_differences 22 | )] 23 | #![allow( 24 | clippy::missing_errors_doc, 25 | clippy::non_ascii_literal, 26 | clippy::redundant_pub_crate, 27 | clippy::unused_self, 28 | clippy::wildcard_imports 29 | )] 30 | 31 | use std::process; 32 | 33 | use clap::*; 34 | use simplelog::Config; 35 | use simplelog::LevelFilter; 36 | use simplelog::SimpleLogger; 37 | 38 | #[derive(Parser, Debug)] 39 | #[command(version, about)] 40 | struct Args { 41 | /// Path to config file 42 | config_file: String, 43 | 44 | /// Quiet mode (disables INFO logs) 45 | #[arg(short, long, default_value_t = false)] 46 | quiet: bool, 47 | } 48 | 49 | fn main() { 50 | let args = Args::parse(); 51 | 52 | let log_level = if args.quiet { 53 | LevelFilter::Warn 54 | } else { 55 | LevelFilter::Info 56 | }; 57 | 58 | _ = SimpleLogger::init(log_level, Config::default()); 59 | 60 | if let Err(error) = dwm_status::run(&args.config_file) { 61 | error.show_error(); 62 | process::exit(1); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Highly performant and configurable DWM status service"; 3 | 4 | inputs = { 5 | naersk = { 6 | url = "github:nmattia/naersk"; 7 | inputs.nixpkgs.follows = "nixpkgs"; 8 | }; 9 | 10 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 11 | }; 12 | 13 | outputs = { self, naersk, nixpkgs }: 14 | let 15 | system = "x86_64-linux"; 16 | naersk-lib = naersk.lib.${system}; 17 | pkgs = nixpkgs.legacyPackages.${system}; 18 | 19 | package = import ./. { 20 | inherit naersk-lib pkgs; 21 | }; 22 | 23 | name = package.pname; 24 | nameWithGlobalAlsaUtils = "${name}-global-alsa-utils"; 25 | 26 | app = { 27 | type = "app"; 28 | program = "${package}/bin/${name}"; 29 | }; 30 | in 31 | { 32 | packages.${system} = { 33 | default = package; 34 | ${name} = package; 35 | ${nameWithGlobalAlsaUtils} = import ./. { 36 | inherit naersk-lib pkgs; 37 | useGlobalAlsaUtils = true; 38 | }; 39 | }; 40 | 41 | apps.${system} = { 42 | default = app; 43 | ${name} = app; 44 | }; 45 | 46 | overlays.default = final: prev: 47 | let 48 | args = { 49 | naersk-lib = (naersk.overlay final prev).naersk; 50 | pkgs = prev; 51 | }; 52 | in 53 | { 54 | ${name} = import ./. args; 55 | ${nameWithGlobalAlsaUtils} = import ./. (args // { useGlobalAlsaUtils = true; }); 56 | }; 57 | 58 | devShells.${system}.default = import ./shell.nix { inherit pkgs; }; 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /src/features/cpu_load/updater.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::error::WrapErrorExt; 3 | use crate::feature; 4 | use crate::wrapper::file; 5 | 6 | use super::Data; 7 | use super::FEATURE_NAME; 8 | 9 | use regex::Regex; 10 | 11 | const PATH_LOADAVG: &str = "/proc/loadavg"; 12 | const PATH_NPROC: &str = "/proc/cpuinfo"; 13 | 14 | const NPROC_REGEX: &str = r"processor\s+: \d+"; 15 | 16 | pub(super) struct Updater { 17 | data: Data, 18 | } 19 | 20 | impl Updater { 21 | pub(super) const fn new(data: Data) -> Self { 22 | Self { data } 23 | } 24 | } 25 | 26 | impl feature::Updatable for Updater { 27 | fn renderable(&self) -> &dyn feature::Renderable { 28 | &self.data 29 | } 30 | 31 | fn update(&mut self) -> Result<()> { 32 | let content = file::read(PATH_LOADAVG) 33 | .wrap_error(FEATURE_NAME, format!("failed to read {}", PATH_LOADAVG))?; 34 | 35 | let mut iterator = content.split_whitespace(); 36 | 37 | let one = convert_to_float(iterator.next())?; 38 | let five = convert_to_float(iterator.next())?; 39 | let fifteen = convert_to_float(iterator.next())?; 40 | 41 | let nproc_content = file::read(PATH_NPROC) 42 | .wrap_error(FEATURE_NAME, format!("failed to read {}", PATH_NPROC))?; 43 | 44 | let re = Regex::new(NPROC_REGEX).unwrap(); 45 | let nproc = u32::try_from(re.find_iter(&nproc_content).count()).unwrap(); 46 | 47 | self.data.update(one, five, fifteen, nproc); 48 | 49 | Ok(()) 50 | } 51 | } 52 | 53 | fn convert_to_float(data: Option<&str>) -> Result { 54 | data.wrap_error(FEATURE_NAME, "no data found")? 55 | .parse() 56 | .wrap_error(FEATURE_NAME, "could not convert to float") 57 | } 58 | -------------------------------------------------------------------------------- /src/wrapper/dbus/data.rs: -------------------------------------------------------------------------------- 1 | pub(crate) struct Match<'a> { 2 | interface: &'static str, 3 | member: Option<&'static str>, 4 | path: &'a str, 5 | } 6 | 7 | impl<'a> Match<'a> { 8 | pub(crate) fn new>>( 9 | interface: &'static str, 10 | member: M, 11 | path: &'a str, 12 | ) -> Self { 13 | Self { 14 | interface, 15 | member: member.into(), 16 | path, 17 | } 18 | } 19 | 20 | pub(crate) fn build(self) -> String { 21 | let member = self 22 | .member 23 | .map_or_else(String::new, |m| format!(",member='{}'", m)); 24 | 25 | format!( 26 | "type='signal',path='{}',interface='{}'{}", 27 | self.path, self.interface, member 28 | ) 29 | } 30 | } 31 | 32 | #[cfg(test)] 33 | mod tests { 34 | use super::*; 35 | 36 | #[test] 37 | fn match_build() { 38 | assert_eq!( 39 | Match::new( 40 | "org.freedesktop.DBus.Properties", 41 | "DeviceAdded", 42 | "/org/freedesktop/UPower", 43 | ) 44 | .build(), 45 | "type='signal',path='/org/freedesktop/UPower',interface='org.freedesktop.DBus.\ 46 | Properties',member='DeviceAdded'" 47 | ); 48 | } 49 | 50 | #[test] 51 | fn match_build_without_member() { 52 | assert_eq!( 53 | Match::new( 54 | "org.freedesktop.UPower", 55 | None, 56 | "/org/freedesktop/UPower/devices/battery_BAT0", 57 | ) 58 | .build(), 59 | "type='signal',path='/org/freedesktop/UPower/devices/battery_BAT0',interface='org.\ 60 | freedesktop.UPower'" 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/wrapper/dbus/message.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::error::WrapErrorExt; 3 | 4 | use super::ERROR_NAME; 5 | 6 | macro_rules! compare_property { 7 | ( $method:ident, $property:ident ) => { 8 | pub(crate) fn $method(&self, compare: &'static str) -> Result { 9 | Ok(if let Some(interface) = self.message.$property() { 10 | interface.as_cstr() 11 | == std::ffi::CString::new(compare) 12 | .wrap_error(ERROR_NAME, "failed to create CString")? 13 | .as_c_str() 14 | } else { 15 | false 16 | }) 17 | } 18 | }; 19 | } 20 | 21 | pub(crate) struct Message { 22 | message: dbus::Message, 23 | } 24 | 25 | impl Message { 26 | compare_property!(is_interface, interface); 27 | 28 | compare_property!(is_member, member); 29 | 30 | pub(crate) const fn new(message: dbus::Message) -> Self { 31 | Self { message } 32 | } 33 | 34 | pub(crate) fn init_method_call( 35 | bus: &'static str, 36 | path: &'_ str, 37 | interface: &'static str, 38 | member: &'static str, 39 | ) -> Result { 40 | Ok(Self { 41 | message: dbus::Message::new_method_call(bus, path, interface, member) 42 | .wrap_error(ERROR_NAME, "failed to create dbus method call message")?, 43 | }) 44 | } 45 | 46 | #[allow(clippy::missing_const_for_fn)] 47 | pub(super) fn raw(self) -> dbus::Message { 48 | self.message 49 | } 50 | 51 | pub(crate) fn return_value<'a, T>(&'a self) -> Result 52 | where 53 | T: dbus::arg::Arg + dbus::arg::Get<'a>, 54 | { 55 | self.message 56 | .read1::() 57 | .wrap_error(ERROR_NAME, "failed to read return value of dbus message") 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/feature.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use crate::error::Result; 3 | use crate::wrapper::thread; 4 | 5 | pub(crate) trait Renderable { 6 | fn render(&self) -> &str; 7 | } 8 | 9 | pub(crate) trait Updatable { 10 | fn renderable(&self) -> &dyn Renderable; 11 | 12 | fn update(&mut self) -> Result<()>; 13 | } 14 | 15 | pub(crate) trait Feature: Updatable { 16 | fn init_notifier(&mut self) -> Result<()>; 17 | 18 | fn name(&self) -> &'static str; 19 | } 20 | 21 | pub(crate) struct Composer 22 | where 23 | N: thread::Runnable, 24 | U: Updatable, 25 | { 26 | name: &'static str, 27 | notifier: Option, 28 | updater: U, 29 | } 30 | 31 | impl Composer 32 | where 33 | N: thread::Runnable, 34 | U: Updatable, 35 | { 36 | #[allow(clippy::missing_const_for_fn)] // not supported by stable 37 | pub(crate) fn new(name: &'static str, notifier: N, updater: U) -> Self { 38 | Self { 39 | name, 40 | notifier: Some(notifier), 41 | updater, 42 | } 43 | } 44 | } 45 | 46 | impl Feature for Composer 47 | where 48 | N: thread::Runnable, 49 | U: Updatable, 50 | { 51 | fn init_notifier(&mut self) -> Result<()> { 52 | self.notifier.take().map_or_else( 53 | || Err(Error::new_custom("feature", "can not start notifier twice")), 54 | |notifier| thread::Thread::new(self.name, notifier).run(), 55 | ) 56 | } 57 | 58 | fn name(&self) -> &'static str { 59 | self.name 60 | } 61 | } 62 | 63 | impl Updatable for Composer 64 | where 65 | N: thread::Runnable, 66 | U: Updatable, 67 | { 68 | fn renderable(&self) -> &dyn Renderable { 69 | self.updater.renderable() 70 | } 71 | 72 | fn update(&mut self) -> Result<()> { 73 | self.updater.update() 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/features/backlight/config.rs: -------------------------------------------------------------------------------- 1 | use serde_derive::Deserialize; 2 | 3 | use crate::error::Result; 4 | use crate::settings::ConfigType; 5 | use crate::wrapper::config; 6 | use crate::wrapper::config::Value; 7 | 8 | use super::FEATURE_NAME; 9 | 10 | #[derive(Clone, Debug, Deserialize)] 11 | pub(crate) struct RenderConfig { 12 | pub(super) icons: Vec, 13 | pub(super) template: String, 14 | } 15 | 16 | #[derive(Clone, Debug, Deserialize)] 17 | pub(crate) struct ConfigEntry { 18 | pub(super) device: String, 19 | pub(super) fallback: Option, 20 | #[serde(flatten)] 21 | pub(super) render: RenderConfig, 22 | } 23 | 24 | impl ConfigType for ConfigEntry { 25 | fn set_default(config: &mut config::Config) -> Result<()> { 26 | config.set_default( 27 | FEATURE_NAME, 28 | map!( 29 | "device" => "intel_backlight", 30 | "icons" => Vec::::new(), 31 | "template" => "L {BL}%", 32 | ), 33 | ) 34 | } 35 | } 36 | 37 | #[cfg(test)] 38 | #[cfg(feature = "mocking")] 39 | mod tests { 40 | use std::collections::HashMap; 41 | 42 | use crate::test_utils::config::test_set_default_err; 43 | use crate::test_utils::config::test_set_default_ok; 44 | 45 | use super::*; 46 | 47 | #[test] 48 | fn config_type_set_default_when_ok() { 49 | test_set_default_ok::("backlight", default_map); 50 | } 51 | 52 | #[test] 53 | fn config_type_set_default_when_err() { 54 | test_set_default_err::("backlight", default_map); 55 | } 56 | 57 | fn default_map() -> HashMap { 58 | let mut map = HashMap::new(); 59 | map.insert("device".to_owned(), "intel_backlight".into()); 60 | map.insert("icons".to_owned(), Vec::::new().into()); 61 | map.insert("template".to_owned(), "L {BL}%".into()); 62 | 63 | map 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/features/audio/config.rs: -------------------------------------------------------------------------------- 1 | use serde_derive::Deserialize; 2 | 3 | use crate::error::Result; 4 | use crate::settings::ConfigType; 5 | use crate::wrapper::config; 6 | use crate::wrapper::config::Value; 7 | 8 | use super::FEATURE_NAME; 9 | 10 | #[derive(Clone, Debug, Deserialize)] 11 | pub(crate) struct RenderConfig { 12 | pub(super) icons: Vec, 13 | pub(super) mute: String, 14 | pub(super) template: String, 15 | } 16 | 17 | #[derive(Clone, Debug, Deserialize)] 18 | pub(crate) struct ConfigEntry { 19 | pub(super) control: String, 20 | #[serde(flatten)] 21 | pub(super) render: RenderConfig, 22 | } 23 | 24 | impl ConfigType for ConfigEntry { 25 | fn set_default(config: &mut config::Config) -> Result<()> { 26 | config.set_default( 27 | FEATURE_NAME, 28 | map!( 29 | "control" => "Master", 30 | "icons" => Vec::::new(), 31 | "mute" => "MUTE", 32 | "template" => "S {VOL}%", 33 | ), 34 | ) 35 | } 36 | } 37 | 38 | #[cfg(test)] 39 | #[cfg(feature = "mocking")] 40 | mod tests { 41 | use std::collections::HashMap; 42 | 43 | use crate::test_utils::config::test_set_default_err; 44 | use crate::test_utils::config::test_set_default_ok; 45 | 46 | use super::*; 47 | 48 | #[test] 49 | fn config_type_set_default_when_ok() { 50 | test_set_default_ok::("audio", default_map); 51 | } 52 | 53 | #[test] 54 | fn config_type_set_default_when_err() { 55 | test_set_default_err::("audio", default_map); 56 | } 57 | 58 | fn default_map() -> HashMap { 59 | let mut map = HashMap::new(); 60 | map.insert("control".to_owned(), "Master".into()); 61 | map.insert("icons".to_owned(), Vec::::new().into()); 62 | map.insert("mute".to_owned(), "MUTE".into()); 63 | map.insert("template".to_owned(), "S {VOL}%".into()); 64 | 65 | map 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/wrapper/config.rs: -------------------------------------------------------------------------------- 1 | pub(crate) use config::Value; 2 | use serde::Deserialize; 3 | 4 | use crate::error::Result; 5 | use crate::error::WrapErrorExt; 6 | 7 | const ERROR_NAME: &str = "config"; 8 | 9 | pub(crate) struct Config { 10 | config: config::Config, 11 | } 12 | 13 | #[cfg_attr(all(test, feature = "mocking"), mocktopus::macros::mockable)] 14 | impl Config { 15 | pub(crate) fn new() -> Self { 16 | Self { 17 | config: config::Config::new(), 18 | } 19 | } 20 | 21 | pub(crate) fn set(&mut self, key: &str, value: T) -> Result<()> 22 | where 23 | T: Into, 24 | { 25 | self.config 26 | .set(key, value) 27 | .wrap_error(ERROR_NAME, "set value failed")?; 28 | 29 | Ok(()) 30 | } 31 | 32 | pub(crate) fn set_default(&mut self, key: &str, value: T) -> Result<()> 33 | where 34 | T: Into, 35 | { 36 | self.config 37 | .set_default(key, value) 38 | .wrap_error(ERROR_NAME, "set default failed")?; 39 | 40 | Ok(()) 41 | } 42 | 43 | pub(crate) fn set_path(&mut self, path: &str) -> Result<()> { 44 | self.config 45 | .merge(config::File::with_name(path)) 46 | .wrap_error(ERROR_NAME, "merge config file failed")?; 47 | 48 | Ok(()) 49 | } 50 | 51 | pub(crate) fn get_bool_option(&self, key: &str) -> Result> { 52 | self.config 53 | .get(key) 54 | .wrap_error(ERROR_NAME, "read optional boolean field failed") 55 | } 56 | 57 | pub(crate) fn get_str(&self, key: &str) -> Result { 58 | self.config 59 | .get_str(key) 60 | .wrap_error(ERROR_NAME, "read string failed") 61 | } 62 | 63 | #[allow(single_use_lifetimes)] // FIXME 64 | pub(crate) fn try_into<'de, T: Deserialize<'de>>(self) -> Result { 65 | self.config 66 | .try_into() 67 | .wrap_error(ERROR_NAME, "failed to build settings object") 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/wrapper/dbus.rs: -------------------------------------------------------------------------------- 1 | use dbus::ffidisp::BusType; 2 | use dbus::ffidisp::Connection as DbusConnection; 3 | use dbus::ffidisp::ConnectionItem; 4 | pub(crate) use dbus::Path; 5 | 6 | use crate::error::Result; 7 | use crate::error::WrapErrorExt; 8 | 9 | pub(crate) use self::data::Match; 10 | pub(crate) use self::message::Message; 11 | 12 | pub(crate) mod data; 13 | pub(crate) mod message; 14 | 15 | const ERROR_NAME: &str = "dbus"; 16 | 17 | pub(crate) struct Connection { 18 | connection: DbusConnection, 19 | } 20 | 21 | impl Connection { 22 | pub(crate) fn init() -> Result { 23 | let connection = DbusConnection::get_private(BusType::System) 24 | .wrap_error(ERROR_NAME, "failed to connect to dbus")?; 25 | 26 | Ok(Self { connection }) 27 | } 28 | 29 | pub(crate) fn add_match(&self, match_: Match<'_>) -> Result<()> { 30 | self.connection 31 | .add_match(&match_.build()) 32 | .wrap_error(ERROR_NAME, "failed to add match") 33 | } 34 | 35 | pub(crate) fn listen_for_signals(&self, mut handle_signal: T) -> Result<()> 36 | where 37 | T: FnMut(Message) -> Result<()>, 38 | { 39 | // 300_000 seconds timeout before sending ConnectionItem::Nothing 40 | for item in self.connection.iter(300_000) { 41 | if let ConnectionItem::Signal(signal) = item { 42 | handle_signal(Message::new(signal))?; 43 | } 44 | } 45 | 46 | Ok(()) 47 | } 48 | 49 | pub(crate) fn remove_match(&self, match_: Match<'_>) -> Result<()> { 50 | self.connection 51 | .remove_match(&match_.build()) 52 | .wrap_error(ERROR_NAME, "failed to remove match") 53 | } 54 | 55 | pub(crate) fn send_message(&self, message: Message) -> Result { 56 | Ok(Message::new( 57 | self.connection 58 | .send_with_reply_and_block(message.raw(), 2000) // 2 seconds timeout 59 | .wrap_error(ERROR_NAME, "failed to send message")?, 60 | )) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/features/audio/updater.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::error::WrapErrorExt; 3 | use crate::feature; 4 | use crate::wrapper::process; 5 | 6 | use super::ConfigEntry; 7 | use super::Data; 8 | use super::FEATURE_NAME; 9 | 10 | const FILTER: &[char] = &['[', ']', '%']; 11 | 12 | pub(super) struct Updater { 13 | data: Data, 14 | settings: ConfigEntry, 15 | } 16 | 17 | impl Updater { 18 | pub(super) const fn new(data: Data, settings: ConfigEntry) -> Self { 19 | Self { data, settings } 20 | } 21 | } 22 | 23 | impl feature::Updatable for Updater { 24 | fn renderable(&self) -> &dyn feature::Renderable { 25 | &self.data 26 | } 27 | 28 | fn update(&mut self) -> Result<()> { 29 | // originally taken from https://github.com/greshake/i3status-rust/blob/master/src/blocks/sound.rs 30 | let output = process::Command::new("amixer", &["get", &self.settings.control]) 31 | .output() 32 | .wrap_error( 33 | FEATURE_NAME, 34 | format!( 35 | "amixer info for control '{}' could not be fetched", 36 | &self.settings.control, 37 | ), 38 | )?; 39 | 40 | let last_line = &output 41 | .lines() 42 | .last() 43 | .wrap_error(FEATURE_NAME, "empty amixer output")?; 44 | 45 | let last = last_line 46 | .split_whitespace() 47 | .filter(|x| x.starts_with('[') && !x.contains("dB")) 48 | .map(|s| s.trim_matches(FILTER)) 49 | .collect::>(); 50 | 51 | if last.get(1).is_some_and(|muted| *muted == "off") { 52 | self.data.update_mute(); 53 | } else { 54 | let volume = last 55 | .first() 56 | .wrap_error(FEATURE_NAME, "no volume part found")? 57 | .parse() 58 | .wrap_error(FEATURE_NAME, "volume not parsable")?; 59 | 60 | self.data.update_volume(volume); 61 | } 62 | 63 | Ok(()) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/status_bar.rs: -------------------------------------------------------------------------------- 1 | use log::info; 2 | 3 | use crate::communication; 4 | use crate::error::Error; 5 | use crate::error::Result; 6 | use crate::feature; 7 | use crate::settings; 8 | use crate::wrapper::xsetroot; 9 | 10 | pub(super) struct StatusBar { 11 | features: Vec>, 12 | xsetroot: xsetroot::XSetRoot, 13 | } 14 | 15 | impl StatusBar { 16 | pub(super) fn init(features: Vec>) -> Result { 17 | Ok(Self { 18 | features, 19 | xsetroot: xsetroot::XSetRoot::init()?, 20 | }) 21 | } 22 | 23 | pub(super) fn update( 24 | &mut self, 25 | message: &communication::Message, 26 | settings: &settings::General, 27 | ) -> Result<()> { 28 | match message { 29 | communication::Message::FeatureUpdate(id) if *id < self.features.len() => { 30 | info!("Update feature {}", self.features[*id].name()); 31 | 32 | self.features[*id].update()?; 33 | self.render(settings)?; 34 | }, 35 | communication::Message::FeatureUpdate(id) => { 36 | return Err(Error::new_custom( 37 | "invalid message", 38 | format!("feature id {} does not exist", id), 39 | )); 40 | }, 41 | communication::Message::UpdateAll => { 42 | info!("Update all features"); 43 | 44 | for id in 0..self.features.len() { 45 | self.features[id].update()?; 46 | } 47 | self.render(settings)?; 48 | }, 49 | communication::Message::Kill => (), 50 | } 51 | 52 | Ok(()) 53 | } 54 | 55 | pub(super) fn render(&self, settings: &settings::General) -> Result<()> { 56 | let status = self 57 | .features 58 | .iter() 59 | .map(|f| f.renderable().render()) 60 | .collect::>() 61 | .join(&settings.separator); 62 | 63 | self.xsetroot.render(status) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/expression_parser.rs: -------------------------------------------------------------------------------- 1 | use evalexpr::{eval_with_context, DefaultNumericTypes, HashMapContext, Value}; 2 | use regex::Captures; 3 | use regex::Regex; 4 | 5 | pub(crate) fn evaluate_expression( 6 | template: &str, 7 | vars: &HashMapContext, 8 | ) -> String { 9 | let re = Regex::new(r"\{([^}]+)\}").unwrap(); 10 | 11 | re.replace_all(template, |caps: &Captures<'_>| { 12 | let expr = &caps[1]; 13 | match eval_with_context(expr, vars) { 14 | Ok(Value::Float(f)) => format!("{:.2}", f), 15 | Ok(s) => s.to_string(), 16 | Err(_) => format!("{{{}}}", expr), 17 | } 18 | }) 19 | .into_owned() 20 | } 21 | 22 | #[cfg(test)] 23 | mod tests { 24 | use evalexpr::context_map; 25 | use hamcrest2::assert_that; 26 | use hamcrest2::prelude::*; 27 | 28 | use super::*; 29 | 30 | fn get_test_vars() -> HashMapContext { 31 | let context: HashMapContext = context_map! { 32 | "A" => Value::from_float(0.2), 33 | "B" => Value::from_float(0.4), 34 | "C" => Value::from_int(4), 35 | } 36 | .unwrap(); 37 | 38 | context 39 | } 40 | 41 | #[test] 42 | fn evaluate_empty() { 43 | let vars = get_test_vars(); 44 | 45 | let evaluated = evaluate_expression("", &vars); 46 | 47 | assert_that!(evaluated, is(equal_to(""))); 48 | } 49 | 50 | #[test] 51 | fn evaluate_replace_simple() { 52 | let vars = get_test_vars(); 53 | 54 | let evaluated = evaluate_expression("{A} {C} {A} test", &vars); 55 | 56 | assert_that!(evaluated, is(equal_to("0.20 4 0.20 test"))); 57 | } 58 | 59 | #[test] 60 | fn evaluate_division() { 61 | let vars = get_test_vars(); 62 | 63 | let evaluated = evaluate_expression("{B/C}", &vars); 64 | 65 | assert_that!(evaluated, is(equal_to("0.10"))); 66 | } 67 | 68 | #[test] 69 | fn evaluate_percentage() { 70 | let vars = get_test_vars(); 71 | 72 | let evaluated = evaluate_expression("{B/C*100}% {A}", &vars); 73 | 74 | assert_that!(evaluated, is(equal_to("10.00% 0.20"))); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/features/backlight/device.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use crate::error::Result; 4 | use crate::error::WrapErrorExt; 5 | use crate::features::backlight::ConfigEntry; 6 | use crate::wrapper::file; 7 | use glob::glob; 8 | 9 | use super::FEATURE_NAME; 10 | 11 | const BACKLIGHT_SYS_PATH: &str = "/sys/class/backlight"; 12 | 13 | pub(super) struct BacklightDevice { 14 | max: u32, 15 | path: String, 16 | } 17 | 18 | impl BacklightDevice { 19 | pub(super) fn init(settings: &ConfigEntry) -> Result { 20 | let mut device = Self { 21 | max: 0, 22 | path: Self::backlight_dir(settings)?, 23 | }; 24 | 25 | device.max = device.get_brightness("max")?; 26 | 27 | Ok(device) 28 | } 29 | 30 | pub(super) fn backlight_dir(settings: &ConfigEntry) -> Result { 31 | let default_path = format!("{}/{}", BACKLIGHT_SYS_PATH, settings.device); 32 | 33 | if Path::new(&default_path).exists() || settings.fallback.is_none() { 34 | return Ok(default_path); 35 | } 36 | 37 | let pattern = format!( 38 | "{}/{}", 39 | BACKLIGHT_SYS_PATH, 40 | settings.fallback.as_ref().unwrap() 41 | ); 42 | 43 | if let Some(Ok(path)) = glob(&pattern) 44 | .wrap_error(FEATURE_NAME, "Failed to read glob pattern")? 45 | .next() 46 | { 47 | return Ok(path.display().to_string()); 48 | } 49 | 50 | Ok(default_path) 51 | } 52 | 53 | pub(super) fn brightness_file(&self) -> String { 54 | self.build_path("actual") 55 | } 56 | 57 | pub(super) fn value(&self) -> Result { 58 | let current = self.get_brightness("actual")?; 59 | let value = current * 100 / self.max; 60 | 61 | Ok(value) 62 | } 63 | 64 | fn build_path(&self, name: &str) -> String { 65 | format!("{}/{}_brightness", self.path, name) 66 | } 67 | 68 | fn get_brightness(&self, name: &str) -> Result { 69 | let brightness = file::parse_file_content(self.build_path(name)) 70 | .wrap_error(FEATURE_NAME, format!("error reading {} brightness", name))?; 71 | 72 | Ok(brightness) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/test_utils/log.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | use std::collections::VecDeque; 3 | 4 | use hamcrest2::assert_that; 5 | use hamcrest2::prelude::*; 6 | pub(crate) use log::Level; 7 | 8 | static LOGGER: TestLogger = TestLogger; 9 | 10 | thread_local!( 11 | static QUEUE: RefCell> = RefCell::new(VecDeque::new()); 12 | ); 13 | 14 | #[derive(Clone, Debug, PartialEq)] 15 | struct LogEntry { 16 | message: String, 17 | level: Level, 18 | } 19 | 20 | struct TestLogger; 21 | 22 | impl log::Log for TestLogger { 23 | fn enabled(&self, _: &log::Metadata<'_>) -> bool { 24 | true 25 | } 26 | 27 | fn log(&self, record: &log::Record<'_>) { 28 | QUEUE.with(|q| { 29 | let queue = &mut *q.borrow_mut(); 30 | queue.push_back(LogEntry { 31 | message: record.args().to_string(), 32 | level: record.level(), 33 | }); 34 | }); 35 | } 36 | 37 | fn flush(&self) {} 38 | } 39 | 40 | pub(crate) struct LoggerContext { 41 | // force use of constructor 42 | _secret: (), 43 | } 44 | 45 | impl LoggerContext { 46 | pub(crate) fn new() -> Self { 47 | log::set_max_level(log::LevelFilter::Trace); 48 | // fails if another test already registered this logger 49 | let _ = log::set_logger(&LOGGER); 50 | 51 | Self { _secret: () } 52 | } 53 | 54 | pub(crate) fn assert_entry>(&self, level: Level, message: T) { 55 | QUEUE.with(|q| { 56 | let queue = &mut *q.borrow_mut(); 57 | assert_that!(queue.is_empty(), is(false)); 58 | 59 | let entry = queue.pop_front().unwrap(); 60 | let expected = LogEntry { 61 | level, 62 | message: message.into(), 63 | }; 64 | 65 | assert_that!(entry, is(equal_to(expected))); 66 | }); 67 | } 68 | } 69 | 70 | impl Drop for LoggerContext { 71 | fn drop(&mut self) { 72 | QUEUE.with(|q| { 73 | let queue = &mut *q.borrow_mut(); 74 | let clone = queue.clone(); 75 | queue.truncate(0); 76 | 77 | assert_that!(clone, is(equal_to(VecDeque::new()))); 78 | }); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/features/cpu_load/data.rs: -------------------------------------------------------------------------------- 1 | use crate::expression_parser::evaluate_expression; 2 | use crate::feature::Renderable; 3 | use evalexpr::{context_map, DefaultNumericTypes, HashMapContext, Value}; 4 | 5 | #[derive(Debug)] 6 | pub(super) struct Data { 7 | cache: String, 8 | template: String, 9 | } 10 | 11 | impl Data { 12 | pub(super) const fn new(template: String) -> Self { 13 | Self { 14 | cache: String::new(), 15 | template, 16 | } 17 | } 18 | 19 | pub(super) fn update(&mut self, one: f32, five: f32, fifteen: f32, nproc: u32) { 20 | let context: HashMapContext = context_map! { 21 | "CL1" => Value::from_float(one.into()), 22 | "CL5" => Value::from_float(five.into()), 23 | "CL15" => Value::from_float(fifteen.into()), 24 | "NPROC" => Value::from_int(nproc.into()), 25 | } 26 | .unwrap(); 27 | 28 | self.cache = evaluate_expression(&self.template, &context); 29 | } 30 | } 31 | 32 | impl Renderable for Data { 33 | fn render(&self) -> &str { 34 | &self.cache 35 | } 36 | } 37 | 38 | #[cfg(test)] 39 | mod tests { 40 | use hamcrest2::assert_that; 41 | use hamcrest2::prelude::*; 42 | 43 | use super::*; 44 | 45 | #[test] 46 | fn render_with_default() { 47 | let object = Data::new("{CL1} {CL5} {CL15}".to_owned()); 48 | 49 | assert_that!(object.render(), is(equal_to(""))); 50 | } 51 | 52 | #[test] 53 | fn render_with_update() { 54 | let mut object = Data::new("{CL1} {CL5} {CL15}".to_owned()); 55 | 56 | object.update(20.1234, 0.005, 5.3, 2); 57 | 58 | assert_that!(object.render(), is(equal_to("20.12 0.00 5.30"))); 59 | } 60 | 61 | #[test] 62 | fn render_with_update_and_missing_placeholder() { 63 | let mut object = Data::new("{CL1} - {CL15}".to_owned()); 64 | 65 | object.update(20.1234, 0.005, 5.3, 2); 66 | 67 | assert_that!(object.render(), is(equal_to("20.12 - 5.30"))); 68 | } 69 | 70 | #[test] 71 | fn render_with_update_and_math() { 72 | let mut object = Data::new("{CL1/NPROC*100}% {CL5} {CL15}".to_owned()); 73 | 74 | object.update(20.1234, 0.005, 5.3, 2); 75 | 76 | assert_that!(object.render(), is(equal_to("1006.17% 0.00 5.30"))); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::cmp; 2 | 3 | #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] 4 | #[cfg_attr(all(test, feature = "mocking"), mocktopus::macros::mockable)] 5 | pub(crate) fn icon_by_percentage>(icons: &[String], percentage: I) -> Option<&str> { 6 | if icons.is_empty() { 7 | return None; 8 | } 9 | 10 | let length = icons.len(); 11 | let interval = 100 / length; 12 | let index = cmp::min(percentage.into() as usize, 100) / interval; 13 | 14 | Some(&icons[cmp::min(index, length - 1)]) 15 | } 16 | 17 | #[cfg(test)] 18 | mod tests { 19 | use super::*; 20 | 21 | #[test] 22 | fn icon_by_percentage_with_no_element() { 23 | let icons = Vec::::new(); 24 | assert_eq!(icon_by_percentage(&icons, 0), None); 25 | assert_eq!(icon_by_percentage(&icons, 100), None); 26 | } 27 | 28 | #[test] 29 | fn icon_by_percentage_with_one_element() { 30 | let icons = vec!["ICON".to_owned()]; 31 | assert_eq!(icon_by_percentage(&icons, 0), Some("ICON")); 32 | assert_eq!(icon_by_percentage(&icons, 50), Some("ICON")); 33 | assert_eq!(icon_by_percentage(&icons, 100), Some("ICON")); 34 | assert_eq!(icon_by_percentage(&icons, 120), Some("ICON")); 35 | } 36 | 37 | #[test] 38 | fn icon_by_percentage_with_two_elements() { 39 | let icons = vec!["LOW".to_owned(), "HIGH".to_owned()]; 40 | assert_eq!(icon_by_percentage(&icons, 0), Some("LOW")); 41 | assert_eq!(icon_by_percentage(&icons, 49), Some("LOW")); 42 | assert_eq!(icon_by_percentage(&icons, 50), Some("HIGH")); 43 | assert_eq!(icon_by_percentage(&icons, 100), Some("HIGH")); 44 | assert_eq!(icon_by_percentage(&icons, 120), Some("HIGH")); 45 | } 46 | 47 | #[test] 48 | fn icon_by_percentage_with_three_elements() { 49 | let icons = vec!["LOW".to_owned(), "MIDDLE".to_owned(), "HIGH".to_owned()]; 50 | assert_eq!(icon_by_percentage(&icons, 0), Some("LOW")); 51 | assert_eq!(icon_by_percentage(&icons, 32), Some("LOW")); 52 | assert_eq!(icon_by_percentage(&icons, 33), Some("MIDDLE")); 53 | assert_eq!(icon_by_percentage(&icons, 65), Some("MIDDLE")); 54 | assert_eq!(icon_by_percentage(&icons, 66), Some("HIGH")); 55 | assert_eq!(icon_by_percentage(&icons, 100), Some("HIGH")); 56 | assert_eq!(icon_by_percentage(&icons, 120), Some("HIGH")); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/wrapper/process.rs: -------------------------------------------------------------------------------- 1 | use std::ffi::OsStr; 2 | use std::io::Read; 3 | use std::process; 4 | 5 | use crate::error::Error; 6 | use crate::error::Result; 7 | use crate::error::WrapErrorExt; 8 | use crate::wrapper::thread; 9 | 10 | const ERROR_NAME: &str = "process"; 11 | 12 | pub(crate) struct Command { 13 | command: process::Command, 14 | } 15 | 16 | impl Command { 17 | pub(crate) fn new(program: &str, args: &[S]) -> Self 18 | where 19 | S: AsRef, 20 | { 21 | let mut command = process::Command::new(program); 22 | for arg in args { 23 | command.arg(arg); 24 | } 25 | 26 | Self { command } 27 | } 28 | 29 | pub(crate) fn args(&mut self, args: I) 30 | where 31 | I: IntoIterator, 32 | S: AsRef, 33 | { 34 | self.command.args(args); 35 | } 36 | 37 | pub(crate) fn output(mut self) -> Result { 38 | self.command 39 | .output() 40 | .wrap_error(ERROR_NAME, "executing process failed") 41 | .and_then(|o| { 42 | if o.status.success() { 43 | Ok(o) 44 | } else { 45 | Err(Error::new_custom( 46 | ERROR_NAME, 47 | format!("process exit code is {}", o.status.code().unwrap()), 48 | )) 49 | } 50 | }) 51 | .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned()) 52 | .wrap_error(ERROR_NAME, "reading process output failed") 53 | } 54 | 55 | pub(crate) fn listen_stdout(mut self, success_handler: S) -> Result<()> 56 | where 57 | S: Fn() -> Result<()>, 58 | { 59 | let mut monitor = self 60 | .command 61 | .stdout(process::Stdio::piped()) 62 | .spawn() 63 | .wrap_error(ERROR_NAME, "failed to start process")? 64 | .stdout 65 | .wrap_error(ERROR_NAME, "failed to pipe process output")?; 66 | 67 | let mut buffer = [0; 1024]; 68 | loop { 69 | if let Ok(bytes) = monitor.read(&mut buffer) { 70 | // reader has reached end-of-life -> thread gets killed 71 | if bytes == 0 { 72 | break Ok(()); 73 | } 74 | 75 | success_handler()?; 76 | } 77 | 78 | thread::sleep_prevent_spam(); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/wrapper/battery.rs: -------------------------------------------------------------------------------- 1 | use log::warn; 2 | use uom::si::f32::Ratio; 3 | use uom::si::f32::Time; 4 | use uom::si::time::second; 5 | 6 | use crate::error::Result; 7 | use crate::error::WrapErrorExt; 8 | 9 | #[derive(Debug)] 10 | pub(crate) enum Battery { 11 | Charging { 12 | percentage: Ratio, 13 | time_to_full: Time, 14 | }, 15 | Discharging { 16 | percentage: Ratio, 17 | time_to_empty: Time, 18 | }, 19 | Unknown { 20 | percentage: Ratio, 21 | }, 22 | Empty, 23 | Full, 24 | } 25 | 26 | pub(crate) fn all_batteries() -> Result> { 27 | let manager = battery::Manager::new().wrap_error("battery", "error in loading battery info")?; 28 | 29 | Ok(manager 30 | .batteries() 31 | .wrap_error("battery", "error in loading battery info")? 32 | .filter_map(|maybe_battery| match maybe_battery { 33 | #[allow(clippy::match_wildcard_for_single_variants)] 34 | Ok(battery) => match battery.state() { 35 | battery::State::Charging => Some(Battery::Charging { 36 | percentage: battery.state_of_charge(), 37 | time_to_full: battery 38 | .time_to_full() 39 | .unwrap_or_else(|| Time::new::(0.)), 40 | }), 41 | battery::State::Discharging => Some(Battery::Discharging { 42 | percentage: battery.state_of_charge(), 43 | time_to_empty: battery 44 | .time_to_empty() 45 | .unwrap_or_else(|| Time::new::(0.)), 46 | }), 47 | battery::State::Empty => Some(Battery::Empty), 48 | battery::State::Full => Some(Battery::Full), 49 | battery::State::Unknown => Some(Battery::Unknown { 50 | // Unknown can mean either controller returned unknown, 51 | // or not able to retrieve state due to some error. 52 | // Nevertheless, it should be possible to get the state of 53 | // charge. 54 | percentage: battery.state_of_charge(), 55 | }), 56 | _ => { 57 | // battery::State is non-exhaustive so we should handle this case 58 | warn!("An hunandled state was reported when reading battery data"); 59 | None 60 | }, 61 | }, 62 | Err(err) => { 63 | warn!("An error occurred reading battery data: {}", err); 64 | None 65 | }, 66 | }) 67 | .collect::>()) 68 | } 69 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build and test 2 | on: 3 | pull_request: 4 | push: 5 | schedule: 6 | - cron: 0 0 * * 1 7 | 8 | jobs: 9 | rust: 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | rust: 16 | - stable 17 | - beta 18 | - nightly 19 | - 1.82.0 20 | include: 21 | - rust: nightly 22 | components: clippy, rustfmt 23 | cargo-flags: --features mocking 24 | 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v6 28 | 29 | - name: Install rust 30 | uses: actions-rust-lang/setup-rust-toolchain@v1 31 | with: 32 | toolchain: ${{ matrix.rust }} 33 | components: ${{ matrix.components }} 34 | 35 | - name: Install build dependencies 36 | run: | 37 | sudo apt-get update 38 | sudo apt-get install libdbus-1-dev libgdk-pixbuf2.0-dev libglib2.0-dev libnotify-dev 39 | 40 | - name: Install tarpaulin 41 | if: matrix.rust == 'nightly' 42 | run: | 43 | sudo apt-get install libssl-dev 44 | RUSTFLAGS="--cfg procmacro2_semver_exempt" cargo install cargo-tarpaulin 45 | 46 | - name: Check coding style 47 | if: matrix.rust == 'nightly' 48 | run: cargo fmt -- --verbose --check 49 | 50 | - name: Build package 51 | run: | 52 | cargo check --verbose ${{ matrix.cargo-flags }} 53 | cargo build --verbose ${{ matrix.cargo-flags }} 54 | 55 | - name: Check clippy errors 56 | if: matrix.rust == 'nightly' 57 | run: cargo clippy --verbose ${{ matrix.cargo-flags }} 58 | 59 | - name: Run cargo tests 60 | if: matrix.rust == 'nightly' 61 | run: cargo test --verbose ${{ matrix.cargo-flags }} 62 | 63 | - name: Generate and push code coverage data 64 | if: matrix.rust == 'nightly' 65 | run: | 66 | RUST_BACKTRACE=full cargo tarpaulin --out xml 67 | bash <(curl -s https://codecov.io/bash) 68 | 69 | 70 | nix: 71 | runs-on: ubuntu-latest 72 | 73 | steps: 74 | - name: Checkout repository 75 | uses: actions/checkout@v6 76 | 77 | - name: Install nix 78 | uses: cachix/install-nix-action@v31 79 | 80 | - name: Setup cachix 81 | uses: cachix/cachix-action@v16 82 | with: 83 | name: gerschtli 84 | authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} 85 | 86 | - name: Check flake 87 | run: nix flake check --log-format bar-with-logs 88 | 89 | - name: Show current rust version 90 | run: nix develop --log-format bar-with-logs --command rustc --version 91 | 92 | - name: Build package 93 | run: nix build --log-format bar-with-logs 94 | -------------------------------------------------------------------------------- /src/features/battery/config.rs: -------------------------------------------------------------------------------- 1 | use serde_derive::Deserialize; 2 | 3 | use crate::error::Result; 4 | use crate::settings::ConfigType; 5 | use crate::wrapper::config; 6 | use crate::wrapper::config::Value; 7 | 8 | use super::FEATURE_NAME; 9 | 10 | #[derive(Clone, Debug, Deserialize)] 11 | pub(crate) struct NotifierConfig { 12 | pub(super) enable_notifier: bool, 13 | pub(super) notifier_critical: u64, 14 | pub(super) notifier_levels: Vec, 15 | } 16 | 17 | #[derive(Clone, Debug, Deserialize)] 18 | pub(crate) struct RenderConfig { 19 | pub(super) charging: String, 20 | pub(super) discharging: String, 21 | pub(super) icons: Vec, 22 | pub(super) no_battery: String, 23 | pub(super) separator: String, 24 | } 25 | 26 | #[derive(Clone, Debug, Deserialize)] 27 | pub(crate) struct ConfigEntry { 28 | #[serde(flatten)] 29 | pub(super) notifier: NotifierConfig, 30 | #[serde(flatten)] 31 | pub(super) render: RenderConfig, 32 | } 33 | 34 | impl ConfigType for ConfigEntry { 35 | fn set_default(config: &mut config::Config) -> Result<()> { 36 | config.set_default( 37 | FEATURE_NAME, 38 | map!( 39 | "charging" => "▲", 40 | "discharging" => "▼", 41 | "enable_notifier" => true, 42 | "icons" => Vec::::new(), 43 | "no_battery" => "NO BATT", 44 | "notifier_critical" => 10, 45 | "notifier_levels" => vec![2, 5, 10, 15, 20], 46 | "separator" => " · ", 47 | ), 48 | ) 49 | } 50 | } 51 | 52 | #[cfg(test)] 53 | #[cfg(feature = "mocking")] 54 | mod tests { 55 | use std::collections::HashMap; 56 | 57 | use crate::test_utils::config::test_set_default_err; 58 | use crate::test_utils::config::test_set_default_ok; 59 | 60 | use super::*; 61 | 62 | #[test] 63 | fn config_type_set_default_when_ok() { 64 | test_set_default_ok::("battery", default_map); 65 | } 66 | 67 | #[test] 68 | fn config_type_set_default_when_err() { 69 | test_set_default_err::("battery", default_map); 70 | } 71 | 72 | fn default_map() -> HashMap { 73 | let mut map = HashMap::new(); 74 | map.insert("charging".to_owned(), "▲".into()); 75 | map.insert("discharging".to_owned(), "▼".into()); 76 | map.insert("enable_notifier".to_owned(), true.into()); 77 | map.insert("icons".to_owned(), Vec::::new().into()); 78 | map.insert("no_battery".to_owned(), "NO BATT".into()); 79 | map.insert("notifier_critical".to_owned(), 10.into()); 80 | map.insert("notifier_levels".to_owned(), vec![2, 5, 10, 15, 20].into()); 81 | map.insert("separator".to_owned(), " · ".into()); 82 | 83 | map 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/features/backlight/data.rs: -------------------------------------------------------------------------------- 1 | use crate::feature::Renderable; 2 | use crate::utils::icon_by_percentage; 3 | 4 | use super::RenderConfig; 5 | 6 | #[derive(Debug)] 7 | pub(super) struct Data { 8 | cache: String, 9 | config: RenderConfig, 10 | } 11 | 12 | impl Data { 13 | pub(super) const fn new(config: RenderConfig) -> Self { 14 | Self { 15 | cache: String::new(), 16 | config, 17 | } 18 | } 19 | 20 | pub(super) fn update(&mut self, value: u32) { 21 | let mut rendered = self.config.template.replace("{BL}", &format!("{}", value)); 22 | 23 | if let Some(icon) = icon_by_percentage(&self.config.icons, value) { 24 | rendered = rendered.replace("{ICO}", icon); 25 | } 26 | 27 | self.cache = rendered; 28 | } 29 | } 30 | 31 | impl Renderable for Data { 32 | fn render(&self) -> &str { 33 | &self.cache 34 | } 35 | } 36 | 37 | #[cfg(test)] 38 | mod tests { 39 | use hamcrest2::assert_that; 40 | use hamcrest2::prelude::*; 41 | #[cfg(feature = "mocking")] 42 | use mocktopus::mocking::*; 43 | 44 | use super::*; 45 | 46 | #[test] 47 | fn render_with_default() { 48 | let config = RenderConfig { 49 | icons: vec![], 50 | template: "TEMPLATE".to_owned(), 51 | }; 52 | 53 | let object = Data::new(config); 54 | 55 | assert_that!(object.render(), is(equal_to(""))); 56 | } 57 | 58 | #[cfg(feature = "mocking")] 59 | #[test] 60 | fn render_with_volume() { 61 | let config = RenderConfig { 62 | icons: vec![], 63 | template: "TEMPLATE {BL} {ICO}".to_owned(), 64 | }; 65 | 66 | icon_by_percentage.mock_safe(|icons, value: u32| { 67 | assert_that!(icons, empty()); 68 | assert_that!(value, is(equal_to(10))); 69 | 70 | MockResult::Return(None) 71 | }); 72 | 73 | let mut object = Data::new(config); 74 | 75 | object.update(10); 76 | 77 | assert_that!(object.render(), is(equal_to("TEMPLATE 10 {ICO}"))); 78 | } 79 | 80 | #[cfg(feature = "mocking")] 81 | #[test] 82 | fn render_with_volume_and_icon() { 83 | let config = RenderConfig { 84 | icons: vec!["ico1".to_owned(), "ico2".to_owned()], 85 | template: "TEMPLATE {BL} {ICO}".to_owned(), 86 | }; 87 | 88 | icon_by_percentage.mock_safe(|icons, value: u32| { 89 | let expected_icons = vec!["ico1".to_owned(), "ico2".to_owned()]; 90 | assert_that!(icons, contains(expected_icons).exactly()); 91 | assert_that!(value, is(equal_to(10))); 92 | 93 | MockResult::Return(Some("ICON")) 94 | }); 95 | 96 | let mut object = Data::new(config); 97 | 98 | object.update(10); 99 | 100 | assert_that!(object.render(), is(equal_to("TEMPLATE 10 ICON"))); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![deny( 2 | anonymous_parameters, 3 | bare_trait_objects, 4 | clippy::all, 5 | clippy::complexity, 6 | clippy::correctness, 7 | clippy::nursery, 8 | clippy::pedantic, 9 | clippy::perf, 10 | clippy::style, 11 | elided_lifetimes_in_paths, 12 | missing_copy_implementations, 13 | missing_debug_implementations, 14 | single_use_lifetimes, 15 | trivial_casts, 16 | trivial_numeric_casts, 17 | unreachable_pub, 18 | unsafe_code, 19 | unused_import_braces, 20 | unused_qualifications, 21 | variant_size_differences 22 | )] 23 | #![allow( 24 | clippy::missing_const_for_fn, // seems to produce false positives 25 | clippy::missing_errors_doc, 26 | clippy::non_ascii_literal, 27 | clippy::redundant_pub_crate, 28 | clippy::uninlined_format_args, // until 1.65.0 is minimum version 29 | clippy::unused_self, 30 | clippy::use_self 31 | )] 32 | #![cfg_attr(all(test, feature = "mocking"), allow(trivial_casts, unsafe_code))] 33 | #![cfg_attr(all(test, feature = "mocking"), feature(proc_macro_hygiene))] 34 | 35 | use std::collections::HashSet; 36 | 37 | use crate::error::Error; 38 | use crate::error::Result; 39 | use crate::error::ResultExt; 40 | use crate::status_bar::StatusBar; 41 | use crate::wrapper::channel; 42 | use crate::wrapper::termination; 43 | 44 | #[macro_use] 45 | mod macros; 46 | mod communication; 47 | mod error; 48 | mod expression_parser; 49 | mod feature; 50 | mod features; 51 | mod resume; 52 | mod settings; 53 | mod status_bar; 54 | #[cfg(test)] 55 | mod test_utils; 56 | mod utils; 57 | mod wrapper; 58 | 59 | fn validate_settings(settings: &settings::Settings) -> Result<()> { 60 | if settings.general.order.is_empty() { 61 | return Err(Error::new_custom("settings", "no features enabled")); 62 | } 63 | 64 | let set: HashSet<&String> = settings.general.order.iter().collect(); 65 | if set.len() < settings.general.order.len() { 66 | return Err(Error::new_custom( 67 | "settings", 68 | "order must not have more than one entry of one feature", 69 | )); 70 | } 71 | 72 | Ok(()) 73 | } 74 | 75 | pub fn run(config_path: &str) -> Result<()> { 76 | let settings = settings::Settings::init(config_path)?; 77 | 78 | validate_settings(&settings)?; 79 | 80 | let (sender, receiver) = channel::create(); 81 | let mut features = Vec::new(); 82 | 83 | for (index, feature_name) in settings.general.order.iter().enumerate() { 84 | let mut feature = features::create_feature(index, feature_name, &sender, &settings)?; 85 | feature.init_notifier()?; 86 | features.push(feature); 87 | } 88 | 89 | resume::init_resume_notifier(&sender)?; 90 | 91 | sender.send(communication::Message::UpdateAll)?; 92 | 93 | termination::register_handler(move || { 94 | sender 95 | .send(communication::Message::Kill) 96 | .show_error_and_ignore(); 97 | })?; 98 | 99 | let mut status_bar = StatusBar::init(features)?; 100 | 101 | while let Ok(message) = receiver.read_blocking() { 102 | match message { 103 | communication::Message::Kill => break, 104 | _ => status_bar.update(&message, &settings.general)?, 105 | } 106 | } 107 | 108 | Ok(()) 109 | } 110 | -------------------------------------------------------------------------------- /src/features/battery/data.rs: -------------------------------------------------------------------------------- 1 | use uom::si::f32::Time; 2 | 3 | use crate::feature::Renderable; 4 | use crate::utils::icon_by_percentage; 5 | use crate::wrapper::battery::Battery; 6 | use crate::wrapper::uom::get_raw_hours; 7 | use crate::wrapper::uom::get_raw_minutes; 8 | use crate::wrapper::uom::get_raw_percent; 9 | 10 | use super::RenderConfig; 11 | 12 | #[derive(Debug)] 13 | pub(super) struct Data { 14 | cache: String, 15 | config: RenderConfig, 16 | } 17 | 18 | impl Data { 19 | pub(super) const fn new(config: RenderConfig) -> Self { 20 | Self { 21 | cache: String::new(), 22 | config, 23 | } 24 | } 25 | 26 | pub(super) fn update(&mut self, batteries: &[Battery]) { 27 | self.cache = if batteries.is_empty() { 28 | self.config.no_battery.clone() 29 | } else { 30 | batteries 31 | .iter() 32 | .map(|battery| { 33 | self.render_battery(battery) 34 | .into_iter() 35 | .collect::>() 36 | .join(" ") 37 | }) 38 | .collect::>() 39 | .join(&self.config.separator) 40 | } 41 | } 42 | 43 | fn render_battery(&self, battery: &Battery) -> Vec { 44 | match *battery { 45 | Battery::Charging { 46 | percentage, 47 | time_to_full, 48 | } => { 49 | let capacity = get_raw_percent(percentage); 50 | 51 | let mut list = vec![self.config.charging.clone()]; 52 | self.push_capacity(&mut list, capacity); 53 | self.push_time(&mut list, time_to_full); 54 | list 55 | }, 56 | Battery::Discharging { 57 | percentage, 58 | time_to_empty, 59 | } => { 60 | let capacity = get_raw_percent(percentage); 61 | 62 | let mut list = vec![self.config.discharging.clone()]; 63 | self.push_capacity(&mut list, capacity); 64 | self.push_time(&mut list, time_to_empty); 65 | list 66 | }, 67 | Battery::Unknown { percentage } => { 68 | let capacity = get_raw_percent(percentage); 69 | let mut list = vec![]; 70 | self.push_capacity(&mut list, capacity); 71 | list 72 | }, 73 | Battery::Empty => { 74 | let mut list = vec![]; 75 | self.push_capacity(&mut list, 0.); 76 | list 77 | }, 78 | Battery::Full => { 79 | let mut list = vec![]; 80 | self.push_capacity(&mut list, 100.); 81 | list 82 | }, 83 | } 84 | } 85 | 86 | fn push_capacity(&self, list: &mut Vec, capacity: f32) { 87 | let icon = icon_by_percentage(&self.config.icons, capacity); 88 | 89 | if let Some(icon_str) = icon { 90 | list.push(icon_str.to_owned()); 91 | } 92 | 93 | list.push(format!("{:.0}%", capacity)); 94 | } 95 | 96 | fn push_time(&self, list: &mut Vec, time: Time) { 97 | list.push(format!( 98 | "({:02}:{:02})", 99 | get_raw_hours(time), 100 | get_raw_minutes(time) 101 | )); 102 | } 103 | } 104 | 105 | impl Renderable for Data { 106 | fn render(&self) -> &str { 107 | &self.cache 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/features/audio/data.rs: -------------------------------------------------------------------------------- 1 | use crate::feature::Renderable; 2 | use crate::utils::icon_by_percentage; 3 | 4 | use super::RenderConfig; 5 | 6 | pub(super) struct Data { 7 | cache: String, 8 | config: RenderConfig, 9 | } 10 | 11 | impl Data { 12 | pub(super) const fn new(config: RenderConfig) -> Self { 13 | Self { 14 | cache: String::new(), 15 | config, 16 | } 17 | } 18 | 19 | pub(super) fn update_mute(&mut self) { 20 | self.cache.clone_from(&self.config.mute); 21 | } 22 | 23 | pub(super) fn update_volume(&mut self, volume: u32) { 24 | let mut rendered = self 25 | .config 26 | .template 27 | .replace("{VOL}", &format!("{}", volume)); 28 | 29 | if let Some(icon) = icon_by_percentage(&self.config.icons, volume) { 30 | rendered = rendered.replace("{ICO}", icon); 31 | } 32 | 33 | self.cache = rendered; 34 | } 35 | } 36 | 37 | impl Renderable for Data { 38 | fn render(&self) -> &str { 39 | &self.cache 40 | } 41 | } 42 | 43 | #[cfg(test)] 44 | mod tests { 45 | use hamcrest2::assert_that; 46 | use hamcrest2::prelude::*; 47 | #[cfg(feature = "mocking")] 48 | use mocktopus::mocking::*; 49 | 50 | use super::*; 51 | 52 | #[test] 53 | fn render_with_default() { 54 | let config = RenderConfig { 55 | icons: vec![], 56 | mute: "MUTE".to_owned(), 57 | template: "TEMPLATE".to_owned(), 58 | }; 59 | 60 | let object = Data::new(config); 61 | 62 | assert_that!(object.render(), is(equal_to(""))); 63 | } 64 | 65 | #[test] 66 | fn render_with_mute() { 67 | let config = RenderConfig { 68 | icons: vec![], 69 | mute: "MUTE".to_owned(), 70 | template: "TEMPLATE".to_owned(), 71 | }; 72 | 73 | let mut object = Data::new(config); 74 | 75 | object.update_mute(); 76 | 77 | assert_that!(object.render(), is(equal_to("MUTE"))); 78 | } 79 | 80 | #[cfg(feature = "mocking")] 81 | #[test] 82 | fn render_with_volume() { 83 | let config = RenderConfig { 84 | icons: vec![], 85 | mute: "MUTE".to_owned(), 86 | template: "TEMPLATE {VOL} {ICO}".to_owned(), 87 | }; 88 | 89 | icon_by_percentage.mock_safe(|icons, value: u32| { 90 | assert_that!(icons, empty()); 91 | assert_that!(value, is(equal_to(10))); 92 | 93 | MockResult::Return(None) 94 | }); 95 | 96 | let mut object = Data::new(config); 97 | 98 | object.update_volume(10); 99 | 100 | assert_that!(object.render(), is(equal_to("TEMPLATE 10 {ICO}"))); 101 | } 102 | 103 | #[cfg(feature = "mocking")] 104 | #[test] 105 | fn render_with_volume_and_icon() { 106 | let config = RenderConfig { 107 | icons: vec!["ico1".to_owned(), "ico2".to_owned()], 108 | mute: "MUTE".to_owned(), 109 | template: "TEMPLATE {VOL} {ICO}".to_owned(), 110 | }; 111 | 112 | icon_by_percentage.mock_safe(|icons, value: u32| { 113 | let expected_icons = vec!["ico1".to_owned(), "ico2".to_owned()]; 114 | assert_that!(icons, contains(expected_icons).exactly()); 115 | assert_that!(value, is(equal_to(10))); 116 | 117 | MockResult::Return(Some("ICON")) 118 | }); 119 | 120 | let mut object = Data::new(config); 121 | 122 | object.update_volume(10); 123 | 124 | assert_that!(object.render(), is(equal_to("TEMPLATE 10 ICON"))); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/features/network/data.rs: -------------------------------------------------------------------------------- 1 | use crate::feature::Renderable; 2 | 3 | use super::RenderConfig; 4 | use super::PLACEHOLDER_ESSID; 5 | use super::PLACEHOLDER_IPV4; 6 | use super::PLACEHOLDER_IPV6; 7 | use super::PLACEHOLDER_LOCAL_IPV4; 8 | use super::PLACEHOLDER_LOCAL_IPV6; 9 | 10 | #[derive(Debug)] 11 | pub(super) struct Data { 12 | cache: String, 13 | config: RenderConfig, 14 | } 15 | 16 | impl Data { 17 | pub(super) const fn new(config: RenderConfig) -> Self { 18 | Self { 19 | cache: String::new(), 20 | config, 21 | } 22 | } 23 | 24 | pub(super) fn update( 25 | &mut self, 26 | ipv4: T4, 27 | ipv6: T6, 28 | local_ipv4: T4, 29 | local_ipv6: T6, 30 | essid: E, 31 | ) where 32 | T4: Into>, 33 | T6: Into>, 34 | E: Into>, 35 | { 36 | self.cache = self 37 | .config 38 | .template 39 | .replace(PLACEHOLDER_IPV4, &self.get_value(ipv4)) 40 | .replace(PLACEHOLDER_IPV6, &self.get_value(ipv6)) 41 | .replace(PLACEHOLDER_LOCAL_IPV4, &self.get_value(local_ipv4)) 42 | .replace(PLACEHOLDER_LOCAL_IPV6, &self.get_value(local_ipv6)) 43 | .replace(PLACEHOLDER_ESSID, &self.get_value(essid)); 44 | } 45 | 46 | fn get_value>>(&self, value: T) -> String { 47 | value.into().unwrap_or_else(|| self.config.no_value.clone()) 48 | } 49 | } 50 | 51 | impl Renderable for Data { 52 | fn render(&self) -> &str { 53 | &self.cache 54 | } 55 | } 56 | 57 | #[cfg(test)] 58 | mod tests { 59 | use hamcrest2::assert_that; 60 | use hamcrest2::prelude::*; 61 | 62 | use super::*; 63 | 64 | #[test] 65 | fn render_with_default() { 66 | let object = Data::new(RenderConfig { 67 | no_value: "--".to_owned(), 68 | template: "{IPv4} {IPv6} {ESSID}".to_owned(), 69 | }); 70 | 71 | assert_that!(object.render(), is(equal_to(""))); 72 | } 73 | 74 | #[test] 75 | fn render_with_update() { 76 | let mut object = Data::new(RenderConfig { 77 | no_value: "--".to_owned(), 78 | template: "{IPv4} {IPv6} {LocalIPv4} {LocalIPv6} {ESSID}".to_owned(), 79 | }); 80 | 81 | object.update( 82 | "127.0.0.1".to_owned(), 83 | "fe::1".to_owned(), 84 | "192.168.0.200".to_owned(), 85 | "fd00:1234:5678::200".to_owned(), 86 | "WLAN".to_owned(), 87 | ); 88 | 89 | assert_that!( 90 | object.render(), 91 | is(equal_to( 92 | "127.0.0.1 fe::1 192.168.0.200 fd00:1234:5678::200 WLAN" 93 | )) 94 | ); 95 | } 96 | 97 | #[test] 98 | fn render_with_update_and_missing_placeholder() { 99 | let mut object = Data::new(RenderConfig { 100 | no_value: "#".to_owned(), 101 | template: "{IPv4} // {LocalIPv4} // {ESSID}".to_owned(), 102 | }); 103 | 104 | object.update( 105 | "127.0.0.1".to_owned(), 106 | "fe::1".to_owned(), 107 | "192.168.0.200".to_owned(), 108 | "fd00:1234:5678::200".to_owned(), 109 | None, 110 | ); 111 | 112 | assert_that!( 113 | object.render(), 114 | is(equal_to("127.0.0.1 // 192.168.0.200 // #")) 115 | ); 116 | } 117 | 118 | #[test] 119 | fn render_with_update_and_none_values() { 120 | let mut object = Data::new(RenderConfig { 121 | no_value: "--".to_owned(), 122 | template: "{IPv4} {IPv6} {LocalIPv4} {LocalIPv6} {ESSID}".to_owned(), 123 | }); 124 | 125 | object.update(None, None, None, None, None); 126 | 127 | assert_that!(object.render(), is(equal_to("-- -- -- -- --"))); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/features/battery/notifier.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Ordering; 2 | use std::collections::BinaryHeap; 3 | 4 | use uom::si::f32::Ratio; 5 | use uom::si::f32::Time; 6 | 7 | use crate::error::Result; 8 | use crate::error::ResultExt; 9 | use crate::wrapper::battery::Battery; 10 | use crate::wrapper::libnotify; 11 | use crate::wrapper::uom::create_ratio_by_percentage; 12 | use crate::wrapper::uom::get_raw_hours; 13 | use crate::wrapper::uom::get_raw_minutes; 14 | use crate::wrapper::uom::get_raw_percent; 15 | 16 | use super::NotifierConfig; 17 | 18 | struct SimpleBattery { 19 | percentage: Ratio, 20 | time_to_empty: Option