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:
@@ -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
|
||||
|
||||
0
src/cthulhu/plugins/WindowTitleReader/__init__.py
Normal file
0
src/cthulhu/plugins/WindowTitleReader/__init__.py
Normal file
14
src/cthulhu/plugins/WindowTitleReader/meson.build
Normal file
14
src/cthulhu/plugins/WindowTitleReader/meson.build
Normal 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'
|
||||
)
|
||||
8
src/cthulhu/plugins/WindowTitleReader/plugin.info
Normal file
8
src/cthulhu/plugins/WindowTitleReader/plugin.info
Normal 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
|
||||
356
src/cthulhu/plugins/WindowTitleReader/plugin.py
Normal file
356
src/cthulhu/plugins/WindowTitleReader/plugin.py
Normal 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
|
||||
@@ -14,3 +14,4 @@ subdir('SimplePluginSystem')
|
||||
subdir('hello_world')
|
||||
subdir('self_voice')
|
||||
subdir('SSIPProxy')
|
||||
subdir('WindowTitleReader')
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user