From d3c48b1e84c642d85c1dabd5a6e91970677d5b5f Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sun, 11 Jan 2026 22:52:07 -0500 Subject: [PATCH] New window title reading plugin implemented. Watches for window title changes and reads them. Enable/disable with cthulhu+control+shift+W. --- distro-packages/Arch-Linux/PKGBUILD | 3 + .../plugins/WindowTitleReader/__init__.py | 0 .../plugins/WindowTitleReader/meson.build | 14 + .../plugins/WindowTitleReader/plugin.info | 8 + .../plugins/WindowTitleReader/plugin.py | 356 ++++++++++++++++++ src/cthulhu/plugins/meson.build | 1 + src/cthulhu/settings.py | 2 +- 7 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 src/cthulhu/plugins/WindowTitleReader/__init__.py create mode 100644 src/cthulhu/plugins/WindowTitleReader/meson.build create mode 100644 src/cthulhu/plugins/WindowTitleReader/plugin.info create mode 100644 src/cthulhu/plugins/WindowTitleReader/plugin.py diff --git a/distro-packages/Arch-Linux/PKGBUILD b/distro-packages/Arch-Linux/PKGBUILD index a68686b..2f40a3f 100644 --- a/distro-packages/Arch-Linux/PKGBUILD +++ b/distro-packages/Arch-Linux/PKGBUILD @@ -69,6 +69,9 @@ optdepends=( # nvda2cthulhu plugin (optional) 'python-msgpack: Msgpack decoding for nvda2cthulhu' 'python-tornado: WebSocket server for nvda2cthulhu' + + # Window Title Reader plugin (optional) + 'python-xlib: X11 access for Wine window title plugin' ) makedepends=( git diff --git a/src/cthulhu/plugins/WindowTitleReader/__init__.py b/src/cthulhu/plugins/WindowTitleReader/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/cthulhu/plugins/WindowTitleReader/meson.build b/src/cthulhu/plugins/WindowTitleReader/meson.build new file mode 100644 index 0000000..39cb587 --- /dev/null +++ b/src/cthulhu/plugins/WindowTitleReader/meson.build @@ -0,0 +1,14 @@ +windowtitlereader_python_sources = files([ + '__init__.py', + 'plugin.py' +]) + +python3.install_sources( + windowtitlereader_python_sources, + subdir: 'cthulhu/plugins/WindowTitleReader' +) + +install_data( + 'plugin.info', + install_dir: python3.get_install_dir() / 'cthulhu' / 'plugins' / 'WindowTitleReader' +) diff --git a/src/cthulhu/plugins/WindowTitleReader/plugin.info b/src/cthulhu/plugins/WindowTitleReader/plugin.info new file mode 100644 index 0000000..4f9edb0 --- /dev/null +++ b/src/cthulhu/plugins/WindowTitleReader/plugin.info @@ -0,0 +1,8 @@ +name = Window Title Reader +version = 1.0.0 +description = Toggle window title reader, including Wine Desktop titles +authors = Stormux +website = https://git.stormux.org/storm/cthulhu +copyright = Copyright 2026 +builtin = false +hidden = false diff --git a/src/cthulhu/plugins/WindowTitleReader/plugin.py b/src/cthulhu/plugins/WindowTitleReader/plugin.py new file mode 100644 index 0000000..494fb26 --- /dev/null +++ b/src/cthulhu/plugins/WindowTitleReader/plugin.py @@ -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 diff --git a/src/cthulhu/plugins/meson.build b/src/cthulhu/plugins/meson.build index 31a0684..75a1a99 100644 --- a/src/cthulhu/plugins/meson.build +++ b/src/cthulhu/plugins/meson.build @@ -14,3 +14,4 @@ subdir('SimplePluginSystem') subdir('hello_world') subdir('self_voice') subdir('SSIPProxy') +subdir('WindowTitleReader') diff --git a/src/cthulhu/settings.py b/src/cthulhu/settings.py index 3140d87..e47f30a 100644 --- a/src/cthulhu/settings.py +++ b/src/cthulhu/settings.py @@ -494,7 +494,7 @@ presentChatRoomLast = False presentLiveRegionFromInactiveTab = False # Plugins -activePlugins = ['AIAssistant', 'DisplayVersion', 'OCR', 'PluginManager', 'HelloCthulhu', 'ByeCthulhu', 'IndentationAudio'] +activePlugins = ['AIAssistant', 'DisplayVersion', 'OCR', 'PluginManager', 'HelloCthulhu', 'ByeCthulhu', 'IndentationAudio', 'WindowTitleReader'] pluginSources = [] # AI Assistant settings (disabled by default for opt-in behavior)