Skip to main content

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}