use battery percentage as icon
This commit is contained in:
@@ -4,6 +4,8 @@ mod status_tray;
|
|||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
mod status_tray_not_linux;
|
mod status_tray_not_linux;
|
||||||
|
|
||||||
|
mod tray_battery_icon_state;
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
fn main() {
|
fn main() {
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use ksni::{
|
|||||||
Handle, MenuItem, ToolTip, Tray, TrayService,
|
Handle, MenuItem, ToolTip, Tray, TrayService,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::tray_battery_icon_state::TrayBatteryIconState;
|
||||||
|
|
||||||
pub struct TrayHandler {
|
pub struct TrayHandler {
|
||||||
handle: Handle<StatusTray>,
|
handle: Handle<StatusTray>,
|
||||||
}
|
}
|
||||||
@@ -54,7 +56,9 @@ impl Tray for StatusTray {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn icon_name(&self) -> String {
|
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 {
|
fn tool_tip(&self) -> ToolTip {
|
||||||
@@ -83,7 +87,9 @@ impl Tray for StatusTray {
|
|||||||
.clone()
|
.clone()
|
||||||
.unwrap_or("Unknown".to_string()),
|
.unwrap_or("Unknown".to_string()),
|
||||||
description,
|
description,
|
||||||
icon_name: "audio-headset".into(),
|
icon_name: TrayBatteryIconState::from_device_properties(Some(device_properties))
|
||||||
|
.linux_icon_name()
|
||||||
|
.to_string(),
|
||||||
icon_pixmap: Vec::new(),
|
icon_pixmap: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ use std::{
|
|||||||
sync::{mpsc::Sender, Arc, Mutex},
|
sync::{mpsc::Sender, Arc, Mutex},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use image::{Rgba, RgbaImage};
|
||||||
use hyper_headset::devices::{DeviceEvent, DeviceProperties, PropertyType};
|
use hyper_headset::devices::{DeviceEvent, DeviceProperties, PropertyType};
|
||||||
use tray_icon::{
|
use tray_icon::{
|
||||||
menu::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, Submenu},
|
menu::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, Submenu},
|
||||||
@@ -10,11 +12,15 @@ use tray_icon::{
|
|||||||
};
|
};
|
||||||
use winit::{application::ApplicationHandler, event::StartCause};
|
use winit::{application::ApplicationHandler, event::StartCause};
|
||||||
|
|
||||||
|
use crate::tray_battery_icon_state::{TrayBatteryIconState, WindowsIconKey};
|
||||||
|
|
||||||
const NO_COMPATIBLE_DEVICE: &str = "No compatible device found. Is the dongle plugged in?";
|
const NO_COMPATIBLE_DEVICE: &str = "No compatible device found. Is the dongle plugged in?";
|
||||||
const HEADSET_NOT_CONNECTED: &str = "Headset is not connected";
|
const HEADSET_NOT_CONNECTED: &str = "Headset is not connected";
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
const WINDOWS_ICON_SIZE: u32 = 16;
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[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
|
// embed a headset .ico/.png at compile time — no file needed at runtime
|
||||||
let bytes = include_bytes!("../assets/headphone.png");
|
let bytes = include_bytes!("../assets/headphone.png");
|
||||||
let img = image::load_from_memory(bytes).unwrap().into_rgba8();
|
let img = image::load_from_memory(bytes).unwrap().into_rgba8();
|
||||||
@@ -22,6 +28,143 @@ fn create_tray_icon() -> tray_icon::Icon {
|
|||||||
tray_icon::Icon::from_rgba(img.into_raw(), w, h).unwrap()
|
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<u8>) {
|
||||||
|
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<u8>,
|
||||||
|
) {
|
||||||
|
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<u8> {
|
||||||
|
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<i32> = text
|
||||||
|
.chars()
|
||||||
|
.map(|digit| if digit == '1' { 2 * scale } else { 3 * scale })
|
||||||
|
.collect();
|
||||||
|
let mut total_width: i32 = glyph_widths.iter().sum::<i32>()
|
||||||
|
+ 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::<i32>()
|
||||||
|
+ 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<Mutex<HashMap<MenuId, Box<dyn Fn() + Send + Sync>>>>;
|
type CallbackMap = Arc<Mutex<HashMap<MenuId, Box<dyn Fn() + Send + Sync>>>>;
|
||||||
|
|
||||||
pub struct TrayApp {
|
pub struct TrayApp {
|
||||||
@@ -29,6 +172,10 @@ pub struct TrayApp {
|
|||||||
pub sender: Sender<DeviceEvent>,
|
pub sender: Sender<DeviceEvent>,
|
||||||
callbacks: CallbackMap,
|
callbacks: CallbackMap,
|
||||||
current_state: Option<Option<DeviceProperties>>,
|
current_state: Option<Option<DeviceProperties>>,
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
icon_cache: HashMap<WindowsIconKey, Vec<u8>>,
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
current_icon_key: Option<WindowsIconKey>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApplicationHandler<Option<DeviceProperties>> for TrayApp {
|
impl ApplicationHandler<Option<DeviceProperties>> for TrayApp {
|
||||||
@@ -44,7 +191,7 @@ impl ApplicationHandler<Option<DeviceProperties>> for TrayApp {
|
|||||||
self.tray_icon = Some(
|
self.tray_icon = Some(
|
||||||
TrayIconBuilder::new()
|
TrayIconBuilder::new()
|
||||||
.with_menu(Box::new(Menu::new()))
|
.with_menu(Box::new(Menu::new()))
|
||||||
.with_icon(create_tray_icon())
|
.with_icon(create_default_tray_icon())
|
||||||
.with_tooltip(NO_COMPATIBLE_DEVICE)
|
.with_tooltip(NO_COMPATIBLE_DEVICE)
|
||||||
.with_menu_on_left_click(true)
|
.with_menu_on_left_click(true)
|
||||||
.build()
|
.build()
|
||||||
@@ -107,15 +254,51 @@ impl TrayApp {
|
|||||||
sender,
|
sender,
|
||||||
callbacks,
|
callbacks,
|
||||||
current_state: None,
|
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<DeviceProperties>) {
|
fn update(&mut self, device_properties: Option<DeviceProperties>) {
|
||||||
if let Some(current_state) = self.current_state.as_ref() {
|
if let Some(current_state) = self.current_state.as_ref() {
|
||||||
if current_state == &device_properties {
|
if current_state == &device_properties {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
self.update_windows_icon(device_properties.as_ref());
|
||||||
|
|
||||||
let Some(tray) = &mut self.tray_icon else {
|
let Some(tray) = &mut self.tray_icon else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|||||||
78
src/tray_battery_icon_state.rs
Normal file
78
src/tray_battery_icon_state.rs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
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)]
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn windows_icon_key(self) -> Option<WindowsIconKey> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user