aethermap_gui/
focus_tracker.rs1use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::Arc;
8use tokio::sync::mpsc;
9use serde::{Deserialize, Serialize};
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 {
125 FocusPortal::try_new().await
126 })
127 .await
128 .ok()
129 .and_then(|r| r);
130
131 Self {
132 portal: portal.map(Arc::new),
133 running: Arc::new(AtomicBool::new(false)),
134 }
135 }
136
137 pub fn is_available(&self) -> bool {
139 self.portal.is_some()
140 }
141
142 pub async fn start(&self, _tx: mpsc::Sender<FocusEvent>) -> Result<(), String> {
156 if self.running.swap(true, Ordering::SeqCst) {
157 return Err("Focus tracking is already running".to_string());
158 }
159
160 let running = self.running.clone();
161 let _portal = self.portal.clone();
162
163 tokio::spawn(async move {
164 tracing::info!("Focus tracking task started");
165
166 if _portal.is_some() {
167 while running.load(Ordering::SeqCst) {
169 tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
177 }
178 } else {
179 tracing::warn!("Focus tracking portal unavailable, task exiting");
180 }
181
182 tracing::info!("Focus tracking task stopped");
183 });
184
185 Ok(())
186 }
187
188 pub fn stop(&self) {
193 self.running.store(false, Ordering::SeqCst);
194 }
195
196 pub fn is_running(&self) -> bool {
198 self.running.load(Ordering::SeqCst)
199 }
200}
201
202impl Default for FocusTracker {
203 fn default() -> Self {
204 let rt = tokio::runtime::Runtime::new()
206 .expect("Failed to create tokio runtime");
207
208 rt.block_on(Self::new())
209 }
210}
211
212pub async fn start_focus_tracking<F>(
225 callback: F,
226) -> Result<(FocusTracker, tokio::task::JoinHandle<()>), String>
227where
228 F: Fn(FocusEvent) + Send + 'static,
229{
230 let tracker = FocusTracker::new().await;
231
232 if !tracker.is_available() {
233 return Err("Focus tracking portal unavailable".to_string());
234 }
235
236 let (tx, mut rx) = mpsc::channel(32);
237 tracker.start(tx).await?;
238
239 let handle = tokio::spawn(async move {
240 while let Some(event) = rx.recv().await {
241 callback(event);
242 }
243 });
244
245 Ok((tracker, handle))
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn test_focus_event_creation() {
254 let event = FocusEvent::new("org.alacritty".to_string(), Some("Alacritty".to_string()));
255 assert_eq!(event.app_id, "org.alacritty");
256 assert_eq!(event.window_title, Some("Alacritty".to_string()));
257 }
258
259 #[test]
260 fn test_focus_event_from_app_id() {
261 let event = FocusEvent::from_app_id("firefox");
262 assert_eq!(event.app_id, "firefox");
263 assert_eq!(event.window_title, None);
264 }
265
266 #[test]
267 fn test_focus_event_matches_exact() {
268 let event = FocusEvent::from_app_id("org.alacritty");
269 assert!(event.matches("org.alacritty"));
270 assert!(!event.matches("org.mozilla.firefox"));
271 }
272
273 #[test]
274 fn test_focus_event_matches_wildcard() {
275 let event = FocusEvent::from_app_id("org.alacritty");
276 assert!(event.matches("*"));
277 }
278
279 #[test]
280 fn test_focus_event_matches_suffix() {
281 let event = FocusEvent::from_app_id("org.mozilla.firefox");
282 assert!(event.matches(".firefox"));
283 assert!(event.matches(".mozilla.firefox"));
284 assert!(!event.matches(".alacritty"));
285 }
286
287 #[test]
288 fn test_focus_event_matches_prefix() {
289 let event = FocusEvent::from_app_id("org.mozilla.firefox");
290 assert!(event.matches("org.mozilla."));
291 assert!(event.matches("org."));
292 assert!(!event.matches("com."));
293 }
294
295 #[tokio::test]
296 async fn test_focus_tracker_creation() {
297 let tracker = FocusTracker::new().await;
298 assert!(!tracker.is_running());
300 }
301
302 #[test]
303 fn test_focus_tracker_default() {
304 let tracker = FocusTracker::default();
305 assert!(!tracker.is_running());
306 }
307}