use anyhow::Result;
use cocoa::base::{id, nil, YES, NO};
use cocoa::foundation::{NSString, NSAutoreleasePool, NSSize};
use cocoa::appkit::{NSStatusBar, NSMenu, NSMenuItem};
use objc::runtime::{Object, Class};
use objc::{msg_send, sel, sel_impl, class};
use objc::runtime::Sel;
use std::fs;
use log::{info, error};
const VOLUME_LEVELS: &[(f32, &str)] = &[
(1.0, "100%"),
(0.75, "75%"),
(0.5, "50%"),
(0.25, "25%"),
];
const STATUS_ITEM_LENGTH: f64 = -1.0;
pub struct TrayIcon {
status_item: id,
menu: id,
pool: id,
target: id,
}
impl TrayIcon {
pub fn new() -> Result<Self> {
unsafe {
let pool = NSAutoreleasePool::new(nil);
let target = {
let cls = register_menu_target_class();
let target: id = msg_send![cls, new];
target
};
let status_bar = NSStatusBar::systemStatusBar(nil);
let status_item = status_bar.statusItemWithLength_(STATUS_ITEM_LENGTH);
if status_item == nil {
return Err(anyhow::anyhow!("Failed to create status item"));
}
let title = NSString::alloc(nil).init_str("🎹");
let _: () = msg_send![status_item, setTitle:title];
let _: () = msg_send![title, release];
let menu = NSMenu::new(nil);
if menu == nil {
return Err(anyhow::anyhow!("Failed to create menu"));
}
let _: () = msg_send![menu, setAutoenablesItems: NO];
let _: () = msg_send![menu, retain];
let _: () = msg_send![target, setMenu:menu];
let enabled = {
let state = crate::APP_STATE.lock();
state.enabled
};
add_menu_item(menu, "Enable Sound", "toggleSound", enabled, target);
add_separator(menu);
let current_volume = {
let state = crate::APP_STATE.lock();
state.volume
};
add_menu_item(menu, "Volume", "", false, target);
add_menu_item(menu, " 100%", "setVolume100", (current_volume - 1.0).abs() < 0.01, target);
add_menu_item(menu, " 75%", "setVolume75", (current_volume - 0.75).abs() < 0.01, target);
add_menu_item(menu, " 50%", "setVolume50", (current_volume - 0.5).abs() < 0.01, target);
add_menu_item(menu, " 25%", "setVolume25", (current_volume - 0.25).abs() < 0.01, target);
add_separator(menu);
let current_profile = {
let state = crate::APP_STATE.lock();
state.keyboard_profile.clone()
};
add_menu_item(menu, "Keyboard Profile", "", false, target);
let profiles = fs::read_dir("assets/keyboards")
.unwrap_or_else(|_| panic!("Failed to read keyboards directory"))
.filter_map(|entry| entry.ok())
.filter_map(|entry| entry.file_name().into_string().ok())
.filter(|name| !name.starts_with('.') && name != "test-profile")
.collect::<Vec<_>>();
for profile in &profiles {
let display_name = profile.replace("-v1", "").replace('-', " ");
add_menu_item(menu, &format!(" {}", display_name), &format!("setProfile_{}", profile), current_profile == *profile, target);
}
add_separator(menu);
add_menu_item(menu, "Quit", "quit", false, target);
let _: () = msg_send![status_item, setMenu:menu];
let _: () = msg_send![status_item, retain];
Ok(Self {
status_item,
menu,
pool,
target,
})
}
}
}
impl Drop for TrayIcon {
fn drop(&mut self) {
unsafe {
let _: () = msg_send![self.target, release];
let _: () = msg_send![self.status_item, release];
let _: () = msg_send![self.menu, release];
let _: () = msg_send![self.pool, drain];
}
}
}
unsafe fn create_menu_item(title: &str, action: &str, checked: bool, target: id) -> id {
info!("Creating menu item: '{}' with action: '{}'", title, action);
let title = NSString::alloc(nil).init_str(title);
let selector = if action.is_empty() {
info!("Empty action, creating menu item without selector");
None
} else {
info!("Registering selector for action: {}", action);
Some(Sel::register(action))
};
let item = NSMenuItem::alloc(nil);
let empty_string = NSString::alloc(nil).init_str("");
match selector {
Some(sel) => {
info!("Initializing menu item with selector");
let _: () = msg_send![item,
initWithTitle:title
action:sel
keyEquivalent:empty_string];
let _: () = msg_send![item, setTarget:target];
}
None => {
info!("Initializing menu item without selector");
let _: () = msg_send![item,
initWithTitle:title
action:nil
keyEquivalent:empty_string];
}
}
if checked {
info!("Setting menu item state to checked");
let _: () = msg_send![item, setState: 1];
}
item
}
unsafe fn add_menu_item(menu: id, title: &str, action: &str, checked: bool, target: id) {
let item = create_menu_item(title, action, checked, target);
let _: () = msg_send![menu, addItem: item];
}
unsafe fn add_separator(menu: id) {
let separator = NSMenuItem::separatorItem(nil);
let _: () = msg_send![menu, addItem: separator];
}
unsafe fn register_menu_target_class() -> *const Class {
let superclass = class!(NSObject);
let mut decl = objc::declare::ClassDecl::new("MenuTarget", superclass).unwrap();
decl.add_ivar::<id>("menu");
decl.add_ivar::<f32>("pending_volume");
decl.add_ivar::<id>("pending_profile");
extern "C" fn handle_action(_this: &Object, _sel: Sel) {
println!("Action received: {:?}", _sel);
let sel_name = unsafe {
let name = objc::runtime::sel_getName(_sel);
std::ffi::CStr::from_ptr(name).to_string_lossy().to_string()
};
println!("Selector name: {}", sel_name);
match sel_name.as_str() {
"toggleSound" => {
println!("Toggle sound action detected");
let mut state = crate::APP_STATE.lock();
state.enabled = !state.enabled;
println!("Sound {}", if state.enabled { "enabled" } else { "disabled" });
if let Err(e) = state.save() {
println!("Failed to save configuration: {}", e);
}
unsafe {
if let Some(menu_item) = get_menu_item_for_action(_this, "toggleSound") {
println!("Found toggle menu item, updating state to {}", state.enabled);
let _: () = msg_send![menu_item, setState: if state.enabled { 1 } else { 0 }];
}
}
},
"setVolume25" => handle_volume(_this, 0.25),
"setVolume50" => handle_volume(_this, 0.5),
"setVolume75" => handle_volume(_this, 0.75),
"setVolume100" => handle_volume(_this, 1.0),
sel_name if sel_name.starts_with("setProfile_") => {
let profile = sel_name.strip_prefix("setProfile_").unwrap();
handle_profile(_this, profile);
},
"quit" => {
println!("Quit action detected");
std::process::exit(0);
},
_ => println!("Unknown action: {}", sel_name)
}
}
fn handle_volume(_this: &Object, volume: f32) {
println!("Set volume action called with value: {}", volume);
let mut state = crate::APP_STATE.lock();
state.volume = volume;
println!("Volume set to {}", volume);
if let Err(e) = state.save() {
println!("Failed to save configuration: {}", e);
}
unsafe {
for name in &["setVolume25", "setVolume50", "setVolume75", "setVolume100"] {
if let Some(menu_item) = get_menu_item_for_action(_this, name) {
println!("Unchecking volume {}", name);
let _: () = msg_send![menu_item, setState: 0];
}
}
let action_name = match volume {
0.25 => "setVolume25",
0.50 => "setVolume50",
0.75 => "setVolume75",
1.0 => "setVolume100",
_ => return
};
if let Some(menu_item) = get_menu_item_for_action(_this, action_name) {
println!("Checking volume {}", action_name);
let _: () = msg_send![menu_item, setState: 1];
}
}
}
fn handle_profile(_this: &Object, profile: &str) {
println!("Set profile action called with value: {}", profile);
let mut state = crate::APP_STATE.lock();
state.keyboard_profile = profile.to_string();
println!("Profile set to {}", profile);
if let Err(e) = state.save() {
println!("Failed to save configuration: {}", e);
}
unsafe {
let ptr = _this as *const _ as *mut Object;
let menu: id = *(*ptr).get_ivar("menu");
let count: usize = msg_send![menu, numberOfItems];
for i in 0..count {
let item: id = msg_send![menu, itemAtIndex:i];
let action: Sel = msg_send![item, action];
let action_name = objc::runtime::sel_getName(action);
if let Ok(name) = std::ffi::CStr::from_ptr(action_name).to_str() {
if name.starts_with("setProfile_") {
let _: () = msg_send![item, setState:0];
}
}
}
let action_name = format!("setProfile_{}", profile);
if let Some(menu_item) = get_menu_item_for_action(_this, &action_name) {
println!("Checking profile {}", action_name);
let _: () = msg_send![menu_item, setState:1];
}
}
}
extern "C" fn set_menu(_this: &Object, _sel: Sel, menu: id) {
unsafe {
println!("Set menu called");
let ptr = _this as *const _ as *mut Object;
(*ptr).set_ivar("menu", menu);
}
}
unsafe {
println!("Registering menu target class methods");
decl.add_method(sel!(toggleSound), handle_action as extern "C" fn(&Object, Sel));
decl.add_method(sel!(setVolume25), handle_action as extern "C" fn(&Object, Sel));
decl.add_method(sel!(setVolume50), handle_action as extern "C" fn(&Object, Sel));
decl.add_method(sel!(setVolume75), handle_action as extern "C" fn(&Object, Sel));
decl.add_method(sel!(setVolume100), handle_action as extern "C" fn(&Object, Sel));
let profiles = fs::read_dir("assets/keyboards")
.unwrap_or_else(|_| panic!("Failed to read keyboards directory"))
.filter_map(|entry| entry.ok())
.filter_map(|entry| entry.file_name().into_string().ok())
.filter(|name| !name.starts_with('.') && name != "test-profile");
for profile in profiles {
let selector = format!("setProfile_{}", profile);
decl.add_method(Sel::register(&selector), handle_action as extern "C" fn(&Object, Sel));
}
decl.add_method(sel!(quit), handle_action as extern "C" fn(&Object, Sel));
decl.add_method(sel!(setMenu:), set_menu as extern "C" fn(&Object, Sel, id));
}
decl.register()
}
unsafe fn get_menu_item_for_action(target: &Object, action: &str) -> Option<id> {
let ptr = target as *const _ as *mut Object;
let menu: id = *(*ptr).get_ivar("menu");
if menu == nil {
println!("Menu is nil when searching for action: {}", action);
return None;
}
let count: usize = msg_send![menu, numberOfItems];
let sel = Sel::register(action);
println!("Searching for menu item with action: {} among {} items", action, count);
for i in 0..count {
let item: id = msg_send![menu, itemAtIndex:i];
let item_sel: Sel = msg_send![item, action];
if item_sel == sel {
println!("Found menu item for action: {}", action);
return Some(item);
}
}
println!("Could not find menu item for action: {}", action);
None
}