Try to improve window title reading.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user