From 5c9ceb42d5473d35eccc1d56c471fe78a1a8c7d0 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 3 Jan 2026 19:36:30 -0500 Subject: [PATCH] Improved support for mumble. Mumble chat messages are now in the message history list. Also, for message review, add shift to copy the message to the clipboard. --- src/cthulhu/chat.py | 40 ++++- src/cthulhu/cmdnames.py | 5 + .../plugins/IndentationAudio/plugin.py | 44 ++++- src/cthulhu/scripts/apps/Mumble/__init__.py | 26 +++ src/cthulhu/scripts/apps/Mumble/chat.py | 150 ++++++++++++++++++ src/cthulhu/scripts/apps/Mumble/meson.build | 11 ++ src/cthulhu/scripts/apps/Mumble/script.py | 146 +++++++++++++++++ .../scripts/apps/Mumble/script_utilities.py | 60 +++++++ src/cthulhu/scripts/apps/__init__.py | 1 + src/cthulhu/scripts/apps/meson.build | 3 +- 10 files changed, 481 insertions(+), 5 deletions(-) create mode 100644 src/cthulhu/scripts/apps/Mumble/__init__.py create mode 100644 src/cthulhu/scripts/apps/Mumble/chat.py create mode 100644 src/cthulhu/scripts/apps/Mumble/meson.build create mode 100644 src/cthulhu/scripts/apps/Mumble/script.py create mode 100644 src/cthulhu/scripts/apps/Mumble/script_utilities.py diff --git a/src/cthulhu/chat.py b/src/cthulhu/chat.py index 1f595a0..1e4baad 100644 --- a/src/cthulhu/chat.py +++ b/src/cthulhu/chat.py @@ -315,6 +315,7 @@ class Chat: self.messageKeys = \ ["F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9"] self.messageKeyModifier = keybindings.CTHULHU_MODIFIER_MASK + self.messageCopyKeyModifier = keybindings.CTHULHU_SHIFT_MODIFIER_MASK self.inputEventHandlers = {} self.setupInputEventHandlers() self.keyBindings = self.getKeyBindings() @@ -356,6 +357,11 @@ class Chat: self.readPreviousMessage, cmdnames.CHAT_PREVIOUS_MESSAGE) + self.inputEventHandlers["copyMessage"] = \ + input_event.InputEventHandler( + self.copyPreviousMessage, + cmdnames.CHAT_COPY_PREVIOUS_MESSAGE) + return def getKeyBindings(self): @@ -396,6 +402,14 @@ class Chat: keybindings.CTHULHU_MODIFIER_MASK, self.inputEventHandlers["reviewMessage"])) + for messageKey in self.messageKeys: + keyBindings.add( + keybindings.KeyBinding( + messageKey, + self.messageCopyKeyModifier, + self.messageCopyKeyModifier, + self.inputEventHandlers["copyMessage"])) + return keyBindings def getAppPreferencesGUI(self): @@ -558,6 +572,27 @@ class Chat: except Exception: pass + message, chatRoomName = self._get_message_for_index(index) + if message and chatRoomName: + self.utterMessage(chatRoomName, message, True) + + def copyPreviousMessage(self, script, inputEvent=None, index=0): + """Copy a previous chat room message to the clipboard.""" + + try: + index = self.messageKeys.index(inputEvent.event_string) + except Exception: + pass + + message, chatRoomName = self._get_message_for_index(index) + if not message: + return + + self._script.utilities.setClipboardText(message) + line = f"Copied {message} to clipboard." + self._script.presentMessage(line) + + def _get_message_for_index(self, index): messageNumber = self.messageListLength - (index + 1) message, chatRoomName = None, None @@ -570,8 +605,7 @@ class Chat: message, chatRoomName = \ self._conversationList.getNthMessageAndName(messageNumber) - if message and chatRoomName: - self.utterMessage(chatRoomName, message, True) + return message, chatRoomName def utterMessage(self, chatRoomName, message, focused=True): """ Speak/braille a chat room message. @@ -816,7 +850,7 @@ class Chat: # things working. And people should not be in multiple chat # rooms with identical names anyway. :-) # - if (AXUtilities.is_text(obj) or AXObject.is_entry(obj)) \ + if (AXUtilities.is_text(obj) or AXUtilities.is_entry(obj)) \ and AXUtilities.is_editable(obj): name = self.getChatRoomName(obj) diff --git a/src/cthulhu/cmdnames.py b/src/cthulhu/cmdnames.py index c6ef3a0..7b3ab06 100644 --- a/src/cthulhu/cmdnames.py +++ b/src/cthulhu/cmdnames.py @@ -606,6 +606,11 @@ BYPASS_NEXT_COMMAND = \ # keyboard commands used to review those previous messages. CHAT_PREVIOUS_MESSAGE = _("Speak and braille a previous chat room message") +# Translators: Cthulhu has a command to copy a previous chat room message to +# the clipboard. This string is associated with the keyboard commands used to +# copy those messages. +CHAT_COPY_PREVIOUS_MESSAGE = _("Copy a previous chat room message to clipboard") + # Translators: In chat applications, it is often possible to see that a "buddy" # is typing currently (e.g. via a keyboard icon or status text). Some users like # to have this typing status announced by Cthulhu; others find that announcement diff --git a/src/cthulhu/plugins/IndentationAudio/plugin.py b/src/cthulhu/plugins/IndentationAudio/plugin.py index 12ca4d8..afb2c3a 100644 --- a/src/cthulhu/plugins/IndentationAudio/plugin.py +++ b/src/cthulhu/plugins/IndentationAudio/plugin.py @@ -19,6 +19,7 @@ from cthulhu import debug from cthulhu import settings from cthulhu import settings_manager from cthulhu.ax_object import AXObject +from cthulhu.ax_utilities import AXUtilities from cthulhu.ax_text import AXText # Import Cthulhu's sound system @@ -489,6 +490,25 @@ class IndentationAudio(Plugin): except Exception as e: logger.error(f"IndentationAudio: Error generating tone: {e}") debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Exception in _generate_indentation_tone: {e}", True) + + @staticmethod + def _get_indentation_key(obj): + if obj is None: + return "global" + + document = None + try: + document = AXObject.find_ancestor(obj, AXUtilities.is_document) + except Exception: + document = None + + if document is None: + document = obj + + try: + return f"{id(document)}" + except Exception: + return str(document) def check_indentation_change(self, obj, line_text): """Check if indentation has changed and play audio cue if needed. @@ -502,7 +522,7 @@ class IndentationAudio(Plugin): try: # Get object identifier for tracking - obj_id = str(obj) if obj else "unknown" + obj_id = self._get_indentation_key(obj) # Calculate current indentation data indentation, columns, levels = self._get_indentation_data(line_text) @@ -523,6 +543,8 @@ class IndentationAudio(Plugin): change_mode = _settingsManager.getSetting('indentationChangeMode') \ or settings.indentationChangeMode only_if_changed = _settingsManager.getSetting('speakIndentationOnlyIfChanged') + if only_if_changed is None: + only_if_changed = settings.speakIndentationOnlyIfChanged if not only_if_changed: changed = True @@ -543,10 +565,30 @@ class IndentationAudio(Plugin): # Play audio cue if indentation changed if changed: + indent_debug = indentation.replace("\t", "\\t").replace(" ", ".") + debug.printMessage( + debug.LEVEL_INFO, + ( + f"IndentationAudio: Reporting indentation key={obj_id} " + f"units={current_units} levels={levels} columns={columns} " + f"mode={change_mode} onlyIfChanged={only_if_changed} " + f"indent='{indent_debug}'" + ), + True, + ) self._generate_indentation_tone(current_units, previous_units) debug_msg = f"IndentationAudio: Indentation units changed from {previous_units} to {current_units}" debug.printMessage(debug.LEVEL_INFO, debug_msg, True) + else: + debug.printMessage( + debug.LEVEL_INFO, + ( + "IndentationAudio: No indentation change; skipping tone " + f"key={obj_id} units={current_units} levels={levels} columns={columns}" + ), + True, + ) except Exception as e: logger.error(f"IndentationAudio: Error checking indentation change: {e}") diff --git a/src/cthulhu/scripts/apps/Mumble/__init__.py b/src/cthulhu/scripts/apps/Mumble/__init__.py new file mode 100644 index 0000000..b006f6c --- /dev/null +++ b/src/cthulhu/scripts/apps/Mumble/__init__.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2024 Stormux +# Copyright (c) 2010-2012 The Orca Team +# Copyright (c) 2012 Igalia, S.L. +# Copyright (c) 2005-2010 Sun Microsystems Inc. +# +# 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. +# +# Forked from Orca screen reader. +# Cthulhu project: https://git.stormux.org/storm/cthulhu + +from .script import Script diff --git a/src/cthulhu/scripts/apps/Mumble/chat.py b/src/cthulhu/scripts/apps/Mumble/chat.py new file mode 100644 index 0000000..eccd2a8 --- /dev/null +++ b/src/cthulhu/scripts/apps/Mumble/chat.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2024 Stormux +# Copyright (c) 2010-2012 The Orca Team +# Copyright (c) 2012 Igalia, S.L. +# Copyright (c) 2005-2010 Sun Microsystems Inc. +# +# 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. +# +# Forked from Orca screen reader. +# Cthulhu project: https://git.stormux.org/storm/cthulhu + +"""Custom chat module for Mumble.""" + +__id__ = "$Id$" +__version__ = "$Revision$" +__date__ = "$Date$" +__copyright__ = "Copyright (c) 2024 Stormux." +__license__ = "LGPL" + +import re + +import cthulhu.chat as chat +import cthulhu.keybindings as keybindings +import cthulhu.cthulhu_state as cthulhu_state +import cthulhu.settings_manager as settings_manager +from cthulhu.ax_object import AXObject +from cthulhu.ax_selection import AXSelection +from cthulhu.ax_utilities import AXUtilities + +_settingsManager = settings_manager.getManager() + + +class Chat(chat.Chat): + """Mumble-specific chat helpers.""" + + def __init__(self, script): + super().__init__(script, []) + + # Mumble can have more than nine recent messages that are useful to review. + self.messageKeys = [ + "F1", "F2", "F3", "F4", "F5", "F6", + "F7", "F8", "F9", "F10", "F11", "F12", + ] + self.messageKeyModifier = keybindings.CTHULHU_MODIFIER_MASK + self.inputEventHandlers = {} + self.setupInputEventHandlers() + self.keyBindings = self.getKeyBindings() + self.messageListLength = len(self.messageKeys) + self._conversationList = chat.ConversationList(self.messageListLength) + + self._channelTree = None + + def isChatRoomMsg(self, obj): + if not obj: + return False + + name = AXObject.get_name(obj) + if name != "Activity log": + return False + + if AXUtilities.is_label(obj) or AXUtilities.is_text(obj): + return True + + return False + + def getMessageFromEvent(self, event): + message = event.any_data or "" + message = message.replace("\u2028", " ") + message = re.sub(r"[\ufdd0-\ufdef]", "", message) + message = " ".join(message.split()) + if not message: + return "" + + if re.match(r"^\[\d{2}:\d{2}:\d{2}\]$", message): + return "" + + message = re.sub(r"^\[\d{2}:\d{2}:\d{2}\]\s*", "", message) + return message.strip() + + def _get_message_for_index(self, index): + messageNumber = self.messageListLength - (index + 1) + message, chatRoomName = None, None + + if _settingsManager.getSetting('chatRoomHistories'): + conversation = self.getConversation(cthulhu_state.locusOfFocus) + if conversation: + message = conversation.getNthMessage(messageNumber) + chatRoomName = conversation.name + + if not message: + message, chatRoomName = \ + self._conversationList.getNthMessageAndName(messageNumber) + + return message, chatRoomName + + def getChatRoomName(self, obj): + channelName = self._get_selected_channel_name() + return channelName or "Mumble" + + def _get_selected_channel_name(self): + channelTree = self._get_channel_tree() + if not channelTree: + return "" + + selectedChildren = AXSelection.get_selected_children(channelTree) + for child in selectedChildren: + childName = AXObject.get_name(child) + if not childName: + continue + if childName.lower().startswith("channel "): + return childName.replace("channel ", "", 1).strip() + + for child in selectedChildren: + channelAncestor = AXObject.find_ancestor( + child, + lambda x: (AXObject.get_name(x) or "").lower().startswith("channel ") + ) + if channelAncestor: + ancestorName = AXObject.get_name(channelAncestor) + return ancestorName.replace("channel ", "", 1).strip() + + return "" + + def _get_channel_tree(self): + if self._channelTree and not AXUtilities.is_defunct(self._channelTree): + return self._channelTree + + activeWindow = cthulhu_state.activeWindow + if not activeWindow: + return None + + def is_channels_tree(obj): + return AXUtilities.is_tree(obj) and AXObject.get_name(obj) == "Channels and users" + + self._channelTree = AXObject.find_descendant(activeWindow, is_channels_tree) + return self._channelTree diff --git a/src/cthulhu/scripts/apps/Mumble/meson.build b/src/cthulhu/scripts/apps/Mumble/meson.build new file mode 100644 index 0000000..9e83272 --- /dev/null +++ b/src/cthulhu/scripts/apps/Mumble/meson.build @@ -0,0 +1,11 @@ +mumble_python_sources = files([ + '__init__.py', + 'chat.py', + 'script.py', + 'script_utilities.py', +]) + +python3.install_sources( + mumble_python_sources, + subdir: 'cthulhu/scripts/apps/Mumble' +) diff --git a/src/cthulhu/scripts/apps/Mumble/script.py b/src/cthulhu/scripts/apps/Mumble/script.py new file mode 100644 index 0000000..7452184 --- /dev/null +++ b/src/cthulhu/scripts/apps/Mumble/script.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2024 Stormux +# Copyright (c) 2010-2012 The Orca Team +# Copyright (c) 2012 Igalia, S.L. +# Copyright (c) 2005-2010 Sun Microsystems Inc. +# +# 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. +# +# Forked from Orca screen reader. +# Cthulhu project: https://git.stormux.org/storm/cthulhu + +"""Custom script for Mumble.""" + +__id__ = "$Id$" +__version__ = "$Revision$" +__date__ = "$Date$" +__copyright__ = "Copyright (c) 2024 Stormux." +__license__ = "LGPL" + +import time + +import cthulhu.debug as debug +import cthulhu.scripts.toolkits.Qt.script as Qt +from cthulhu.ax_object import AXObject +from cthulhu.ax_utilities import AXUtilities + +from .chat import Chat +from .script_utilities import Utilities + + +class Script(Qt.Script): + """Mumble-specific script tweaks.""" + + def __init__(self, app): + self._lastConnectFocusName = "" + self._lastConnectFocusRole = None + self._lastConnectFocusTime = 0.0 + self._lastMessageDialogId = None + + super().__init__(app) + + def getChat(self): + return Chat(self) + + def getUtilities(self): + return Utilities(self) + + def setupInputEventHandlers(self): + super().setupInputEventHandlers() + self.inputEventHandlers.update(self.chat.inputEventHandlers) + + def getAppKeyBindings(self): + return self.chat.keyBindings + + def getAppPreferencesGUI(self): + return self.chat.getAppPreferencesGUI() + + def getPreferencesFromGUI(self): + return self.chat.getPreferencesFromGUI() + + def onTextInserted(self, event): + if self.chat.presentInsertedText(event): + return + + super().onTextInserted(event) + + def onFocusedChanged(self, event): + if self._should_ignore_connect_dialog_focus(event): + return + + super().onFocusedChanged(event) + + def onCaretMoved(self, event): + super().onCaretMoved(event) + self._maybe_announce_message_dialog_input(event.source) + + def _should_ignore_connect_dialog_focus(self, event): + if not event.detail1: + return False + + obj = event.source + if not self._is_in_connect_dialog(obj): + return False + + objName = AXObject.get_name(obj) or "" + if not objName: + return False + + role = AXObject.get_role(obj) + now = time.time() + if self._lastConnectFocusName == objName \ + and self._lastConnectFocusRole == role \ + and (now - self._lastConnectFocusTime) < 0.35: + msg = "MUMBLE: Ignoring duplicate focus event in connect dialog" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return True + + self._lastConnectFocusName = objName + self._lastConnectFocusRole = role + self._lastConnectFocusTime = now + return False + + def _is_in_connect_dialog(self, obj): + dialog = AXObject.find_ancestor(obj, AXUtilities.is_dialog) + return bool(dialog) and AXObject.get_name(dialog) == "Mumble Server Connect" + + def _maybe_announce_message_dialog_input(self, obj): + if not obj or not AXUtilities.is_text(obj): + return + + if not AXUtilities.is_editable(obj): + return + + if AXObject.get_name(obj): + return + + dialog = AXObject.find_ancestor(obj, AXUtilities.is_dialog) + if not dialog: + return + + dialogName = AXObject.get_name(dialog) or "" + if not dialogName.startswith("Sending message to "): + return + + dialogHash = hash(dialog) + if dialogHash == self._lastMessageDialogId: + return + + self._lastMessageDialogId = dialogHash + label = "Message" + voice = self.speechGenerator.voice(string=label) + self.speakMessage(label, voice=voice) diff --git a/src/cthulhu/scripts/apps/Mumble/script_utilities.py b/src/cthulhu/scripts/apps/Mumble/script_utilities.py new file mode 100644 index 0000000..9405c72 --- /dev/null +++ b/src/cthulhu/scripts/apps/Mumble/script_utilities.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2024 Stormux +# Copyright (c) 2010-2012 The Orca Team +# Copyright (c) 2012 Igalia, S.L. +# Copyright (c) 2005-2010 Sun Microsystems Inc. +# +# 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. +# +# Forked from Orca screen reader. +# Cthulhu project: https://git.stormux.org/storm/cthulhu + +"""Custom script utilities for Mumble.""" + +__id__ = "$Id$" +__version__ = "$Revision$" +__date__ = "$Date$" +__copyright__ = "Copyright (c) 2024 Stormux." +__license__ = "LGPL" + +import cthulhu.scripts.toolkits.Qt.script_utilities as Qt +from cthulhu.ax_object import AXObject +from cthulhu.ax_utilities import AXUtilities + + +class Utilities(Qt.Utilities): + """Mumble-specific script utilities.""" + + def shouldReadFullRow(self, obj): + if self._is_connect_server_list_cell(obj): + return False + + return super().shouldReadFullRow(obj) + + def _is_connect_server_list_cell(self, obj): + if not AXUtilities.is_table_cell_or_header(obj): + return False + + dialog = AXObject.find_ancestor(obj, AXUtilities.is_dialog) + if not dialog or AXObject.get_name(dialog) != "Mumble Server Connect": + return False + + tree = AXObject.find_ancestor( + obj, + lambda x: AXUtilities.is_tree(x) and AXObject.get_name(x) == "Server list" + ) + return tree is not None diff --git a/src/cthulhu/scripts/apps/__init__.py b/src/cthulhu/scripts/apps/__init__.py index 8333088..c28d24d 100644 --- a/src/cthulhu/scripts/apps/__init__.py +++ b/src/cthulhu/scripts/apps/__init__.py @@ -25,6 +25,7 @@ __all__ = ['Banshee', 'Eclipse', + 'Mumble', 'epiphany', 'evince', 'evolution', diff --git a/src/cthulhu/scripts/apps/meson.build b/src/cthulhu/scripts/apps/meson.build index 91991cd..39b1438 100644 --- a/src/cthulhu/scripts/apps/meson.build +++ b/src/cthulhu/scripts/apps/meson.build @@ -9,6 +9,7 @@ python3.install_sources( subdir('Banshee') subdir('Eclipse') +subdir('Mumble') subdir('SeaMonkey') subdir('Thunderbird') subdir('epiphany') @@ -26,4 +27,4 @@ subdir('pidgin') subdir('soffice') subdir('smuxi-frontend-gnome') subdir('steamwebhelper') -subdir('xfwm4') \ No newline at end of file +subdir('xfwm4')