hyprcorrect_platform/linux/tray.rs
1//! System tray (ksni-based) for the hyprcorrect daemon.
2//!
3//! Publishes a StatusNotifierItem with a small menu: Pause/Resume,
4//! Open Preferences…, Quit. Menu activations arrive on the returned
5//! channel. The pause state is shared with the daemon via an
6//! `Arc<AtomicBool>` — the tray reads it live to choose its icon,
7//! label, and SNI status — and [`TrayHandle::refresh`] pushes a
8//! property-change so SNI hosts pick up the new state immediately.
9
10use std::sync::Arc;
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::sync::mpsc::{self, Receiver, Sender};
13
14/// A menu event from the tray.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum TrayEvent {
17 /// The user picked "Pause" or "Resume" — toggle the pause flag.
18 TogglePause,
19 /// The user picked "Open Preferences…".
20 OpenPrefs,
21 /// The user picked "Quit".
22 Quit,
23}
24
25/// An error starting the tray.
26#[derive(Debug, thiserror::Error)]
27pub enum TrayError {
28 /// The ksni service (D-Bus / SNI publication) could not start.
29 #[error("ksni: {0}")]
30 Ksni(String),
31}
32
33/// A live handle to the running tray. Holding it keeps the SNI
34/// service registered; dropping it tears it down.
35pub struct TrayHandle {
36 inner: ksni::blocking::Handle<HyprcorrectTray>,
37}
38
39impl TrayHandle {
40 /// Re-publish the tray's properties so SNI hosts pick up changes
41 /// to pause state immediately. Cheap: the closure is a no-op —
42 /// state lives in the shared `Arc<AtomicBool>`.
43 pub fn refresh(&self) {
44 self.inner.update(|_| {});
45 }
46}
47
48/// A pre-rasterized icon pixmap to publish via SNI. The data is
49/// ARGB32 in network (big-endian) byte order — that's the format
50/// the StatusNotifierItem spec requires.
51pub struct IconPixmap {
52 pub width: i32,
53 pub height: i32,
54 pub argb: Vec<u8>,
55}
56
57/// Start the tray. Returns a [`TrayHandle`] (the caller must hold
58/// it for the life of the daemon) and a receiver of menu activations.
59///
60/// `paused` is the shared pause flag; the tray reads it live and
61/// switches between `active_icon` and `paused_icon` to reflect it.
62/// Each icon is a list of pre-rasterized pixmaps the platform layer
63/// publishes as-is; the caller (the daemon) owns rasterization so
64/// this crate doesn't have to drag in resvg/tiny-skia.
65///
66/// # Errors
67///
68/// See [`TrayError`].
69pub fn start(
70 paused: Arc<AtomicBool>,
71 active_icon: Vec<IconPixmap>,
72 paused_icon: Vec<IconPixmap>,
73) -> Result<(TrayHandle, Receiver<TrayEvent>), TrayError> {
74 use ksni::blocking::TrayMethods;
75
76 let (events_tx, events_rx) = mpsc::channel();
77 let tray = HyprcorrectTray {
78 events_tx,
79 paused,
80 active_icon,
81 paused_icon,
82 };
83 let inner = tray.spawn().map_err(|e| TrayError::Ksni(e.to_string()))?;
84 Ok((TrayHandle { inner }, events_rx))
85}
86
87struct HyprcorrectTray {
88 events_tx: Sender<TrayEvent>,
89 paused: Arc<AtomicBool>,
90 active_icon: Vec<IconPixmap>,
91 paused_icon: Vec<IconPixmap>,
92}
93
94impl HyprcorrectTray {
95 fn is_paused(&self) -> bool {
96 self.paused.load(Ordering::Relaxed)
97 }
98}
99
100impl ksni::Tray for HyprcorrectTray {
101 fn id(&self) -> String {
102 "hyprcorrect".to_string()
103 }
104
105 fn title(&self) -> String {
106 "hyprcorrect".to_string()
107 }
108
109 fn category(&self) -> ksni::Category {
110 ksni::Category::ApplicationStatus
111 }
112
113 fn status(&self) -> ksni::Status {
114 // Stay `Active` even while paused — many SNI hosts (Waybar,
115 // for example) hide `Passive` items entirely, which would make
116 // the menu unreachable. Pause is conveyed by the icon swap.
117 ksni::Status::Active
118 }
119
120 fn icon_name(&self) -> String {
121 // Empty string forces SNI hosts to skip the icon-theme
122 // lookup and use [`icon_pixmap`] below — themed-name
123 // resolution is inconsistent across hosts (waybar can pick
124 // a small pre-rasterized PNG variant from the theme even
125 // when we publish a pixmap, and that variant is often a
126 // different drawing). Mirrors vernier's tray approach.
127 String::new()
128 }
129
130 fn icon_pixmap(&self) -> Vec<ksni::Icon> {
131 // Publish the daemon-rasterized bundled icon directly so we
132 // don't depend on the user's icon theme. SNI hosts downscale
133 // a single large pixmap (64×64) crisply to whatever bar
134 // slot they draw.
135 let src = if self.is_paused() {
136 &self.paused_icon
137 } else {
138 &self.active_icon
139 };
140 src.iter()
141 .map(|p| ksni::Icon {
142 width: p.width,
143 height: p.height,
144 data: p.argb.clone(),
145 })
146 .collect()
147 }
148
149 fn tool_tip(&self) -> ksni::ToolTip {
150 let description = if self.is_paused() {
151 "Paused. Click the tray icon to resume.".to_string()
152 } else {
153 "Press the trigger chord to fix the last word.".to_string()
154 };
155 ksni::ToolTip {
156 title: "hyprcorrect".into(),
157 description,
158 icon_name: String::new(),
159 icon_pixmap: Vec::new(),
160 }
161 }
162
163 fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
164 use ksni::MenuItem;
165 use ksni::menu::StandardItem;
166 let pause_label = if self.is_paused() { "Resume" } else { "Pause" };
167 vec![
168 StandardItem {
169 label: pause_label.into(),
170 activate: Box::new(|this: &mut HyprcorrectTray| {
171 let _ = this.events_tx.send(TrayEvent::TogglePause);
172 }),
173 ..Default::default()
174 }
175 .into(),
176 MenuItem::Separator,
177 StandardItem {
178 label: "Open Preferences…".into(),
179 activate: Box::new(|this: &mut HyprcorrectTray| {
180 let _ = this.events_tx.send(TrayEvent::OpenPrefs);
181 }),
182 ..Default::default()
183 }
184 .into(),
185 MenuItem::Separator,
186 StandardItem {
187 label: "Quit".into(),
188 activate: Box::new(|this: &mut HyprcorrectTray| {
189 let _ = this.events_tx.send(TrayEvent::Quit);
190 }),
191 ..Default::default()
192 }
193 .into(),
194 ]
195 }
196}