Skip to main content

aethermap_gui/
focus_tracker.rs

1//! Focus Tracker for Wayland window focus detection
2//!
3//! This module provides window focus tracking via xdg-desktop-portal.
4//! Focus changes are detected and can be used to trigger profile switching.
5
6use serde::{Deserialize, Serialize};
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::Arc;
9use tokio::sync::mpsc;
10
11/// A window focus change event
12///
13/// Contains information about the application that gained focus.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct FocusEvent {
16    /// Application identifier (e.g., "org.alacritty", "firefox")
17    /// This is the primary identifier for profile matching.
18    pub app_id: String,
19    /// Optional window title
20    /// May be empty on some compositors due to Wayland security restrictions.
21    pub window_title: Option<String>,
22}
23
24impl FocusEvent {
25    /// Create a new focus event
26    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    /// Create a focus event with only app_id
34    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    /// Check if this event matches a given app_id pattern
42    pub fn matches(&self, pattern: &str) -> bool {
43        if pattern == "*" {
44            return true;
45        }
46
47        // Exact match
48        if self.app_id == pattern {
49            return true;
50        }
51
52        // Suffix match (e.g., ".firefox" matches "org.mozilla.firefox")
53        if pattern.starts_with('.') {
54            return self.app_id.ends_with(pattern);
55        }
56
57        // Prefix match (e.g., "org.mozilla." matches "org.mozilla.firefox")
58        if pattern.ends_with('.') {
59            return self.app_id.starts_with(pattern);
60        }
61
62        false
63    }
64}
65
66/// Focus tracker using xdg-desktop-portal
67///
68/// This tracker monitors window focus changes on Wayland compositors
69/// via the xdg-desktop-portal API. When the portal is unavailable,
70/// it gracefully degrades and provides a no-op implementation.
71pub struct FocusTracker {
72    /// Portal connection (None when unavailable)
73    portal: Option<Arc<FocusPortal>>,
74    /// Whether the tracker is currently running
75    running: Arc<AtomicBool>,
76}
77
78/// Internal portal wrapper for ashpd integration
79#[allow(dead_code)]
80struct FocusPortal {
81    /// Whether portal is available
82    available: bool,
83    /// Portal backend identifier (for logging/debugging)
84    backend: String,
85}
86
87impl FocusPortal {
88    /// Initialize the portal connection
89    ///
90    /// Returns None if xdg-desktop-portal is not available or
91    /// running on a non-Wayland session.
92    async fn try_new() -> Option<Self> {
93        // Check if we're running on Wayland
94        if std::env::var("WAYLAND_DISPLAY").is_err() {
95            tracing::warn!("Not running on Wayland, focus tracking unavailable");
96            return None;
97        }
98
99        // Check if xdg-desktop-portal is running
100        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    /// Create a new focus tracker
119    ///
120    /// Attempts to initialize the portal connection. If the portal
121    /// is unavailable, returns a tracker that will gracefully handle
122    /// all operations as no-ops.
123    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    /// Check if the portal is available
136    pub fn is_available(&self) -> bool {
137        self.portal.is_some()
138    }
139
140    /// Start tracking focus changes
141    ///
142    /// Spawns an async task that listens for focus change events
143    /// and sends them to the provided channel.
144    ///
145    /// # Arguments
146    ///
147    /// * `tx` - Channel to send focus events to
148    ///
149    /// # Returns
150    ///
151    /// Returns `Ok(())` if tracking started successfully, or an error
152    /// if tracking is already in progress or the portal is unavailable.
153    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                // Run focus monitoring loop
166                while running.load(Ordering::SeqCst) {
167                    // In a full implementation, we would:
168                    // 1. Register for focus change notifications via portal
169                    // 2. Listen for events
170                    // 3. Extract app_id and send to channel
171
172                    // For now, we provide a placeholder that
173                    // simulates the structure for future enhancement
174                    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    /// Stop tracking focus changes
187    ///
188    /// Signals the tracking task to stop. The task will exit
189    /// on its next iteration.
190    pub fn stop(&self) {
191        self.running.store(false, Ordering::SeqCst);
192    }
193
194    /// Check if tracking is currently running
195    pub fn is_running(&self) -> bool {
196        self.running.load(Ordering::SeqCst)
197    }
198}
199
200impl Default for FocusTracker {
201    fn default() -> Self {
202        // Create a runtime for the async new() call
203        let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
204
205        rt.block_on(Self::new())
206    }
207}
208
209/// Start focus tracking with a callback
210///
211/// Convenience function that creates a tracker and starts it,
212/// invoking the callback for each focus event.
213///
214/// # Arguments
215///
216/// * `callback` - Function to call with each focus event
217///
218/// # Returns
219///
220/// Returns the tracker and a handle that can be used to stop tracking.
221pub 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        // Tracker should always create successfully, even if portal unavailable
296        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}