Try to improve window title reading.
This commit is contained in:
@@ -24,6 +24,7 @@ import logging
|
|||||||
|
|
||||||
from gi.repository import GLib
|
from gi.repository import GLib
|
||||||
|
|
||||||
|
from cthulhu.ax_object import AXObject
|
||||||
from cthulhu import debug
|
from cthulhu import debug
|
||||||
from cthulhu import dbus_service
|
from cthulhu import dbus_service
|
||||||
from cthulhu.plugin import Plugin, cthulhu_hookimpl
|
from cthulhu.plugin import Plugin, cthulhu_hookimpl
|
||||||
@@ -53,6 +54,9 @@ class WindowTitleReader(Plugin):
|
|||||||
self._enabled = False
|
self._enabled = False
|
||||||
self._pollSourceId = None
|
self._pollSourceId = None
|
||||||
self._pollIntervalMs = 100
|
self._pollIntervalMs = 100
|
||||||
|
self._fallbackDelayMs = 250
|
||||||
|
self._pendingFallbackSourceId = None
|
||||||
|
self._lastActiveWindowId = None
|
||||||
self._lastTitle = None
|
self._lastTitle = None
|
||||||
self._display = None
|
self._display = None
|
||||||
self._root = None
|
self._root = None
|
||||||
@@ -71,6 +75,13 @@ class WindowTitleReader(Plugin):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
self._register_keybinding()
|
self._register_keybinding()
|
||||||
|
self.app.getDynamicApiManager().registerAPI(
|
||||||
|
"WindowTitleReader",
|
||||||
|
self,
|
||||||
|
overwrite=True,
|
||||||
|
)
|
||||||
|
if xlibAvailable:
|
||||||
|
self._start_tracking()
|
||||||
self._activated = True
|
self._activated = True
|
||||||
debug.printMessage(debug.LEVEL_INFO, "WindowTitleReader: Activated", True)
|
debug.printMessage(debug.LEVEL_INFO, "WindowTitleReader: Activated", True)
|
||||||
return True
|
return True
|
||||||
@@ -81,6 +92,8 @@ class WindowTitleReader(Plugin):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self._stop_tracking()
|
self._stop_tracking()
|
||||||
|
if self.app:
|
||||||
|
self.app.getDynamicApiManager().unregisterAPI("WindowTitleReader")
|
||||||
self._activated = False
|
self._activated = False
|
||||||
debug.printMessage(debug.LEVEL_INFO, "WindowTitleReader: Deactivated", True)
|
debug.printMessage(debug.LEVEL_INFO, "WindowTitleReader: Deactivated", True)
|
||||||
return True
|
return True
|
||||||
@@ -118,7 +131,8 @@ class WindowTitleReader(Plugin):
|
|||||||
|
|
||||||
def _toggle_tracking(self, script=None, inputEvent=None):
|
def _toggle_tracking(self, script=None, inputEvent=None):
|
||||||
if self._enabled:
|
if self._enabled:
|
||||||
self._stop_tracking()
|
self._enabled = False
|
||||||
|
self._lastTitle = None
|
||||||
self._present_message("Window title reader off")
|
self._present_message("Window title reader off")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -132,6 +146,8 @@ class WindowTitleReader(Plugin):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
if self._start_tracking():
|
if self._start_tracking():
|
||||||
|
self._enabled = True
|
||||||
|
self._poll_window_title()
|
||||||
self._present_message("Window title reader on")
|
self._present_message("Window title reader on")
|
||||||
else:
|
else:
|
||||||
self._present_message("Window title reader unavailable")
|
self._present_message("Window title reader unavailable")
|
||||||
@@ -162,6 +178,8 @@ class WindowTitleReader(Plugin):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
if self._start_tracking():
|
if self._start_tracking():
|
||||||
|
self._enabled = True
|
||||||
|
self._poll_window_title()
|
||||||
if notify_user:
|
if notify_user:
|
||||||
self._present_message("Window title reader on")
|
self._present_message("Window title reader on")
|
||||||
return True
|
return True
|
||||||
@@ -171,7 +189,8 @@ class WindowTitleReader(Plugin):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
if self._enabled:
|
if self._enabled:
|
||||||
self._stop_tracking()
|
self._enabled = False
|
||||||
|
self._lastTitle = None
|
||||||
|
|
||||||
if notify_user:
|
if notify_user:
|
||||||
self._present_message("Window title reader off")
|
self._present_message("Window title reader off")
|
||||||
@@ -195,7 +214,7 @@ class WindowTitleReader(Plugin):
|
|||||||
pluginLogger.exception("WindowTitleReader: Failed to present message")
|
pluginLogger.exception("WindowTitleReader: Failed to present message")
|
||||||
|
|
||||||
def _start_tracking(self):
|
def _start_tracking(self):
|
||||||
if self._enabled:
|
if self._pollSourceId is not None:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -203,7 +222,6 @@ class WindowTitleReader(Plugin):
|
|||||||
self._root = self._display.screen().root
|
self._root = self._display.screen().root
|
||||||
self._init_atoms()
|
self._init_atoms()
|
||||||
self._pollSourceId = GLib.timeout_add(self._pollIntervalMs, self._poll_window_title)
|
self._pollSourceId = GLib.timeout_add(self._pollIntervalMs, self._poll_window_title)
|
||||||
self._enabled = True
|
|
||||||
self._poll_window_title()
|
self._poll_window_title()
|
||||||
return True
|
return True
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
@@ -217,11 +235,16 @@ class WindowTitleReader(Plugin):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _stop_tracking(self):
|
def _stop_tracking(self):
|
||||||
|
if self._pendingFallbackSourceId is not None:
|
||||||
|
GLib.source_remove(self._pendingFallbackSourceId)
|
||||||
|
self._pendingFallbackSourceId = None
|
||||||
|
|
||||||
if self._pollSourceId is not None:
|
if self._pollSourceId is not None:
|
||||||
GLib.source_remove(self._pollSourceId)
|
GLib.source_remove(self._pollSourceId)
|
||||||
self._pollSourceId = None
|
self._pollSourceId = None
|
||||||
|
|
||||||
self._enabled = False
|
self._enabled = False
|
||||||
|
self._lastActiveWindowId = None
|
||||||
self._lastTitle = None
|
self._lastTitle = None
|
||||||
self._cleanup_display()
|
self._cleanup_display()
|
||||||
|
|
||||||
@@ -246,7 +269,7 @@ class WindowTitleReader(Plugin):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _poll_window_title(self):
|
def _poll_window_title(self):
|
||||||
if not self._enabled:
|
if self._pollSourceId is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -260,7 +283,14 @@ class WindowTitleReader(Plugin):
|
|||||||
self._lastTitle = None
|
self._lastTitle = None
|
||||||
return True
|
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._present_title(windowTitle)
|
||||||
self._lastTitle = windowTitle
|
self._lastTitle = windowTitle
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
@@ -269,6 +299,70 @@ class WindowTitleReader(Plugin):
|
|||||||
|
|
||||||
return True
|
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):
|
def _present_title(self, titleText):
|
||||||
if not self.app:
|
if not self.app:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ __copyright__ = "Copyright (c) 2005-2008 Sun Microsystems Inc." \
|
|||||||
__license__ = "LGPL"
|
__license__ = "LGPL"
|
||||||
|
|
||||||
from . import cmdnames
|
from . import cmdnames
|
||||||
|
from . import cthulhu
|
||||||
from . import debug
|
from . import debug
|
||||||
from . import input_event
|
from . import input_event
|
||||||
from . import keybindings
|
from . import keybindings
|
||||||
@@ -319,10 +320,33 @@ class WhereAmIPresenter:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
title = script.speechGenerator.generateTitle(obj)
|
title = script.speechGenerator.generateTitle(obj)
|
||||||
|
fallbackTitle = self._get_fallback_title(title)
|
||||||
|
if fallbackTitle:
|
||||||
|
script.presentMessage(fallbackTitle)
|
||||||
|
return True
|
||||||
|
|
||||||
for (string, voice) in title:
|
for (string, voice) in title:
|
||||||
script.presentMessage(string, voice=voice)
|
script.presentMessage(string, voice=voice)
|
||||||
return True
|
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):
|
def _present_default_button(self, script, event=None, dialog=None, error_messages=True):
|
||||||
"""Presents the default button of the current dialog."""
|
"""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