aethermap_gui/
focus_tracker.rs1use serde::{Deserialize, Serialize};
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::Arc;
9use tokio::sync::mpsc;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct FocusEvent {
16 pub app_id: String,
19 pub window_title: Option<String>,
22}
23
24impl FocusEvent {
25 pub fn new(app_id: impl Into<String>, window_title: Option<String>) -> Self {
27 Self {
28 app_id: app_id.into(),
29 window_title,
30 }
31 }
32
33 pub fn from_app_id(app_id: impl Into<String>) -> Self {
35 Self {
36 app_id: app_id.into(),
37 window_title: None,
38 }
39 }
40
41 pub fn matches(&self, pattern: &str) -> bool {
43 if pattern == "*" {
44 return true;
45 }
46
47 if self.app_id == pattern {
49 return true;
50 }
51
52 if pattern.starts_with('.') {
54 return self.app_id.ends_with(pattern);
55 }
56
57 if pattern.ends_with('.') {
59 return self.app_id.starts_with(pattern);
60 }
61
62 false
63 }
64}
65
66pub struct FocusTracker {
72 portal: Option<Arc<FocusPortal>>,
74 running: Arc<AtomicBool>,
76}
77
78#[allow(dead_code)]
80struct FocusPortal {
81 available: bool,
83 backend: String,
85}
86
87impl FocusPortal {
88 async fn try_new() -> Option<Self> {
93 if std::env::var("WAYLAND_DISPLAY").is_err() {
95 tracing::warn!("Not running on Wayland, focus tracking unavailable");
96 return None;
97 }
98
99 match ashpd::desktop::global_shortcuts::GlobalShortcuts::new().await {
101 Ok(_) => {
102 tracing::info!("Successfully connected to xdg-desktop-portal");
103 Some(Self {
104 available: true,
105 backend: "xdg-desktop-portal".to_string(),
106 })
107 }
108 Err(e) => {
109 tracing::warn!("Failed to connect to xdg-desktop-portal: {}", e);
110 tracing::warn!("Focus tracking will be unavailable");
111 None
112 }
113 }
114 }
115}
116
117impl FocusTracker {
118 pub async fn new() -> Self {
124 let portal = tokio::task::spawn(async { FocusPortal::try_new().await })
125 .await
126 .ok()
127 .and_then(|r| r);
128
129 Self {
130 portal: portal.map(Arc::new),
131 running: Arc::new(AtomicBool::new(false)),
132 }
133 }
134
135 pub fn is_available(&self) -> bool {
137 self.portal.is_some()
138 }
139
140 pub async fn start(&self, _tx: mpsc::Sender<FocusEvent>) -> Result<(), String> {
154 if self.running.swap(true, Ordering::SeqCst) {
155 return Err("Focus tracking is already running".to_string());
156 }
157
158 let running = self.running.clone();
159 let _portal = self.portal.clone();
160
161 tokio::spawn(async move {
162 tracing::info!("Focus tracking task started");
163
164 if _portal.is_some() {
165 while running.load(Ordering::SeqCst) {
167 tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
175 }
176 } else {
177 tracing::warn!("Focus tracking portal unavailable, task exiting");
178 }
179
180 tracing::info!("Focus tracking task stopped");
181 });
182
183 Ok(())
184 }
185
186 pub fn stop(&self) {
191 self.running.store(false, Ordering::SeqCst);
192 }
193
194 pub fn is_running(&self) -> bool {
196 self.running.load(Ordering::SeqCst)
197 }
198}
199
200impl Default for FocusTracker {
201 fn default() -> Self {
202 let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
204
205 rt.block_on(Self::new())
206 }
207}
208
209pub async fn start_focus_tracking<F>(
222 callback: F,
223) -> Result<(FocusTracker, tokio::task::JoinHandle<()>), String>
224where
225 F: Fn(FocusEvent) + Send + 'static,
226{
227 let tracker = FocusTracker::new().await;
228
229 if !tracker.is_available() {
230 return Err("Focus tracking portal unavailable".to_string());
231 }
232
233 let (tx, mut rx) = mpsc::channel(32);
234 tracker.start(tx).await?;
235
236 let handle = tokio::spawn(async move {
237 while let Some(event) = rx.recv().await {
238 callback(event);
239 }
240 });
241
242 Ok((tracker, handle))
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn test_focus_event_creation() {
251 let event = FocusEvent::new("org.alacritty".to_string(), Some("Alacritty".to_string()));
252 assert_eq!(event.app_id, "org.alacritty");
253 assert_eq!(event.window_title, Some("Alacritty".to_string()));
254 }
255
256 #[test]
257 fn test_focus_event_from_app_id() {
258 let event = FocusEvent::from_app_id("firefox");
259 assert_eq!(event.app_id, "firefox");
260 assert_eq!(event.window_title, None);
261 }
262
263 #[test]
264 fn test_focus_event_matches_exact() {
265 let event = FocusEvent::from_app_id("org.alacritty");
266 assert!(event.matches("org.alacritty"));
267 assert!(!event.matches("org.mozilla.firefox"));
268 }
269
270 #[test]
271 fn test_focus_event_matches_wildcard() {
272 let event = FocusEvent::from_app_id("org.alacritty");
273 assert!(event.matches("*"));
274 }
275
276 #[test]
277 fn test_focus_event_matches_suffix() {
278 let event = FocusEvent::from_app_id("org.mozilla.firefox");
279 assert!(event.matches(".firefox"));
280 assert!(event.matches(".mozilla.firefox"));
281 assert!(!event.matches(".alacritty"));
282 }
283
284 #[test]
285 fn test_focus_event_matches_prefix() {
286 let event = FocusEvent::from_app_id("org.mozilla.firefox");
287 assert!(event.matches("org.mozilla."));
288 assert!(event.matches("org."));
289 assert!(!event.matches("com."));
290 }
291
292 #[tokio::test]
293 async fn test_focus_tracker_creation() {
294 let tracker = FocusTracker::new().await;
295 assert!(!tracker.is_running());
297 }
298
299 #[test]
300 fn test_focus_tracker_default() {
301 let tracker = FocusTracker::default();
302 assert!(!tracker.is_running());
303 }
304}