New window title reading plugin implemented. Watches for window title changes and reads them. Enable/disable with cthulhu+control+shift+W.

This commit is contained in:
Storm Dragon
2026-01-11 22:52:07 -05:00
parent 2ccd118cc5
commit d3c48b1e84
7 changed files with 383 additions and 1 deletions

View File

@@ -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

View File

@@ -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'
)

View File

@@ -0,0 +1,8 @@
name = Window Title Reader
version = 1.0.0
description = Toggle window title reader, including Wine Desktop titles
authors = Stormux <storm_dragon@stormux.org>
website = https://git.stormux.org/storm/cthulhu
copyright = Copyright 2026
builtin = false
hidden = false

View File

@@ -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

View File

@@ -14,3 +14,4 @@ subdir('SimplePluginSystem')
subdir('hello_world')
subdir('self_voice')
subdir('SSIPProxy')
subdir('WindowTitleReader')

View File

@@ -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)