a lightweight, interval-based utility to combat digital strain through "Ma" (intentional pauses) for the eyes and body.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add system tray icon

+129
+4
Cargo.toml
··· 33 33 log = "0.4" 34 34 env_logger = "0.11" 35 35 36 + # Image loading (for tray icons) 37 + image = { version = "0.25", default-features = false, features = ["png"] } 38 + 36 39 # Misc 37 40 dirs = "5" 38 41 thiserror = "1" ··· 45 48 windows = { version = "0.58", features = [ 46 49 "Win32_UI_Input_KeyboardAndMouse", 47 50 "Win32_Foundation", 51 + "Win32_System_SystemInformation", 48 52 ] } 49 53 50 54 [build-dependencies]
assets/icon_active.png

This is a binary file and will not be displayed.

assets/icon_paused.png

This is a binary file and will not be displayed.

+125
src/tray.rs
··· 1 + use std::time::Duration; 2 + 3 + use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; 4 + use tray_icon::{TrayIcon, TrayIconBuilder, TrayIconEvent}; 5 + 6 + use crate::timer::TimerCommand; 7 + 8 + const PAUSE_DURATIONS: &[(&str, u64)] = &[ 9 + ("15 minutes", 15 * 60), 10 + ("30 minutes", 30 * 60), 11 + ("1 hour", 60 * 60), 12 + ]; 13 + 14 + pub struct AppTray { 15 + _tray: TrayIcon, 16 + icon_active: tray_icon::Icon, 17 + icon_paused: tray_icon::Icon, 18 + item_skip: MenuItem, 19 + pause_items: Vec<(MenuItem, u64)>, 20 + item_settings: MenuItem, 21 + item_quit: MenuItem, 22 + } 23 + 24 + impl AppTray { 25 + pub fn new() -> anyhow::Result<Self> { 26 + let icon_active = load_icon(include_bytes!("../assets/icon_active.png"))?; 27 + let icon_paused = load_icon(include_bytes!("../assets/icon_paused.png"))?; 28 + 29 + let menu = Menu::new(); 30 + 31 + let item_skip = MenuItem::new("Skip to next break", true, None); 32 + menu.append(&item_skip)?; 33 + menu.append(&PredefinedMenuItem::separator())?; 34 + 35 + let pause_sub = Submenu::new("Pause for", true); 36 + let mut pause_items = Vec::new(); 37 + for (label, secs) in PAUSE_DURATIONS { 38 + let item = MenuItem::new(*label, true, None); 39 + pause_sub.append(&item)?; 40 + pause_items.push((item, *secs)); 41 + } 42 + menu.append(&pause_sub)?; 43 + menu.append(&PredefinedMenuItem::separator())?; 44 + 45 + let item_settings = MenuItem::new("Settings", true, None); 46 + menu.append(&item_settings)?; 47 + menu.append(&PredefinedMenuItem::separator())?; 48 + 49 + let item_quit = MenuItem::new("Quit", true, None); 50 + menu.append(&item_quit)?; 51 + 52 + let tray = TrayIconBuilder::new() 53 + .with_menu(Box::new(menu)) 54 + .with_tooltip("ioma — break reminder") 55 + .with_icon(icon_active.clone()) 56 + .build()?; 57 + 58 + Ok(Self { 59 + _tray: tray, 60 + icon_active, 61 + icon_paused, 62 + item_skip, 63 + pause_items, 64 + item_settings, 65 + item_quit, 66 + }) 67 + } 68 + 69 + /// Set icon to reflect paused state. 70 + pub fn set_paused(&self, paused: bool) { 71 + let _ = self._tray.set_icon(Some(if paused { 72 + self.icon_paused.clone() 73 + } else { 74 + self.icon_active.clone() 75 + })); 76 + } 77 + 78 + pub fn set_tooltip(&self, text: &str) { 79 + let _ = self._tray.set_tooltip(Some(text)); 80 + } 81 + 82 + /// Process pending tray/menu events. Call from the main event loop. 83 + /// Returns `true` if a quit was requested. 84 + pub fn process_events( 85 + &self, 86 + cmd_tx: &tokio::sync::mpsc::UnboundedSender<TimerCommand>, 87 + open_settings: &mut bool, 88 + ) -> bool { 89 + // Drain tray icon clicks (left-click opens settings on some platforms). 90 + while let Ok(event) = TrayIconEvent::receiver().try_recv() { 91 + if let tray_icon::TrayIconEvent::Click { 92 + button: tray_icon::MouseButton::Left, 93 + .. 94 + } = event 95 + { 96 + *open_settings = true; 97 + } 98 + } 99 + 100 + // Drain menu events. 101 + while let Ok(event) = MenuEvent::receiver().try_recv() { 102 + if event.id() == self.item_quit.id() { 103 + return true; 104 + } 105 + if event.id() == self.item_skip.id() { 106 + let _ = cmd_tx.send(TimerCommand::SkipToNextBreak); 107 + } 108 + if event.id() == self.item_settings.id() { 109 + *open_settings = true; 110 + } 111 + for (item, secs) in &self.pause_items { 112 + if event.id() == item.id() { 113 + let _ = cmd_tx.send(TimerCommand::PauseFor(Duration::from_secs(*secs))); 114 + } 115 + } 116 + } 117 + false 118 + } 119 + } 120 + 121 + fn load_icon(bytes: &[u8]) -> anyhow::Result<tray_icon::Icon> { 122 + let img = image::load_from_memory(bytes)?.into_rgba8(); 123 + let (w, h) = img.dimensions(); 124 + tray_icon::Icon::from_rgba(img.into_raw(), w, h).map_err(|e| anyhow::anyhow!("{e}")) 125 + }