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.

This commit is contained in:
Storm Dragon
2026-01-03 19:36:30 -05:00
parent 28652e24f4
commit 5c9ceb42d5
10 changed files with 481 additions and 5 deletions
+37 -3
View File
@@ -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)
+5
View File
@@ -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
+43 -1
View File
@@ -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}")
@@ -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
+150
View File
@@ -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
@@ -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'
)
+146
View File
@@ -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)
@@ -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
+1
View File
@@ -25,6 +25,7 @@
__all__ = ['Banshee',
'Eclipse',
'Mumble',
'epiphany',
'evince',
'evolution',
+2 -1
View File
@@ -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')
subdir('xfwm4')