diff --git a/src/cthulhu/plugins/WindowTitleReader/plugin.py b/src/cthulhu/plugins/WindowTitleReader/plugin.py index b6284f8..dee1439 100644 --- a/src/cthulhu/plugins/WindowTitleReader/plugin.py +++ b/src/cthulhu/plugins/WindowTitleReader/plugin.py @@ -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 diff --git a/src/cthulhu/where_am_i_presenter.py b/src/cthulhu/where_am_i_presenter.py index 0ac9a73..6cfdaa8 100644 --- a/src/cthulhu/where_am_i_presenter.py +++ b/src/cthulhu/where_am_i_presenter.py @@ -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.""" diff --git a/tests/test_window_title_fallback_regressions.py b/tests/test_window_title_fallback_regressions.py new file mode 100644 index 0000000..aef52f3 --- /dev/null +++ b/tests/test_window_title_fallback_regressions.py @@ -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()