Try to improve window title reading.

This commit is contained in:
Storm Dragon
2026-05-31 00:48:44 -04:00
parent 6cd745dc57
commit 43996e7b8c
3 changed files with 221 additions and 6 deletions
+100 -6
View File
@@ -24,6 +24,7 @@ import logging
from gi.repository import GLib
from cthulhu.ax_object import AXObject
from cthulhu import debug
from cthulhu import dbus_service
from cthulhu.plugin import Plugin, cthulhu_hookimpl
@@ -53,6 +54,9 @@ class WindowTitleReader(Plugin):
self._enabled = False
self._pollSourceId = None
self._pollIntervalMs = 100
self._fallbackDelayMs = 250
self._pendingFallbackSourceId = None
self._lastActiveWindowId = None
self._lastTitle = None
self._display = None
self._root = None
@@ -71,6 +75,13 @@ class WindowTitleReader(Plugin):
return True
self._register_keybinding()
self.app.getDynamicApiManager().registerAPI(
"WindowTitleReader",
self,
overwrite=True,
)
if xlibAvailable:
self._start_tracking()
self._activated = True
debug.printMessage(debug.LEVEL_INFO, "WindowTitleReader: Activated", True)
return True
@@ -81,6 +92,8 @@ class WindowTitleReader(Plugin):
return
self._stop_tracking()
if self.app:
self.app.getDynamicApiManager().unregisterAPI("WindowTitleReader")
self._activated = False
debug.printMessage(debug.LEVEL_INFO, "WindowTitleReader: Deactivated", True)
return True
@@ -118,7 +131,8 @@ class WindowTitleReader(Plugin):
def _toggle_tracking(self, script=None, inputEvent=None):
if self._enabled:
self._stop_tracking()
self._enabled = False
self._lastTitle = None
self._present_message("Window title reader off")
return True
@@ -132,6 +146,8 @@ class WindowTitleReader(Plugin):
return True
if self._start_tracking():
self._enabled = True
self._poll_window_title()
self._present_message("Window title reader on")
else:
self._present_message("Window title reader unavailable")
@@ -162,6 +178,8 @@ class WindowTitleReader(Plugin):
return False
if self._start_tracking():
self._enabled = True
self._poll_window_title()
if notify_user:
self._present_message("Window title reader on")
return True
@@ -171,7 +189,8 @@ class WindowTitleReader(Plugin):
return False
if self._enabled:
self._stop_tracking()
self._enabled = False
self._lastTitle = None
if notify_user:
self._present_message("Window title reader off")
@@ -195,7 +214,7 @@ class WindowTitleReader(Plugin):
pluginLogger.exception("WindowTitleReader: Failed to present message")
def _start_tracking(self):
if self._enabled:
if self._pollSourceId is not None:
return True
try:
@@ -203,7 +222,6 @@ class WindowTitleReader(Plugin):
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:
@@ -217,11 +235,16 @@ class WindowTitleReader(Plugin):
return False
def _stop_tracking(self):
if self._pendingFallbackSourceId is not None:
GLib.source_remove(self._pendingFallbackSourceId)
self._pendingFallbackSourceId = None
if self._pollSourceId is not None:
GLib.source_remove(self._pollSourceId)
self._pollSourceId = None
self._enabled = False
self._lastActiveWindowId = None
self._lastTitle = None
self._cleanup_display()
@@ -246,7 +269,7 @@ class WindowTitleReader(Plugin):
}
def _poll_window_title(self):
if not self._enabled:
if self._pollSourceId is None:
return False
try:
@@ -260,7 +283,14 @@ class WindowTitleReader(Plugin):
self._lastTitle = None
return True
if windowTitle != self._lastTitle:
activeWindowId = activeWindow.id
if self._lastActiveWindowId is None:
self._lastActiveWindowId = activeWindowId
elif activeWindowId != self._lastActiveWindowId:
self._lastActiveWindowId = activeWindowId
self._schedule_fallback_title()
if self._enabled and windowTitle != self._lastTitle:
self._present_title(windowTitle)
self._lastTitle = windowTitle
except Exception as error:
@@ -269,6 +299,70 @@ class WindowTitleReader(Plugin):
return True
def _schedule_fallback_title(self):
if self._enabled:
return
if self._pendingFallbackSourceId is not None:
GLib.source_remove(self._pendingFallbackSourceId)
self._pendingFallbackSourceId = GLib.timeout_add(
self._fallbackDelayMs,
self._present_pending_fallback_title,
)
def _present_pending_fallback_title(self):
self._pendingFallbackSourceId = None
titleText = self.get_fallback_title()
if titleText:
self._present_title(titleText)
return False
def get_fallback_title(self, atspiTitle=None):
"""Returns the X11 title when AT-SPI has not exposed an equivalent title."""
if not xlibAvailable and self._display is None:
return ""
startedTracking = self._pollSourceId is not None
if not startedTracking and not self._start_tracking():
return ""
activeWindow = self._get_active_window()
titleText = self._get_current_title(activeWindow) if activeWindow else ""
if not titleText:
return ""
if atspiTitle is None:
atspiTitle = self._get_atspi_title()
if self._titles_match(atspiTitle, titleText):
return ""
return titleText
def _get_atspi_title(self):
if not self.app:
return ""
appState = self.app.getDynamicApiManager().getAPI("CthulhuState")
if not appState:
return ""
return AXObject.get_name(appState.activeWindow) or ""
def _titles_match(self, atspiTitle, fallbackTitle):
if not atspiTitle or not fallbackTitle:
return False
if self._is_wine_desktop_title(atspiTitle):
return False
normalizedAtspiTitle = " ".join(atspiTitle.casefold().split())
normalizedFallbackTitle = " ".join(fallbackTitle.casefold().split())
return normalizedAtspiTitle in normalizedFallbackTitle \
or normalizedFallbackTitle in normalizedAtspiTitle
def _present_title(self, titleText):
if not self.app:
return
+24
View File
@@ -33,6 +33,7 @@ __copyright__ = "Copyright (c) 2005-2008 Sun Microsystems Inc." \
__license__ = "LGPL"
from . import cmdnames
from . import cthulhu
from . import debug
from . import input_event
from . import keybindings
@@ -319,10 +320,33 @@ class WhereAmIPresenter:
return True
title = script.speechGenerator.generateTitle(obj)
fallbackTitle = self._get_fallback_title(title)
if fallbackTitle:
script.presentMessage(fallbackTitle)
return True
for (string, voice) in title:
script.presentMessage(string, voice=voice)
return True
@staticmethod
def _get_fallback_title(generatedTitle):
"""Returns a non-AT-SPI title when the active title reader can provide one."""
try:
reader = cthulhu.getManager().getDynamicApiManager().getAPI("WindowTitleReader")
except Exception:
return ""
if reader is None:
return ""
atspiTitle = " ".join(
str(item[0]) for item in generatedTitle
if isinstance(item, (list, tuple)) and item
)
return reader.get_fallback_title(atspiTitle)
def _present_default_button(self, script, event=None, dialog=None, error_messages=True):
"""Presents the default button of the current dialog."""
@@ -0,0 +1,97 @@
import sys
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cthulhu import where_am_i_presenter
from cthulhu.plugins.WindowTitleReader.plugin import WindowTitleReader
class WindowTitleFallbackRegressionTests(unittest.TestCase):
def test_activate_registers_reader_api_and_starts_fallback_tracking(self):
plugin = WindowTitleReader()
plugin.app = mock.Mock()
with (
mock.patch.object(plugin, "_register_keybinding"),
mock.patch.object(plugin, "_start_tracking") as startTracking,
):
plugin.activate(plugin)
plugin.app.getDynamicApiManager.return_value.registerAPI.assert_called_once_with(
"WindowTitleReader",
plugin,
overwrite=True,
)
startTracking.assert_called_once_with()
def test_poll_schedules_fallback_after_active_window_changes(self):
plugin = WindowTitleReader()
plugin._pollSourceId = 1
plugin._lastActiveWindowId = 100
activeWindow = mock.Mock(id=200)
with (
mock.patch.object(plugin, "_get_active_window", return_value=activeWindow),
mock.patch.object(plugin, "_get_current_title", return_value="XTerm"),
mock.patch.object(plugin, "_schedule_fallback_title") as scheduleFallback,
):
self.assertTrue(plugin._poll_window_title())
scheduleFallback.assert_called_once_with()
def test_poll_keeps_previous_window_across_transient_missing_active_window(self):
plugin = WindowTitleReader()
plugin._pollSourceId = 1
plugin._lastActiveWindowId = 100
with mock.patch.object(plugin, "_get_active_window", return_value=None):
self.assertTrue(plugin._poll_window_title())
self.assertEqual(plugin._lastActiveWindowId, 100)
def test_fallback_is_empty_when_atspi_exposes_same_title(self):
plugin = WindowTitleReader()
plugin._pollSourceId = 1
activeWindow = mock.Mock()
with (
mock.patch.object(plugin, "_get_active_window", return_value=activeWindow),
mock.patch.object(plugin, "_get_current_title", return_value="Terminal"),
):
self.assertEqual(plugin.get_fallback_title("Terminal"), "")
def test_fallback_replaces_wine_desktop_title(self):
plugin = WindowTitleReader()
plugin._pollSourceId = 1
activeWindow = mock.Mock()
with (
mock.patch.object(plugin, "_get_active_window", return_value=activeWindow),
mock.patch.object(plugin, "_get_current_title", return_value="Game Window"),
):
self.assertEqual(plugin.get_fallback_title("Wine Desktop"), "Game Window")
def test_present_title_uses_fallback_instead_of_atspi_title(self):
presenter = where_am_i_presenter.WhereAmIPresenter()
script = mock.Mock()
script.speechGenerator.generateTitle.return_value = [("Wine Desktop", None)]
with (
mock.patch.object(where_am_i_presenter.cthulhu_state, "locusOfFocus", object()),
mock.patch.object(where_am_i_presenter.AXObject, "is_dead", return_value=False),
mock.patch.object(
presenter,
"_get_fallback_title",
return_value="Game Window",
),
):
self.assertTrue(presenter.present_title(script))
script.presentMessage.assert_called_once_with("Game Window")
if __name__ == "__main__":
unittest.main()