feat(aw): scan both active foreground and visible desktop windows for distractions

Previously, fallback window detection only checked user32.GetForegroundWindow().
If the user had YouTube Shorts open in Chrome on a second monitor or behind the terminal,
it was ignored.

Now check_for_distraction():
1. Checks the active foreground window first.
2. If not a distraction, scans all visible top-level desktop windows via find_window_by_match().
3. Immediately wakes the cat from sleeping or sitting if any distraction window is discovered.
This commit is contained in:
2026-09-08 23:38:00 +02:00
parent 6204a88792
commit a969c91c28
+55 -36
View File
@@ -169,57 +169,76 @@ class ActivityWatchClient:
"""Signals the background worker to exit."""
self._running = False
def check_for_distraction(self) -> Optional[DistractionEvent]:
"""
Checks current active window against distraction rules.
Uses ActivityWatch if available, otherwise falls back to native Win32.
"""
app_name = ""
window_title = ""
active_window_info: Optional[WindowInfo] = None
# 1. Try ActivityWatch
aw_event = self.get_current_window_event()
if aw_event:
app_name = (aw_event.get("app") or "").lower()
window_title = aw_event.get("title") or ""
# Locate corresponding Win32 HWND for screen coordinates and close button
active_window_info = WindowManager.find_window_by_match(
title_predicate=lambda t: window_title.lower() in t.lower() or t.lower() in window_title.lower(),
process_predicate=lambda p: app_name in p.lower(),
)
# 2. Fallback to native Win32 if ActivityWatch is unavailable or HWND not found
if not active_window_info and self.config.auto_fallback_to_win32:
active_window_info = WindowManager.get_foreground_window_info()
if active_window_info:
app_name = active_window_info.process_name.lower()
window_title = active_window_info.title
if not active_window_info or not window_title:
return None
def _match_event(self, app_name: str, window_title: str, win_info: WindowInfo) -> Optional[DistractionEvent]:
"""Evaluates a window title and process name against distraction rules."""
title_lower = window_title.lower()
app_lower = app_name.lower()
# Check for title keyword matches (e.g. "shorts", "reels", "tiktok")
for kw in self.config.distraction_keywords:
if kw.lower() in title_lower:
return DistractionEvent(
app=app_name,
title=window_title,
matched_rule=f"keyword '{kw}'",
window_info=active_window_info,
window_info=win_info,
)
# Check for application process matches
for target_app in self.config.distraction_apps:
if target_app.lower() in app_name:
if target_app.lower() in app_lower:
return DistractionEvent(
app=app_name,
title=window_title,
matched_rule=f"app '{target_app}'",
window_info=active_window_info,
window_info=win_info,
)
return None
def check_for_distraction(self) -> Optional[DistractionEvent]:
"""
Checks current active window and visible background windows against distraction rules.
Uses ActivityWatch if available, otherwise falls back to native Win32.
"""
# 1. Try ActivityWatch (non-blocking read of cached event)
aw_event = self.get_current_window_event()
if aw_event:
app_name = (aw_event.get("app") or "").lower()
window_title = aw_event.get("title") or ""
active_window_info = WindowManager.find_window_by_match(
title_predicate=lambda t: window_title.lower() in t.lower() or t.lower() in window_title.lower(),
process_predicate=lambda p: app_name in p.lower(),
)
if active_window_info:
matched = self._match_event(app_name, window_title, active_window_info)
if matched:
return matched
if not self.config.auto_fallback_to_win32:
return None
# 2. Native Win32: Check foreground window first
fg_info = WindowManager.get_foreground_window_info()
if fg_info and fg_info.title:
matched = self._match_event(fg_info.process_name, fg_info.title, fg_info)
if matched:
return matched
# 3. Native Win32: If foreground window is not a distraction, check any visible desktop window!
# This catches distraction windows open in the background, on a 2nd monitor, or when user clicks away
def title_matches(t: str) -> bool:
tl = t.lower()
return any(kw.lower() in tl for kw in self.config.distraction_keywords)
def app_matches(p: str) -> bool:
pl = p.lower()
return any(a.lower() in pl for a in self.config.distraction_apps)
matched_window = WindowManager.find_window_by_match(
title_predicate=title_matches if self.config.distraction_keywords else None,
process_predicate=app_matches if self.config.distraction_apps else None,
)
if matched_window and matched_window.title:
return self._match_event(matched_window.process_name, matched_window.title, matched_window)
return None