|
|
|
@@ -0,0 +1,356 @@
|
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
#
|
|
|
|
|
# Copyright (c) 2026 Stormux
|
|
|
|
|
#
|
|
|
|
|
# This library is free software; you can redistribute it and/or
|
|
|
|
|
# modify it under the terms of the GNU Lesser General Public
|
|
|
|
|
# License as published by the Free Software Foundation; either
|
|
|
|
|
# version 2.1 of the License, or (at your option) any later version.
|
|
|
|
|
#
|
|
|
|
|
# This library is distributed in the hope that it will be useful,
|
|
|
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
|
|
|
# Lesser General Public License for more details.
|
|
|
|
|
#
|
|
|
|
|
# You should have received a copy of the GNU Lesser General Public
|
|
|
|
|
# License along with this library; if not, write to the
|
|
|
|
|
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
|
|
|
|
|
# Boston MA 02110-1301 USA.
|
|
|
|
|
#
|
|
|
|
|
|
|
|
|
|
"""Window Title Reader plugin for Cthulhu."""
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
|
|
|
|
|
from gi.repository import GLib
|
|
|
|
|
|
|
|
|
|
from cthulhu import debug
|
|
|
|
|
from cthulhu.plugin import Plugin, cthulhu_hookimpl
|
|
|
|
|
|
|
|
|
|
xlibAvailable = True
|
|
|
|
|
xlibImportError = None
|
|
|
|
|
try:
|
|
|
|
|
from Xlib import X
|
|
|
|
|
from Xlib import display as xDisplay
|
|
|
|
|
from Xlib import error as xError
|
|
|
|
|
except Exception as error:
|
|
|
|
|
xlibAvailable = False
|
|
|
|
|
xlibImportError = error
|
|
|
|
|
X = None
|
|
|
|
|
xDisplay = None
|
|
|
|
|
xError = None
|
|
|
|
|
|
|
|
|
|
pluginLogger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class WindowTitleReader(Plugin):
|
|
|
|
|
"""Speak the active window title when toggled on."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, *positionalArgs, **keywordArgs):
|
|
|
|
|
super().__init__(*positionalArgs, **keywordArgs)
|
|
|
|
|
self._activated = False
|
|
|
|
|
self._enabled = False
|
|
|
|
|
self._pollSourceId = None
|
|
|
|
|
self._pollIntervalMs = 100
|
|
|
|
|
self._lastTitle = None
|
|
|
|
|
self._display = None
|
|
|
|
|
self._root = None
|
|
|
|
|
self._atoms = {}
|
|
|
|
|
self._wineDesktopLabel = "Wine Desktop"
|
|
|
|
|
self._kbToggleTracking = None
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, "WindowTitleReader: Plugin initialized", True)
|
|
|
|
|
|
|
|
|
|
@cthulhu_hookimpl
|
|
|
|
|
def activate(self, plugin=None):
|
|
|
|
|
if plugin is not None and plugin is not self:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if self._activated:
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, "WindowTitleReader: Already activated", True)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
self._register_keybinding()
|
|
|
|
|
self._activated = True
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, "WindowTitleReader: Activated", True)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
@cthulhu_hookimpl
|
|
|
|
|
def deactivate(self, plugin=None):
|
|
|
|
|
if plugin is not None and plugin is not self:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
self._stop_tracking()
|
|
|
|
|
self._activated = False
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, "WindowTitleReader: Deactivated", True)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def _register_keybinding(self):
|
|
|
|
|
if not self.app:
|
|
|
|
|
debug.printMessage(
|
|
|
|
|
debug.LEVEL_INFO,
|
|
|
|
|
"WindowTitleReader: No app reference; cannot register keybinding",
|
|
|
|
|
True,
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
gestureString = "kb:cthulhu+control+shift+w"
|
|
|
|
|
description = "Toggle window title reader"
|
|
|
|
|
|
|
|
|
|
self._kbToggleTracking = self.registerGestureByString(
|
|
|
|
|
self._toggle_tracking,
|
|
|
|
|
description,
|
|
|
|
|
gestureString,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if self._kbToggleTracking:
|
|
|
|
|
debug.printMessage(
|
|
|
|
|
debug.LEVEL_INFO,
|
|
|
|
|
f"WindowTitleReader: Registered keybinding {gestureString}",
|
|
|
|
|
True,
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
debug.printMessage(
|
|
|
|
|
debug.LEVEL_INFO,
|
|
|
|
|
f"WindowTitleReader: Failed to register keybinding {gestureString}",
|
|
|
|
|
True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _toggle_tracking(self, script=None, inputEvent=None):
|
|
|
|
|
if self._enabled:
|
|
|
|
|
self._stop_tracking()
|
|
|
|
|
self._present_message("Window title reader off")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
if not xlibAvailable:
|
|
|
|
|
self._present_message("Window title reader unavailable")
|
|
|
|
|
debug.printMessage(
|
|
|
|
|
debug.LEVEL_INFO,
|
|
|
|
|
f"WindowTitleReader: python-xlib unavailable: {xlibImportError}",
|
|
|
|
|
True,
|
|
|
|
|
)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
if self._start_tracking():
|
|
|
|
|
self._present_message("Window title reader on")
|
|
|
|
|
else:
|
|
|
|
|
self._present_message("Window title reader unavailable")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def _present_message(self, messageText):
|
|
|
|
|
if not self.app:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
appState = self.app.getDynamicApiManager().getAPI("CthulhuState")
|
|
|
|
|
if appState and appState.activeScript:
|
|
|
|
|
appState.activeScript.presentMessage(messageText, resetStyles=False)
|
|
|
|
|
except Exception:
|
|
|
|
|
pluginLogger.exception("WindowTitleReader: Failed to present message")
|
|
|
|
|
|
|
|
|
|
def _start_tracking(self):
|
|
|
|
|
if self._enabled:
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
self._display = xDisplay.Display()
|
|
|
|
|
self._root = self._display.screen().root
|
|
|
|
|
self._init_atoms()
|
|
|
|
|
self._pollSourceId = GLib.timeout_add(self._pollIntervalMs, self._poll_window_title)
|
|
|
|
|
self._enabled = True
|
|
|
|
|
self._poll_window_title()
|
|
|
|
|
return True
|
|
|
|
|
except Exception as error:
|
|
|
|
|
debug.printMessage(
|
|
|
|
|
debug.LEVEL_INFO,
|
|
|
|
|
f"WindowTitleReader: Failed to start tracking: {error}",
|
|
|
|
|
True,
|
|
|
|
|
)
|
|
|
|
|
pluginLogger.exception("WindowTitleReader: Failed to start tracking")
|
|
|
|
|
self._stop_tracking()
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def _stop_tracking(self):
|
|
|
|
|
if self._pollSourceId is not None:
|
|
|
|
|
GLib.source_remove(self._pollSourceId)
|
|
|
|
|
self._pollSourceId = None
|
|
|
|
|
|
|
|
|
|
self._enabled = False
|
|
|
|
|
self._lastTitle = None
|
|
|
|
|
self._cleanup_display()
|
|
|
|
|
|
|
|
|
|
def _cleanup_display(self):
|
|
|
|
|
if self._display is None:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
self._display.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pluginLogger.exception("WindowTitleReader: Failed to close display")
|
|
|
|
|
|
|
|
|
|
self._display = None
|
|
|
|
|
self._root = None
|
|
|
|
|
self._atoms = {}
|
|
|
|
|
|
|
|
|
|
def _init_atoms(self):
|
|
|
|
|
self._atoms = {
|
|
|
|
|
"NET_ACTIVE_WINDOW": self._display.intern_atom("_NET_ACTIVE_WINDOW"),
|
|
|
|
|
"NET_WM_NAME": self._display.intern_atom("_NET_WM_NAME"),
|
|
|
|
|
"WM_NAME": self._display.intern_atom("WM_NAME"),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def _poll_window_title(self):
|
|
|
|
|
if not self._enabled:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
activeWindow = self._get_active_window()
|
|
|
|
|
if not activeWindow:
|
|
|
|
|
self._lastTitle = None
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
windowTitle = self._get_current_title(activeWindow)
|
|
|
|
|
if not windowTitle:
|
|
|
|
|
self._lastTitle = None
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
if windowTitle != self._lastTitle:
|
|
|
|
|
self._present_title(windowTitle)
|
|
|
|
|
self._lastTitle = windowTitle
|
|
|
|
|
except Exception as error:
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, f"WindowTitleReader: Poll error: {error}", True)
|
|
|
|
|
pluginLogger.exception("WindowTitleReader: Poll failed")
|
|
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def _present_title(self, titleText):
|
|
|
|
|
if not self.app:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
appState = self.app.getDynamicApiManager().getAPI("CthulhuState")
|
|
|
|
|
if appState and appState.activeScript:
|
|
|
|
|
appState.activeScript.presentMessage(titleText, resetStyles=False)
|
|
|
|
|
except Exception:
|
|
|
|
|
pluginLogger.exception("WindowTitleReader: Failed to present title")
|
|
|
|
|
|
|
|
|
|
def _get_current_title(self, activeWindow):
|
|
|
|
|
activeTitle = self._get_window_title(activeWindow)
|
|
|
|
|
if not activeTitle:
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
if self._is_wine_desktop_title(activeTitle):
|
|
|
|
|
return self._get_wine_desktop_title(activeWindow)
|
|
|
|
|
|
|
|
|
|
return activeTitle
|
|
|
|
|
|
|
|
|
|
def _get_wine_desktop_title(self, desktopWindow):
|
|
|
|
|
focusWindow = self._get_focus_window()
|
|
|
|
|
if focusWindow and focusWindow.id != desktopWindow.id:
|
|
|
|
|
focusTitle = self._get_window_title(focusWindow)
|
|
|
|
|
if focusTitle and not self._is_wine_desktop_title(focusTitle):
|
|
|
|
|
return focusTitle
|
|
|
|
|
|
|
|
|
|
return self._find_child_title(desktopWindow)
|
|
|
|
|
|
|
|
|
|
def _find_child_title(self, rootWindow):
|
|
|
|
|
try:
|
|
|
|
|
childQueue = list(rootWindow.query_tree().children)
|
|
|
|
|
except xError.XError:
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
checkedCount = 0
|
|
|
|
|
while childQueue and checkedCount < 200:
|
|
|
|
|
window = childQueue.pop(0)
|
|
|
|
|
checkedCount += 1
|
|
|
|
|
titleText = self._get_window_title(window)
|
|
|
|
|
if titleText and not self._is_wine_desktop_title(titleText):
|
|
|
|
|
return titleText
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
childQueue.extend(window.query_tree().children)
|
|
|
|
|
except xError.XError:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
def _is_wine_desktop_title(self, titleText):
|
|
|
|
|
return self._wineDesktopLabel.lower() in titleText.lower()
|
|
|
|
|
|
|
|
|
|
def _get_window_title(self, window):
|
|
|
|
|
if not window:
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
netTitle = self._get_text_property(window, "NET_WM_NAME")
|
|
|
|
|
if netTitle:
|
|
|
|
|
return netTitle
|
|
|
|
|
|
|
|
|
|
return self._get_text_property(window, "WM_NAME")
|
|
|
|
|
|
|
|
|
|
def _get_text_property(self, window, atomName):
|
|
|
|
|
atom = self._atoms.get(atomName)
|
|
|
|
|
if not atom:
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
propertyData = window.get_full_property(atom, X.AnyPropertyType)
|
|
|
|
|
except xError.XError:
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
if not propertyData or propertyData.value is None:
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
value = propertyData.value
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
return value.strip("\x00")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
rawBytes = bytes(value)
|
|
|
|
|
except Exception:
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
decodedText = self._decode_property_bytes(rawBytes)
|
|
|
|
|
return decodedText.strip("\x00")
|
|
|
|
|
|
|
|
|
|
def _decode_property_bytes(self, rawBytes):
|
|
|
|
|
for encoding in ("utf-8", "latin-1"):
|
|
|
|
|
try:
|
|
|
|
|
return rawBytes.decode(encoding, errors="replace")
|
|
|
|
|
except Exception:
|
|
|
|
|
continue
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
def _get_active_window(self):
|
|
|
|
|
if not self._root:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
propertyData = self._root.get_full_property(
|
|
|
|
|
self._atoms["NET_ACTIVE_WINDOW"],
|
|
|
|
|
X.AnyPropertyType,
|
|
|
|
|
)
|
|
|
|
|
except xError.XError:
|
|
|
|
|
propertyData = None
|
|
|
|
|
|
|
|
|
|
if propertyData and propertyData.value:
|
|
|
|
|
try:
|
|
|
|
|
windowId = int(propertyData.value[0])
|
|
|
|
|
return self._display.create_resource_object("window", windowId)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
return self._get_focus_window()
|
|
|
|
|
|
|
|
|
|
def _get_focus_window(self):
|
|
|
|
|
if not self._display:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
focusInfo = self._display.get_input_focus()
|
|
|
|
|
focusWindow = focusInfo.focus
|
|
|
|
|
if not focusWindow or focusWindow.id == 0:
|
|
|
|
|
return None
|
|
|
|
|
if self._root and focusWindow.id == self._root.id:
|
|
|
|
|
return None
|
|
|
|
|
return focusWindow
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|