diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 98305b3..96021ff 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -34,9 +34,20 @@ jobs: - name: test run: cargo test + build-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + - name: Build + run: cargo build --verbose + - name: test + run: cargo test + publish-aur: runs-on: ubuntu-latest - needs: [build-linux, build-macos] + needs: [build-linux, build-macos, build-windows] if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v3 diff --git a/README.md b/README.md index 27087fa..05c5dd6 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,11 @@ Because the action only toggles Discord's state, you may need to synchronize it ## Contributing / TODOs - [ ] Update ksni +- [ ] Add Docs +- [ ] Add to crates.io +- [ ] Let CLI periodically output the state +- [ ] Optional CLI output in JSON +- [ ] Waybar applet - [x] Menu bar app for MacOS. - [x] Windows support - [x] Allow configuration via tray app diff --git a/src/devices/mod.rs b/src/devices/mod.rs index 9d63fc2..9d19a73 100644 --- a/src/devices/mod.rs +++ b/src/devices/mod.rs @@ -920,9 +920,15 @@ pub trait Device { /// Refreshes the state by listening for events /// Only the battery level is actively queried because it is not communicated by the device on its own fn passive_refresh_state(&mut self) -> Result<(), DeviceError> { + let mut request_active_refresh = false; if self.allow_passive_refresh() { if let Some(events) = self.wait_for_updates(PASSIVE_REFRESH_TIME_OUT) { for event in events { + // Some headsets send this if they just turned on so we should refresh the + // state + if matches!(event, DeviceEvent::WirelessConnected(true)) { + request_active_refresh = true; + } self.get_device_state_mut().update_self_with_event(&event); } } @@ -933,10 +939,18 @@ pub trait Device { std::thread::sleep(RESPONSE_DELAY); if let Some(events) = self.wait_for_updates(Duration::from_secs(1)) { for event in events { + // Some headsets send this if they just turned on so we should refresh the + // state + if matches!(event, DeviceEvent::WirelessConnected(true)) { + request_active_refresh = true; + } self.get_device_state_mut().update_self_with_event(&event); } } } + if request_active_refresh { + self.active_refresh_state()?; + } Ok(()) } diff --git a/src/main.rs b/src/main.rs index 9c406ba..dae13aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,8 @@ mod status_tray; #[cfg(not(target_os = "linux"))] mod status_tray_not_linux; +mod tray_battery_icon_state; + #[cfg(not(target_os = "linux"))] fn main() { use std::sync::mpsc; diff --git a/src/status_tray.rs b/src/status_tray.rs index 8aed515..e830302 100644 --- a/src/status_tray.rs +++ b/src/status_tray.rs @@ -6,6 +6,8 @@ use ksni::{ Handle, MenuItem, ToolTip, Tray, TrayService, }; +use crate::tray_battery_icon_state::TrayBatteryIconState; + pub struct TrayHandler { handle: Handle, } @@ -54,7 +56,9 @@ impl Tray for StatusTray { } fn icon_name(&self) -> String { - "audio-headset".into() + TrayBatteryIconState::from_device_properties(self.device_properties.as_ref()) + .linux_icon_name() + .to_string() } fn tool_tip(&self) -> ToolTip { @@ -83,7 +87,9 @@ impl Tray for StatusTray { .clone() .unwrap_or("Unknown".to_string()), description, - icon_name: "audio-headset".into(), + icon_name: TrayBatteryIconState::from_device_properties(Some(device_properties)) + .linux_icon_name() + .to_string(), icon_pixmap: Vec::new(), } } diff --git a/src/status_tray_not_linux.rs b/src/status_tray_not_linux.rs index aabcb87..b5f1e8a 100644 --- a/src/status_tray_not_linux.rs +++ b/src/status_tray_not_linux.rs @@ -3,6 +3,8 @@ use std::{ sync::{mpsc::Sender, Arc, Mutex}, }; +#[cfg(target_os = "windows")] +use image::{Rgba, RgbaImage}; use hyper_headset::devices::{DeviceEvent, DeviceProperties, PropertyType}; use tray_icon::{ menu::{CheckMenuItem, Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, Submenu}, @@ -15,6 +17,9 @@ use winreg::{ RegKey, RegValue, }; +#[cfg(target_os = "windows")] +use crate::tray_battery_icon_state::{TrayBatteryIconState, WindowsIconKey}; + const NO_COMPATIBLE_DEVICE: &str = "No compatible device found. Is the dongle plugged in?"; const HEADSET_NOT_CONNECTED: &str = "Headset is not connected"; #[cfg(target_os = "windows")] @@ -24,9 +29,10 @@ const STARTUP_APPROVED_RUN_KEY_PATH: &str = r"Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run"; #[cfg(target_os = "windows")] const STARTUP_VALUE_NAME: &str = "HyperHeadset"; +const WINDOWS_ICON_SIZE: u32 = 16; #[cfg(target_os = "windows")] -fn create_tray_icon() -> tray_icon::Icon { +fn create_default_tray_icon() -> tray_icon::Icon { // embed a headset .ico/.png at compile time — no file needed at runtime let bytes = include_bytes!("../assets/headphone.png"); let img = image::load_from_memory(bytes).unwrap().into_rgba8(); @@ -34,6 +40,143 @@ fn create_tray_icon() -> tray_icon::Icon { tray_icon::Icon::from_rgba(img.into_raw(), w, h).unwrap() } +#[cfg(target_os = "windows")] +fn draw_rect(image: &mut RgbaImage, x: i32, y: i32, width: i32, height: i32, color: Rgba) { + for px in x.max(0)..(x + width).min(WINDOWS_ICON_SIZE as i32) { + for py in y.max(0)..(y + height).min(WINDOWS_ICON_SIZE as i32) { + image.put_pixel(px as u32, py as u32, color); + } + } +} + +#[cfg(target_os = "windows")] +fn draw_digit( + image: &mut RgbaImage, + digit: char, + x: i32, + y: i32, + scale: i32, + color: Rgba, +) { + let rows = match digit { + '0' => ["111", "101", "101", "101", "111"], + // Narrow upright '1'. + '1' => ["01", "01", "01", "01", "01"], + '2' => ["111", "001", "111", "100", "111"], + '3' => ["111", "001", "111", "001", "111"], + '4' => ["101", "101", "111", "001", "001"], + '5' => ["111", "100", "111", "001", "111"], + '6' => ["111", "100", "111", "101", "111"], + '7' => ["111", "001", "010", "010", "010"], + '8' => ["111", "101", "111", "101", "111"], + '9' => ["111", "101", "111", "001", "111"], + _ => ["000", "000", "000", "000", "000"], + }; + + for (row_index, row) in rows.iter().enumerate() { + for (col_index, bit) in row.chars().enumerate() { + if bit == '1' { + draw_rect( + image, + x + (col_index as i32 * scale), + y + (row_index as i32 * scale), + scale, + scale, + color, + ); + } + } + } +} + +#[cfg(target_os = "windows")] +fn render_windows_battery_icon_rgba(key: WindowsIconKey) -> Vec { + let mut image = RgbaImage::from_pixel(WINDOWS_ICON_SIZE, WINDOWS_ICON_SIZE, Rgba([0, 0, 0, 0])); + + // Charging overrides battery-level color with yellow background. + let background_color = if key.charging { + Rgba([245, 216, 64, 255]) + } else if key.percent < 30 { + Rgba([220, 90, 90, 255]) + } else { + Rgba([96, 196, 106, 255]) + }; + draw_rect( + &mut image, + 0, + 0, + WINDOWS_ICON_SIZE as i32, + WINDOWS_ICON_SIZE as i32, + background_color, + ); + + // Custom compact "100" layout for 16x16: + // keeps large text while enforcing spacing between all digits. + if key.percent == 100 { + let text_color = Rgba([10, 10, 10, 255]); + let y = 3; + + // "1" (3x10) + draw_rect(&mut image, 1, y, 1, 10, text_color); + draw_rect(&mut image, 0, y + 9, 3, 1, text_color); + + // First "0" (5x10), 1px gap from "1" + let z1 = 4; + draw_rect(&mut image, z1, y, 5, 1, text_color); + draw_rect(&mut image, z1, y + 9, 5, 1, text_color); + draw_rect(&mut image, z1, y, 1, 10, text_color); + draw_rect(&mut image, z1 + 4, y, 1, 10, text_color); + + // Second "0" (5x10), 1px gap from first "0" + let z2 = 10; + draw_rect(&mut image, z2, y, 5, 1, text_color); + draw_rect(&mut image, z2, y + 9, 5, 1, text_color); + draw_rect(&mut image, z2, y, 1, 10, text_color); + draw_rect(&mut image, z2 + 4, y, 1, 10, text_color); + + return image.into_raw(); + } + + let text = key.percent.to_string(); + let mut scale = 2; + // Borderless icon: preserve explicit outer padding + spacing between digits. + let spacing = if text.len() >= 3 { 0 } else { 1 }; + // Allow 100 to use full icon width so it can stay at scale 2. + let horizontal_padding = if text.len() >= 3 { 0 } else { 1 }; + let inner_left = horizontal_padding; + let inner_right = (WINDOWS_ICON_SIZE as i32 - 1 - horizontal_padding).max(inner_left); + let usable_width = (inner_right - inner_left + 1).max(1); + + let mut glyph_widths: Vec = text + .chars() + .map(|digit| if digit == '1' { 2 * scale } else { 3 * scale }) + .collect(); + let mut total_width: i32 = glyph_widths.iter().sum::() + + spacing * (text.chars().count().saturating_sub(1) as i32); + if total_width > usable_width && scale > 1 { + // On 16x16 icons, enforce padding on both sides over large glyph size. + scale = 1; + glyph_widths = text + .chars() + .map(|digit| if digit == '1' { 2 * scale } else { 3 * scale }) + .collect(); + total_width = glyph_widths.iter().sum::() + + spacing * (text.chars().count().saturating_sub(1) as i32); + } + let centered_start_x = inner_left + ((usable_width - total_width).max(0) / 2); + let max_start_x = (inner_right - total_width + 1).max(inner_left); + let start_x = centered_start_x.clamp(inner_left, max_start_x); + let start_y = if scale == 2 { 3 } else { 5 }; + + let mut x = start_x; + for (idx, digit) in text.chars().enumerate() { + draw_digit(&mut image, digit, x, start_y, scale, Rgba([10, 10, 10, 255])); + x += glyph_widths[idx] + spacing; + } + + image.into_raw() +} + type CallbackMap = Arc>>>; pub struct TrayApp { @@ -41,6 +184,10 @@ pub struct TrayApp { pub sender: Sender, callbacks: CallbackMap, current_state: Option>, + #[cfg(target_os = "windows")] + icon_cache: HashMap>, + #[cfg(target_os = "windows")] + current_icon_key: Option, } impl ApplicationHandler> for TrayApp { @@ -56,7 +203,7 @@ impl ApplicationHandler> for TrayApp { self.tray_icon = Some( TrayIconBuilder::new() .with_menu(Box::new(Menu::new())) - .with_icon(create_tray_icon()) + .with_icon(create_default_tray_icon()) .with_tooltip(NO_COMPATIBLE_DEVICE) .with_menu_on_left_click(true) .build() @@ -119,15 +266,51 @@ impl TrayApp { sender, callbacks, current_state: None, + #[cfg(target_os = "windows")] + icon_cache: HashMap::new(), + #[cfg(target_os = "windows")] + current_icon_key: None, } } + #[cfg(target_os = "windows")] + fn update_windows_icon(&mut self, device_properties: Option<&DeviceProperties>) { + let Some(tray) = self.tray_icon.as_ref() else { + return; + }; + let icon_state = TrayBatteryIconState::from_device_properties(device_properties); + let desired_key = icon_state.windows_icon_key(); + if desired_key == self.current_icon_key { + return; + } + + if let Some(key) = desired_key { + let rgba = self + .icon_cache + .entry(key) + .or_insert_with(|| render_windows_battery_icon_rgba(key)) + .clone(); + if let Ok(icon) = tray_icon::Icon::from_rgba(rgba, WINDOWS_ICON_SIZE, WINDOWS_ICON_SIZE) + { + let _ = tray.set_icon(Some(icon)); + } + } else { + let _ = tray.set_icon(Some(create_default_tray_icon())); + } + + self.current_icon_key = desired_key; + } + fn update(&mut self, device_properties: Option) { if let Some(current_state) = self.current_state.as_ref() { if current_state == &device_properties { return; } } + + #[cfg(target_os = "windows")] + self.update_windows_icon(device_properties.as_ref()); + let Some(tray) = &mut self.tray_icon else { return; }; diff --git a/src/tray_battery_icon_state.rs b/src/tray_battery_icon_state.rs new file mode 100644 index 0000000..01157cf --- /dev/null +++ b/src/tray_battery_icon_state.rs @@ -0,0 +1,80 @@ +use hyper_headset::devices::{ChargingStatus, DeviceProperties}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TrayBatteryIconState { + NoDevice, + Disconnected, + ConnectedUnknown, + Connected { + percent: u8, + charging: bool, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg(target_os = "windows")] +pub struct WindowsIconKey { + pub percent: u8, + pub charging: bool, +} + +impl TrayBatteryIconState { + pub fn from_device_properties(device_properties: Option<&DeviceProperties>) -> Self { + let Some(device_properties) = device_properties else { + return Self::NoDevice; + }; + if !device_properties.connected.unwrap_or(false) { + return Self::Disconnected; + } + let charging = matches!( + device_properties.charging, + Some(ChargingStatus::Charging | ChargingStatus::FullyCharged) + ); + let Some(percent) = device_properties.battery_level else { + return Self::ConnectedUnknown; + }; + Self::Connected { + percent: percent.min(100), + charging, + } + } + + #[cfg(target_os = "windows")] + pub fn windows_icon_key(self) -> Option { + match self { + Self::Connected { percent, charging } => Some(WindowsIconKey { percent, charging }), + _ => None, + } + } + + #[cfg(target_os = "linux")] + pub fn linux_icon_name(self) -> &'static str { + match self { + Self::NoDevice | Self::Disconnected | Self::ConnectedUnknown => "audio-headset", + Self::Connected { percent, charging } => { + let level_name = if percent <= 10 { + "battery-caution" + } else if percent <= 30 { + "battery-low" + } else if percent <= 60 { + "battery-medium" + } else if percent <= 85 { + "battery-good" + } else { + "battery-full" + }; + if charging { + match level_name { + "battery-caution" => "battery-caution-charging", + "battery-low" => "battery-low-charging", + "battery-medium" => "battery-medium-charging", + "battery-good" => "battery-good-charging", + _ => "battery-full-charging", + } + } else { + level_name + } + } + } + } +}