// This file is part of elem . // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, version 3. // // This program is distributed in the hope that it will be useful, but // WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU // General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . // // Copyright (C) 2022-2022 Fuwn // SPDX-License-Identifier: GPL-3.0-only use std::{ ffi::OsStr, iter::once, os::windows::ffi::OsStrExt, sync::{Arc, Mutex}, }; use tao::{ event::Event, event_loop::ControlFlow, menu, menu::CustomMenuItem, system_tray, system_tray::Icon, }; use winapi::um::{wincon::GetConsoleWindow, winuser, winuser::ShowWindow}; const DEFAULT_UPDATE_FREQUENCY: u64 = 60000; struct TrayInner { devices: Vec, selected_device_display_name: Option, update_frequency: u64, } pub struct Tray { inner: Arc>, } impl Tray { pub fn new(update_frequency: Option) -> Self { Self { inner: Arc::new(Mutex::new(TrayInner { devices: vec![], selected_device_display_name: None, update_frequency: { update_frequency.map_or_else( || { debug!( "using default update frequency of {}ms", DEFAULT_UPDATE_FREQUENCY ); DEFAULT_UPDATE_FREQUENCY }, |update_frequency| match update_frequency.parse() { Ok(update_frequency) => { debug!( "using custom update frequency of {}ms", update_frequency ); update_frequency } Err(e) => { warn!( "invalid update frequency, using default of {}ms: {}", DEFAULT_UPDATE_FREQUENCY, e ); DEFAULT_UPDATE_FREQUENCY } }, ) }, })), } } /// Force an icon by bypassing the device state check. /// /// Useful for displaying informational icons fn force_icon(code: &str) -> Icon { trace!("building forced icon '{}'", code); let image = image::load_from_memory(&crate::ascii_art::number_to_image( // This will never fail because we as the author provide a code that is // always a number in string form. code.parse().unwrap(), )) .unwrap_or_else(|_| quit(&format!("failed to load forced icon '{code}'"))) .into_rgba8(); let (width, height) = image.dimensions(); let icon = Icon::from_rgba(image.into_raw(), width, height).unwrap_or_else(|_| { quit(&format!("failed to convert forced icon '{code}' to rgba")) }); trace!("built forced icon '{}'", code); icon } /// Create a tray icon compatible icon from a devices battery level fn icon(selected_device_display_name: &Option) -> Icon { trace!( "building icon for display name '{:?}'", selected_device_display_name ); let image = image::load_from_memory(&if selected_device_display_name == &Some("43770".to_string()) || selected_device_display_name == &Some("Dummy (Debug)".to_string()) { crate::ascii_art::number_to_image(43770) } else { crate::ascii_art::number_to_image( crate::logitech::device( &selected_device_display_name .clone() .unwrap_or_else(|| "1337".to_string()), ) .payload() .percentage(), ) }) .unwrap_or_else(|_| { quit(&format!( "failed to load icon for display name '{:?}'", selected_device_display_name )) }) .into_rgba8(); let (width, height) = image.dimensions(); let icon = Icon::from_rgba(image.into_raw(), width, height).unwrap_or_else(|_| { quit(&format!( "failed to convert icon for display name '{:?}' to rgba", selected_device_display_name )) }); trace!( "built icon for display name '{:?}'", selected_device_display_name ); icon } /// Checks and update the battery level of non-dummy devices fn watchman( icon_self: &Arc>, system_tray_updater: &Arc>, ) { loop { std::thread::sleep(std::time::Duration::from_millis( icon_self.lock().unwrap().update_frequency, )); trace!("checking for system tray icon update"); // Only refresh the tray icon (battery level) if the device is not a dummy // device if icon_self.lock().unwrap().selected_device_display_name != Some("Dummy (Debug)".to_string()) { // "80085" is the internal code for ellipsis. An ellipsis is displayed // while the battery level is being fetched. system_tray_updater .lock() .unwrap() .set_icon(Self::force_icon("80085")); system_tray_updater.lock().unwrap().set_tooltip(&format!( "elem (updating {} from watchman)", &icon_self .lock() .unwrap() .selected_device_display_name .clone() .unwrap_or_else(|| "Dummy (Debug)".to_string()) )); trace!("updating system tray icon from watchman"); let icon = Self::icon(&Some( icon_self .lock() .unwrap() .selected_device_display_name .clone() .unwrap_or_else(|| "Dummy (Debug)".to_string()), )); system_tray_updater.lock().unwrap().set_tooltip(&format!( "elem ({})", icon_self .lock() .unwrap() .selected_device_display_name .clone() .unwrap_or_else(|| "Dummy (Debug)".to_string()), )); system_tray_updater.lock().unwrap().set_icon(icon); trace!("updated system tray icon",); } } } /// Run the tray icon and event loop #[allow(clippy::too_many_lines)] pub fn run(&mut self) { let local_self = self.inner.clone(); // Grab all wireless devices let devices = crate::logitech::wireless_devices(); // Set up the event loop and tray icon-related stuff let event_loop = tao::event_loop::EventLoop::new(); let main_tray_id = tao::TrayId::new("main-tray"); let mut tray_menu = menu::ContextMenu::new(); tray_menu.add_item( menu::MenuItemAttributes::new(&format!( "Update frequency: {}ms", local_self.lock().unwrap().update_frequency )) .with_enabled(false), ); // Adding all wireless devices to the tray icons devices menu tray_menu.add_submenu("Devices", true, { let mut menu = menu::ContextMenu::new(); let mut devices = devices .values() .collect::>(); // Making sure that the last device, the default device, is never the // dummy device // // There will always be a last device because the dummy device is always // present. if devices.last().unwrap().display_name == "Dummy (Debug)" { // We can always pop the last device because there will always be a // last element even if there is only one. let last = devices.pop().unwrap(); devices.insert(0, last); } local_self.lock().unwrap().devices.clear(); for (i, device_info) in devices.iter().enumerate() { let mut id = menu .add_item(menu::MenuItemAttributes::new(&device_info.display_name)); if i == devices.len() - 1 { id.set_selected(true); local_self.lock().unwrap().selected_device_display_name = Some(device_info.display_name.to_string()); } local_self.lock().unwrap().devices.push(id); } menu }); let mut log_window = tray_menu.add_item(menu::MenuItemAttributes::new("Show Log Window")); let mut log_window_state = false; let quit = tray_menu.add_item(menu::MenuItemAttributes::new("Quit")); let system_tray = Arc::new(Mutex::new( system_tray::SystemTrayBuilder::new( Self::icon(&local_self.lock().unwrap().selected_device_display_name), Some(tray_menu), ) .with_id(main_tray_id) .with_tooltip("elem") .build(&event_loop) .unwrap_or_else(|_| self::quit("failed to build system tray")), )); let mut devices = local_self.lock().unwrap().devices.clone(); let icon_self = self.inner.clone(); let system_tray_updater = system_tray.clone(); // An thread which updates the tray icon (battery level) every minute std::thread::spawn(move || { Self::watchman(&icon_self, &system_tray_updater); }); // The event loop which takes care of switching devices, handling menu // events, and updating the device icon (battery level) event_loop.run(move |event, _event_loop, control_flow| { *control_flow = ControlFlow::Wait; match event { Event::MenuEvent { menu_id, origin: menu::MenuType::ContextMenu, .. } => { if menu_id == quit.clone().id() { info!("quitting"); *control_flow = ControlFlow::Exit; } if menu_id == log_window.clone().id() { if log_window_state { unsafe { ShowWindow(GetConsoleWindow(), winuser::SW_HIDE) }; log_window.set_title("Show Log Window"); trace!("hiding log window from intent"); log_window_state = false; } else { unsafe { ShowWindow(GetConsoleWindow(), winuser::SW_SHOW) }; log_window.set_title("Hide Log Window"); trace!("showing log window from intent"); log_window_state = true; } } // Checking to see if a new device was selected // // If a new device was selected, update the icon and update the menu // accordingly. if devices.iter().any(|d| d.clone().id() == menu_id) { for device in &mut devices { if menu_id == device.clone().id() { debug!("selected device '{}'", device.clone().title()); device.set_selected(true); // Ellipsis icon to indicate background process system_tray .lock() .unwrap() .set_icon(Self::force_icon("80085")); trace!("updating system tray icon from intent"); // If the selected device is the dummy device, set a dummy icon if device.0.title() == "Dummy (Debug)" { system_tray .lock() .unwrap() .set_icon(Self::force_icon("43770")); } else { system_tray .lock() .unwrap() .set_icon(Self::icon(&Some(device.0.title()))); } trace!("updated system tray icon from intent"); system_tray.lock().unwrap().set_tooltip(&format!( "elem (updating {} from intent)", device.0.title() )); local_self.lock().unwrap().selected_device_display_name = Some(device.0.title()); system_tray .lock() .unwrap() .set_tooltip(&format!("elem ({})", device.0.title())); info!( "completed device selection ({}) and associated tasks", device.0.title() ); } else { device.set_selected(false); } } } } Event::TrayEvent { id, event, .. } => { if id == main_tray_id && event == tao::event::TrayEvent::LeftClick && !log_window_state { unsafe { ShowWindow(GetConsoleWindow(), winuser::SW_SHOW) }; trace!("showing log window from tray event"); } } _ => {} } }); } } pub fn quit(message: &str) -> ! { message_box(message); panic!("{}", message); } pub fn message_box( // title: &str, message: &str, // buttons: u32, icon: u32 ) -> i32 { let buttons = winuser::MB_OK; let icon = winuser::MB_ICONEXCLAMATION; let other_options = winuser::MB_SETFOREGROUND | winuser::MB_TOPMOST; unsafe { winuser::MessageBoxW( std::ptr::null_mut(), OsStr::new(message) .encode_wide() .chain(once(0)) .collect::>() .as_ptr(), OsStr::new("elem") // title .encode_wide() .chain(once(0)) .collect::>() .as_ptr(), buttons | icon | other_options, ) } }