Compare commits
6 Commits
4add36f5ca
...
523b896053
| Author | SHA1 | Date | |
|---|---|---|---|
| 523b896053 | |||
| 9152455227 | |||
| c0fdaca4d0 | |||
| 8312a842c1 | |||
| 07138197cb | |||
| ed78ffc248 |
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Storm Dragon <storm_dragon@stormux.org>
|
||||
|
||||
pkgname=cthulhu
|
||||
pkgver=2026.02.17
|
||||
pkgver=2026.02.18
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca"
|
||||
url="https://git.stormux.org/storm/cthulhu"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
project('cthulhu',
|
||||
version: '2026.02.17-master',
|
||||
version: '2026.02.18-master',
|
||||
meson_version: '>= 1.0.0',
|
||||
)
|
||||
|
||||
|
||||
@@ -23,5 +23,5 @@
|
||||
# Forked from Orca screen reader.
|
||||
# Cthulhu project: https://git.stormux.org/storm/cthulhu
|
||||
|
||||
version = "2026.02.17"
|
||||
version = "2026.02.18"
|
||||
codeName = "master"
|
||||
|
||||
@@ -183,6 +183,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
|
||||
self.soundThemeCombo = None
|
||||
self.roleSoundPresentationCombo = None
|
||||
self._isInitialSetup = False
|
||||
self._updatingSpeechFamilies = False
|
||||
self.selectedFamilyChoices = {}
|
||||
self.selectedLanguageChoices = {}
|
||||
self.profilesCombo = None
|
||||
@@ -1271,7 +1272,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
|
||||
for familyChoice in self.speechFamiliesChoices:
|
||||
name = familyChoice[speechserver.VoiceFamily.NAME]
|
||||
if name == familyName:
|
||||
self.get_widget("speechFamilies").set_active(i)
|
||||
self._setSpeechFamiliesActive(i)
|
||||
self.speechFamiliesChoice = self.speechFamiliesChoices[i]
|
||||
familySet = True
|
||||
break
|
||||
@@ -1295,13 +1296,22 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
|
||||
if not familySet:
|
||||
tokens = ["PREFERENCES DIALOG: Could not find speech family match for", familyName]
|
||||
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||
self.get_widget("speechFamilies").set_active(0)
|
||||
self._setSpeechFamiliesActive(0)
|
||||
self.speechFamiliesChoice = self.speechFamiliesChoices[0]
|
||||
|
||||
if familySet:
|
||||
self.selectedFamilyChoices[self.speechServersChoice,
|
||||
self.speechLanguagesChoice] = i
|
||||
|
||||
def _setSpeechFamiliesActive(self, index):
|
||||
"""Sets speechFamilies active index while suppressing preview chatter."""
|
||||
|
||||
self._updatingSpeechFamilies = True
|
||||
try:
|
||||
self.get_widget("speechFamilies").set_active(index)
|
||||
finally:
|
||||
self._updatingSpeechFamilies = False
|
||||
|
||||
def _setupFamilies(self):
|
||||
"""Gets the list of voice variants for the current speech server and
|
||||
current language.
|
||||
@@ -1353,7 +1363,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
|
||||
selectedIndex = self.selectedFamilyChoices[self.speechServersChoice,
|
||||
self.speechLanguagesChoice]
|
||||
|
||||
self.get_widget("speechFamilies").set_active(selectedIndex)
|
||||
self._setSpeechFamiliesActive(selectedIndex)
|
||||
|
||||
def _setSpeechLanguagesChoice(self, languageName):
|
||||
"""Sets the active item in the languages ("Language:") combo box
|
||||
@@ -2899,21 +2909,51 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
|
||||
self.get_widget("enableDiacriticalKeysCheckButton").set_sensitive( \
|
||||
enable)
|
||||
|
||||
def _presentMessage(self, text, interrupt=False):
|
||||
def _presentMessage(self, text, interrupt=False, voice=None):
|
||||
"""If the text field is not None, presents the given text, optionally
|
||||
interrupting anything currently being spoken.
|
||||
|
||||
Arguments:
|
||||
- text: the text to present
|
||||
- interrupt: if True, interrupt any speech currently being spoken
|
||||
- voice: the voice to use; if None, use the default system voice
|
||||
"""
|
||||
|
||||
self.script.speakMessage(text, interrupt=interrupt)
|
||||
self.script.speakMessage(text, voice=voice, interrupt=interrupt)
|
||||
try:
|
||||
self.script.displayBrailleMessage(text, flashTime=-1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _previewVoiceSelection(self, voiceType, name):
|
||||
"""Present a short preview in the currently selected voice."""
|
||||
|
||||
if not name:
|
||||
return
|
||||
|
||||
voice = self._getACSSForVoiceType(voiceType)
|
||||
if not voice:
|
||||
return
|
||||
|
||||
previewVoice = acss.ACSS(voice)
|
||||
if self.speechSystemsChoice \
|
||||
and self.speechSystemsChoice.SpeechServer.getFactoryName() == guilabels.SPEECH_DISPATCHER:
|
||||
previewMessage = messages.SPEECH_VOICE_VALUE % name
|
||||
else:
|
||||
previewMessage = messages.SPEECH_VOICE_VALUE_GENERIC % name
|
||||
|
||||
# Speak through the currently-selected preferences speech server so
|
||||
# preview uses the chosen module/voice, not the globally-active one.
|
||||
server = self.speechServersChoice
|
||||
if server and hasattr(server, "speak"):
|
||||
try:
|
||||
server.speak(previewMessage, previewVoice, interrupt=True)
|
||||
return
|
||||
except Exception:
|
||||
debug.printException(debug.LEVEL_WARNING)
|
||||
|
||||
self._presentMessage(previewMessage, interrupt=True, voice=previewVoice)
|
||||
|
||||
def _createNode(self, appName):
|
||||
"""Create a new root node in the TreeStore model with the name of the
|
||||
application.
|
||||
@@ -3316,6 +3356,8 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
|
||||
variant = family[speechserver.VoiceFamily.VARIANT]
|
||||
voiceType = self.get_widget("voiceTypesCombo").get_active()
|
||||
self._setFamilyNameForVoiceType(voiceType, name, language, dialect, variant)
|
||||
if not self._updatingSpeechFamilies:
|
||||
self._previewVoiceSelection(voiceType, name)
|
||||
except Exception:
|
||||
debug.printException(debug.LEVEL_SEVERE)
|
||||
|
||||
|
||||
@@ -276,7 +276,9 @@ class LiveRegionManager:
|
||||
utts = message['labels'] + message['content']
|
||||
|
||||
if self.monitoring:
|
||||
self._script.presentMessage(utts)
|
||||
# Live region content is user-generated text, not system messages.
|
||||
# Use resetStyles=False to preserve the user's punctuation settings.
|
||||
self._script.presentMessage(utts, resetStyles=False)
|
||||
else:
|
||||
msg = "INFO: Not presenting message because monitoring is off"
|
||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
name = Speech History
|
||||
version = 1.0.0
|
||||
description = Shows a searchable history of the last 50 unique utterances spoken by Cthulhu
|
||||
version = 1.2.0
|
||||
description = Shows speech history and a live floating monitor of spoken text from Cthulhu
|
||||
authors = Stormux
|
||||
website = https://git.stormux.org/storm/cthulhu
|
||||
copyright = Copyright 2025 Stormux
|
||||
builtin = false
|
||||
hidden = false
|
||||
|
||||
|
||||
@@ -7,39 +7,47 @@
|
||||
# License as published by the Free Software Foundation; either
|
||||
# version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
"""Speech History plugin for Cthulhu."""
|
||||
"""Speech history and speech monitor plugin for Cthulhu."""
|
||||
|
||||
import logging
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, Gdk
|
||||
from gi.repository import Gtk, Gdk, GLib
|
||||
|
||||
from cthulhu.plugin import Plugin, cthulhu_hookimpl
|
||||
from cthulhu import debug
|
||||
from cthulhu import speech
|
||||
from cthulhu import speech_history
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SpeechHistory(Plugin):
|
||||
"""Plugin that displays a window containing recent spoken output."""
|
||||
"""Plugin that provides speech history and a live speech monitor."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._activated = False
|
||||
self._kbOpenWindow = None
|
||||
self._kbToggleMonitor = None
|
||||
|
||||
# Speech history window state
|
||||
self._window = None
|
||||
self._filterEntry = None
|
||||
self._filterText = ""
|
||||
self._listStore = None
|
||||
self._filterModel = None
|
||||
self._treeView = None
|
||||
|
||||
self._capturePaused = False
|
||||
|
||||
debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Plugin initialized", True)
|
||||
# Live monitor window state
|
||||
self._monitorWindow = None
|
||||
self._monitorTextView = None
|
||||
self._monitorTextBuffer = None
|
||||
self._monitorEndMark = None
|
||||
self._monitorLineCount = 0
|
||||
self._monitorMaxLines = 500
|
||||
|
||||
@cthulhu_hookimpl
|
||||
def activate(self, plugin=None):
|
||||
@@ -47,17 +55,13 @@ class SpeechHistory(Plugin):
|
||||
return
|
||||
|
||||
if self._activated:
|
||||
debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Already activated, skipping", True)
|
||||
return True
|
||||
|
||||
try:
|
||||
debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Activating plugin", True)
|
||||
self._register_keybinding()
|
||||
self._register_keybindings()
|
||||
self._activated = True
|
||||
debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Activated successfully", True)
|
||||
return True
|
||||
except Exception as e:
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR during activate: {e}", True)
|
||||
except Exception:
|
||||
logger.exception("Error activating SpeechHistory plugin")
|
||||
return False
|
||||
|
||||
@@ -67,46 +71,208 @@ class SpeechHistory(Plugin):
|
||||
return
|
||||
|
||||
try:
|
||||
debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Deactivating plugin", True)
|
||||
self._close_window()
|
||||
self._disable_monitor()
|
||||
self._kbOpenWindow = None
|
||||
self._kbToggleMonitor = None
|
||||
self._activated = False
|
||||
debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Deactivated successfully", True)
|
||||
return True
|
||||
except Exception as e:
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR during deactivate: {e}", True)
|
||||
except Exception:
|
||||
logger.exception("Error deactivating SpeechHistory plugin")
|
||||
return False
|
||||
|
||||
def _register_keybinding(self):
|
||||
try:
|
||||
if not self.app:
|
||||
debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: No app reference; cannot register keybinding", True)
|
||||
return
|
||||
def _register_keybindings(self):
|
||||
if not self.app:
|
||||
debug.printMessage(
|
||||
debug.LEVEL_INFO,
|
||||
"SpeechHistory: No app reference; cannot register keybindings",
|
||||
True,
|
||||
)
|
||||
return
|
||||
|
||||
gestureString = "kb:cthulhu+control+h"
|
||||
description = "Open speech history"
|
||||
historyGesture = "kb:cthulhu+control+h"
|
||||
historyDescription = "Open speech history"
|
||||
self._kbOpenWindow = self.registerGestureByString(
|
||||
self._open_window,
|
||||
historyDescription,
|
||||
historyGesture,
|
||||
)
|
||||
|
||||
self._kbOpenWindow = self.registerGestureByString(
|
||||
self._open_window,
|
||||
description,
|
||||
gestureString,
|
||||
monitorGesture = "kb:cthulhu+shift+d"
|
||||
monitorDescription = "Toggle speech monitor"
|
||||
self._kbToggleMonitor = self.registerGestureByString(
|
||||
self._toggle_monitor,
|
||||
monitorDescription,
|
||||
monitorGesture,
|
||||
)
|
||||
|
||||
if not self._kbOpenWindow:
|
||||
debug.printMessage(
|
||||
debug.LEVEL_INFO,
|
||||
f"SpeechHistory: Failed to register keybinding {historyGesture}",
|
||||
True,
|
||||
)
|
||||
|
||||
if self._kbOpenWindow:
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: Registered keybinding {gestureString}", True)
|
||||
else:
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: Failed to register keybinding {gestureString}", True)
|
||||
except Exception as e:
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR registering keybinding: {e}", True)
|
||||
logger.exception("Error registering keybinding for SpeechHistory")
|
||||
if not self._kbToggleMonitor:
|
||||
debug.printMessage(
|
||||
debug.LEVEL_INFO,
|
||||
f"SpeechHistory: Failed to register keybinding {monitorGesture}",
|
||||
True,
|
||||
)
|
||||
|
||||
# Live monitor methods
|
||||
|
||||
def _toggle_monitor(self, script=None, inputEvent=None):
|
||||
try:
|
||||
if self._monitorWindow is not None:
|
||||
self._disable_monitor()
|
||||
self._present_message("Speech monitor disabled.")
|
||||
return True
|
||||
|
||||
self._enable_monitor()
|
||||
self._present_message("Speech monitor enabled.")
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Error toggling speech monitor")
|
||||
self._disable_monitor()
|
||||
self._present_message("Error toggling speech monitor.")
|
||||
return False
|
||||
|
||||
def _enable_monitor(self):
|
||||
if self._monitorWindow is not None:
|
||||
self._monitorWindow.show_all()
|
||||
return
|
||||
|
||||
self._create_monitor_window()
|
||||
speech.set_monitor_callbacks(writeText=self._on_spoken_text)
|
||||
self._monitorWindow.show_all()
|
||||
|
||||
def _disable_monitor(self):
|
||||
speech.set_monitor_callbacks(writeText=None)
|
||||
|
||||
if self._monitorWindow is not None:
|
||||
self._monitorWindow.destroy()
|
||||
|
||||
def _create_monitor_window(self):
|
||||
self._monitorWindow = Gtk.Window(title="Speech Monitor - Cthulhu")
|
||||
self._monitorWindow.set_default_size(900, 320)
|
||||
self._monitorWindow.set_modal(False)
|
||||
self._monitorWindow.set_border_width(8)
|
||||
self._monitorWindow.set_keep_above(True)
|
||||
self._monitorWindow.set_accept_focus(False)
|
||||
self._monitorWindow.set_focus_on_map(False)
|
||||
self._monitorWindow.set_skip_taskbar_hint(True)
|
||||
self._monitorWindow.set_skip_pager_hint(True)
|
||||
self._monitorWindow.set_type_hint(Gdk.WindowTypeHint.UTILITY)
|
||||
|
||||
self._monitorWindow.connect("destroy", self._on_monitor_window_destroy)
|
||||
self._monitorWindow.connect("key-press-event", self._on_monitor_window_key_press)
|
||||
|
||||
mainBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||
|
||||
scrolledWindow = Gtk.ScrolledWindow()
|
||||
scrolledWindow.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||||
|
||||
self._monitorTextView = Gtk.TextView()
|
||||
self._monitorTextView.set_editable(False)
|
||||
self._monitorTextView.set_cursor_visible(False)
|
||||
self._monitorTextView.set_can_focus(False)
|
||||
self._monitorTextView.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
|
||||
self._monitorTextView.set_accepts_tab(False)
|
||||
self._monitorTextView.set_left_margin(6)
|
||||
self._monitorTextView.set_right_margin(6)
|
||||
self._monitorTextView.set_top_margin(6)
|
||||
self._monitorTextView.set_bottom_margin(6)
|
||||
|
||||
textAccessible = self._monitorTextView.get_accessible()
|
||||
if textAccessible:
|
||||
textAccessible.set_name("Speech monitor output")
|
||||
|
||||
self._monitorTextBuffer = self._monitorTextView.get_buffer()
|
||||
self._monitorEndMark = self._monitorTextBuffer.create_mark(
|
||||
"monitorEnd",
|
||||
self._monitorTextBuffer.get_end_iter(),
|
||||
False,
|
||||
)
|
||||
self._monitorLineCount = 0
|
||||
|
||||
scrolledWindow.add(self._monitorTextView)
|
||||
mainBox.pack_start(scrolledWindow, True, True, 0)
|
||||
|
||||
buttonRow = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
buttonRow.set_halign(Gtk.Align.END)
|
||||
|
||||
clearButton = Gtk.Button(label="Clear")
|
||||
closeButton = Gtk.Button(label="Close")
|
||||
clearButton.connect("clicked", self._on_monitor_clear_clicked)
|
||||
closeButton.connect("clicked", self._on_monitor_close_clicked)
|
||||
|
||||
buttonRow.pack_start(clearButton, False, False, 0)
|
||||
buttonRow.pack_start(closeButton, False, False, 0)
|
||||
mainBox.pack_start(buttonRow, False, False, 0)
|
||||
|
||||
self._monitorWindow.add(mainBox)
|
||||
|
||||
def _on_monitor_close_clicked(self, button):
|
||||
self._disable_monitor()
|
||||
|
||||
def _on_monitor_clear_clicked(self, button):
|
||||
if self._monitorTextBuffer is None:
|
||||
return
|
||||
|
||||
self._monitorTextBuffer.set_text("")
|
||||
self._monitorLineCount = 0
|
||||
|
||||
def _on_monitor_window_destroy(self, widget):
|
||||
speech.set_monitor_callbacks(writeText=None)
|
||||
|
||||
self._monitorWindow = None
|
||||
self._monitorTextView = None
|
||||
self._monitorTextBuffer = None
|
||||
self._monitorEndMark = None
|
||||
self._monitorLineCount = 0
|
||||
|
||||
def _on_monitor_window_key_press(self, widget, event):
|
||||
if event.keyval == Gdk.KEY_Escape:
|
||||
self._disable_monitor()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _on_spoken_text(self, text):
|
||||
if not text or not isinstance(text, str):
|
||||
return
|
||||
|
||||
GLib.idle_add(self._append_text_on_main_thread, text)
|
||||
|
||||
def _append_text_on_main_thread(self, text):
|
||||
if self._monitorTextBuffer is None:
|
||||
return False
|
||||
|
||||
self._monitorTextBuffer.insert(self._monitorTextBuffer.get_end_iter(), f"{text}\n")
|
||||
self._monitorLineCount += 1
|
||||
|
||||
if self._monitorLineCount > self._monitorMaxLines:
|
||||
excess = self._monitorLineCount - self._monitorMaxLines
|
||||
startIter = self._monitorTextBuffer.get_start_iter()
|
||||
cutIter = self._monitorTextBuffer.get_iter_at_line(excess)
|
||||
self._monitorTextBuffer.delete(startIter, cutIter)
|
||||
self._monitorLineCount = self._monitorMaxLines
|
||||
|
||||
if self._monitorEndMark is not None and self._monitorTextView is not None:
|
||||
self._monitorTextBuffer.move_mark(
|
||||
self._monitorEndMark,
|
||||
self._monitorTextBuffer.get_end_iter(),
|
||||
)
|
||||
self._monitorTextView.scroll_mark_onscreen(self._monitorEndMark)
|
||||
|
||||
return False
|
||||
|
||||
# Speech history methods
|
||||
|
||||
def _open_window(self, script=None, inputEvent=None):
|
||||
try:
|
||||
debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Open window requested", True)
|
||||
|
||||
if self._window:
|
||||
debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Window already open; presenting", True)
|
||||
self._window.present()
|
||||
return True
|
||||
|
||||
@@ -120,10 +286,8 @@ class SpeechHistory(Plugin):
|
||||
elif self._filterEntry:
|
||||
self._filterEntry.grab_focus()
|
||||
|
||||
debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Window shown", True)
|
||||
return True
|
||||
except Exception as e:
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR opening window: {e}", True)
|
||||
except Exception:
|
||||
logger.exception("Error opening SpeechHistory window")
|
||||
self._resume_capture()
|
||||
return False
|
||||
@@ -141,7 +305,6 @@ class SpeechHistory(Plugin):
|
||||
|
||||
self._filterText = ""
|
||||
|
||||
# Filter row
|
||||
filterRow = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||
filterLabel = Gtk.Label(label="_Filter:")
|
||||
filterLabel.set_use_underline(True)
|
||||
@@ -156,7 +319,6 @@ class SpeechHistory(Plugin):
|
||||
filterRow.pack_start(self._filterEntry, True, True, 0)
|
||||
mainBox.pack_start(filterRow, False, False, 0)
|
||||
|
||||
# List
|
||||
self._listStore = Gtk.ListStore(str)
|
||||
self._filterModel = self._listStore.filter_new()
|
||||
self._filterModel.set_visible_func(self._filter_visible_func)
|
||||
@@ -169,7 +331,7 @@ class SpeechHistory(Plugin):
|
||||
|
||||
textRenderer = Gtk.CellRendererText()
|
||||
textRenderer.set_property("wrap-width", 640)
|
||||
textRenderer.set_property("wrap-mode", 2) # Pango.WrapMode.WORD_CHAR
|
||||
textRenderer.set_property("wrap-mode", 2)
|
||||
textColumn = Gtk.TreeViewColumn("Spoken Text", textRenderer, text=0)
|
||||
textColumn.set_resizable(True)
|
||||
textColumn.set_expand(True)
|
||||
@@ -180,7 +342,6 @@ class SpeechHistory(Plugin):
|
||||
scrolled.add(self._treeView)
|
||||
mainBox.pack_start(scrolled, True, True, 0)
|
||||
|
||||
# Buttons
|
||||
buttonRow = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||
buttonRow.set_halign(Gtk.Align.END)
|
||||
|
||||
@@ -201,16 +362,12 @@ class SpeechHistory(Plugin):
|
||||
|
||||
self._refresh_list(selectFirst=True)
|
||||
|
||||
debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Window created", True)
|
||||
|
||||
def _on_filter_changed(self, entry):
|
||||
try:
|
||||
self._filterText = entry.get_text() or ""
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: Filter changed '{self._filterText}'", True)
|
||||
if self._filterModel:
|
||||
self._filterModel.refilter()
|
||||
except Exception as e:
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR filtering: {e}", True)
|
||||
except Exception:
|
||||
logger.exception("Error updating speech history filter")
|
||||
|
||||
def _filter_visible_func(self, model, treeIter, data=None):
|
||||
@@ -221,8 +378,7 @@ class SpeechHistory(Plugin):
|
||||
|
||||
spokenText = model[treeIter][0] or ""
|
||||
return spokenText.lower().startswith(filterText)
|
||||
except Exception as e:
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR in filter func: {e}", True)
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
def _refresh_list(self, selectFirst=False):
|
||||
@@ -230,31 +386,19 @@ class SpeechHistory(Plugin):
|
||||
if not self._listStore:
|
||||
return
|
||||
|
||||
debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Refreshing list", True)
|
||||
self._listStore.clear()
|
||||
|
||||
items = speech_history.get_items()
|
||||
debug.printMessage(
|
||||
debug.LEVEL_INFO,
|
||||
f"SpeechHistory: Retrieved {len(items)} items (paused={speech_history.is_capture_paused()})",
|
||||
True,
|
||||
)
|
||||
for item in items:
|
||||
self._listStore.append([item])
|
||||
|
||||
if self._filterModel:
|
||||
self._filterModel.refilter()
|
||||
debug.printMessage(
|
||||
debug.LEVEL_INFO,
|
||||
f"SpeechHistory: Filtered items visible={len(self._filterModel)} filter='{self._filterText}'",
|
||||
True,
|
||||
)
|
||||
|
||||
if selectFirst and self._treeView and len(self._filterModel) > 0:
|
||||
selection = self._treeView.get_selection()
|
||||
selection.select_path(Gtk.TreePath.new_first())
|
||||
except Exception as e:
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR refreshing list: {e}", True)
|
||||
except Exception:
|
||||
logger.exception("Error refreshing speech history list")
|
||||
|
||||
def _get_selected_text(self):
|
||||
@@ -268,8 +412,7 @@ class SpeechHistory(Plugin):
|
||||
return None
|
||||
|
||||
return model[treeIter][0]
|
||||
except Exception as e:
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR getting selection: {e}", True)
|
||||
except Exception:
|
||||
logger.exception("Error getting selected speech history item")
|
||||
return None
|
||||
|
||||
@@ -284,11 +427,8 @@ class SpeechHistory(Plugin):
|
||||
clipboard.set_text(selectedText, -1)
|
||||
clipboard.store()
|
||||
|
||||
preview = selectedText[:60] + ("..." if len(selectedText) > 60 else "")
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: Copied to clipboard '{preview}'", True)
|
||||
self._present_message("Copied to clipboard.")
|
||||
except Exception as e:
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR copying: {e}", True)
|
||||
except Exception:
|
||||
logger.exception("Error copying speech history item to clipboard")
|
||||
self._present_message("Error copying to clipboard.")
|
||||
|
||||
@@ -300,33 +440,27 @@ class SpeechHistory(Plugin):
|
||||
return
|
||||
|
||||
removed = speech_history.remove(selectedText)
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: Remove requested removed={removed}", True)
|
||||
if removed:
|
||||
self._refresh_list(selectFirst=True)
|
||||
self._present_message("Removed from history.")
|
||||
else:
|
||||
self._present_message("Item not found in history.")
|
||||
except Exception as e:
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR removing: {e}", True)
|
||||
except Exception:
|
||||
logger.exception("Error removing speech history item")
|
||||
self._present_message("Error removing item from history.")
|
||||
|
||||
def _on_close_clicked(self, button):
|
||||
debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Close button clicked", True)
|
||||
self._close_window()
|
||||
|
||||
def _close_window(self):
|
||||
try:
|
||||
if self._window:
|
||||
debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Closing window", True)
|
||||
self._window.destroy()
|
||||
except Exception as e:
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR closing window: {e}", True)
|
||||
except Exception:
|
||||
logger.exception("Error closing SpeechHistory window")
|
||||
self._resume_capture()
|
||||
|
||||
def _on_window_destroy(self, widget):
|
||||
debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Window destroyed", True)
|
||||
self._window = None
|
||||
self._filterEntry = None
|
||||
self._listStore = None
|
||||
@@ -338,14 +472,12 @@ class SpeechHistory(Plugin):
|
||||
def _on_window_key_press(self, widget, event):
|
||||
try:
|
||||
if event.keyval == Gdk.KEY_Escape:
|
||||
debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Escape pressed; closing window", True)
|
||||
self._close_window()
|
||||
return True
|
||||
|
||||
if not self._filterEntry or self._filterEntry.is_focus():
|
||||
return False
|
||||
|
||||
# If user starts typing anywhere, move focus to filter and update it.
|
||||
if event.keyval == Gdk.KEY_BackSpace:
|
||||
currentText = self._filterEntry.get_text() or ""
|
||||
if currentText:
|
||||
@@ -376,8 +508,7 @@ class SpeechHistory(Plugin):
|
||||
self._filterEntry.set_text(currentText + charTyped)
|
||||
self._filterEntry.set_position(-1)
|
||||
return True
|
||||
except Exception as e:
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR in key handler: {e}", True)
|
||||
except Exception:
|
||||
logger.exception("Error handling key press in SpeechHistory window")
|
||||
return False
|
||||
|
||||
@@ -385,7 +516,6 @@ class SpeechHistory(Plugin):
|
||||
if self._capturePaused:
|
||||
return
|
||||
|
||||
debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Pausing capture while window is open", True)
|
||||
speech_history.pause_capture(reason="SpeechHistory window open")
|
||||
self._capturePaused = True
|
||||
|
||||
@@ -393,7 +523,6 @@ class SpeechHistory(Plugin):
|
||||
if not self._capturePaused:
|
||||
return
|
||||
|
||||
debug.printMessage(debug.LEVEL_INFO, "SpeechHistory: Resuming capture (window closed)", True)
|
||||
speech_history.resume_capture(reason="SpeechHistory window closed")
|
||||
self._capturePaused = False
|
||||
|
||||
@@ -408,6 +537,5 @@ class SpeechHistory(Plugin):
|
||||
state.activeScript.presentMessage(message, resetStyles=False)
|
||||
else:
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: {message}", True)
|
||||
except Exception as e:
|
||||
debug.printMessage(debug.LEVEL_INFO, f"SpeechHistory: ERROR presenting message: {e}", True)
|
||||
except Exception:
|
||||
logger.exception("Error presenting message from SpeechHistory")
|
||||
|
||||
@@ -1473,7 +1473,9 @@ class Script(script.Script):
|
||||
"""Callback for object:announcement events."""
|
||||
|
||||
if isinstance(event.any_data, str):
|
||||
self.presentMessage(event.any_data)
|
||||
# AT-SPI announcements contain application content, not system messages.
|
||||
# Use resetStyles=False to preserve the user's punctuation settings.
|
||||
self.presentMessage(event.any_data, resetStyles=False)
|
||||
|
||||
def onNameChanged(self, event):
|
||||
"""Callback for object:property-change:accessible-name events."""
|
||||
|
||||
@@ -1648,9 +1648,21 @@ class Script(default.Script):
|
||||
elif self.utilities.isContentEditableWithEmbeddedObjects(newFocus) \
|
||||
and (self._lastCommandWasCaretNav or self._lastCommandWasStructNav) \
|
||||
and not (AXUtilities.is_table_cell(newFocus) and AXObject.get_name(newFocus)):
|
||||
tokens = ["WEB: New focus", newFocus, "content editable. Generating line."]
|
||||
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||
contents = self.utilities.getLineContentsAtOffset(newFocus, caretOffset)
|
||||
# Check if we're entering the content editable from outside (e.g. down arrow
|
||||
# from a message list into a message entry). In that case, generate full object
|
||||
# speech (with label and role) rather than just line contents.
|
||||
enteredFromOutside = oldFocus is not None \
|
||||
and oldFocus != newFocus \
|
||||
and not AXObject.find_ancestor(oldFocus, lambda x: x == newFocus)
|
||||
if enteredFromOutside:
|
||||
tokens = ["WEB: New focus", newFocus,
|
||||
"content editable entered from outside. Generating speech."]
|
||||
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||
args['priorObj'] = oldFocus
|
||||
else:
|
||||
tokens = ["WEB: New focus", newFocus, "content editable. Generating line."]
|
||||
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||
contents = self.utilities.getLineContentsAtOffset(newFocus, caretOffset)
|
||||
elif self.utilities.isAnchor(newFocus):
|
||||
tokens = ["WEB: New focus", newFocus, "is anchor. Generating line."]
|
||||
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||
|
||||
@@ -1834,6 +1834,16 @@ class Utilities(script_utilities.Utilities):
|
||||
prevObj, pOffset = self.findPreviousCaretInOrder(firstObj, firstStart)
|
||||
nextObj, nOffset = self.findNextCaretInOrder(lastObj, lastEnd - 1)
|
||||
|
||||
# If we're inside a content editable, don't expand line contents beyond
|
||||
# its boundaries (e.g. don't include a "More options" button adjacent to
|
||||
# a message entry just because it's on the same visual line).
|
||||
contentEditableBoundary = None
|
||||
if self.isContentEditableWithEmbeddedObjects(obj):
|
||||
contentEditableBoundary = obj
|
||||
else:
|
||||
contentEditableBoundary = AXObject.find_ancestor(
|
||||
obj, self.isContentEditableWithEmbeddedObjects)
|
||||
|
||||
# Check for things on the same line to the left of this object.
|
||||
prevStartTime = time.time()
|
||||
while prevObj and self.getDocumentForObject(prevObj) == document:
|
||||
@@ -1848,6 +1858,10 @@ class Utilities(script_utilities.Utilities):
|
||||
if objRow != AXObject.find_ancestor(prevObj, AXUtilities.is_table_row):
|
||||
break
|
||||
|
||||
if contentEditableBoundary and prevObj != contentEditableBoundary \
|
||||
and not AXObject.find_ancestor(prevObj, lambda x: x == contentEditableBoundary):
|
||||
break
|
||||
|
||||
onLeft = self._getContentsForObj(prevObj, pOffset, boundary)
|
||||
onLeft = list(filter(_include, onLeft))
|
||||
if not onLeft:
|
||||
@@ -1878,6 +1892,10 @@ class Utilities(script_utilities.Utilities):
|
||||
if objRow != AXObject.find_ancestor(nextObj, AXUtilities.is_table_row):
|
||||
break
|
||||
|
||||
if contentEditableBoundary and nextObj != contentEditableBoundary \
|
||||
and not AXObject.find_ancestor(nextObj, lambda x: x == contentEditableBoundary):
|
||||
break
|
||||
|
||||
onRight = self._getContentsForObj(nextObj, nOffset, boundary)
|
||||
if onRight and self._contentIsSubsetOf(objects[0], onRight[-1]):
|
||||
onRight = onRight[0:-1]
|
||||
|
||||
@@ -361,7 +361,13 @@ class SpeechGenerator(speech_generator.SpeechGenerator):
|
||||
if self._script.utilities.isContentEditableWithEmbeddedObjects(obj) \
|
||||
or self._script.utilities.isDocument(obj):
|
||||
if input_event_manager.get_manager().last_event_was_caret_navigation():
|
||||
return []
|
||||
# Still generate the label if we just entered this object from outside
|
||||
# (e.g. down arrow from message list into message entry in Discord).
|
||||
enteredFromOutside = priorObj is not None \
|
||||
and priorObj != obj \
|
||||
and not AXObject.find_ancestor(priorObj, lambda x: x == obj)
|
||||
if not enteredFromOutside:
|
||||
return []
|
||||
|
||||
if AXUtilities.is_page_tab(priorObj) and AXObject.get_name(priorObj) == objName:
|
||||
return []
|
||||
@@ -554,6 +560,9 @@ class SpeechGenerator(speech_generator.SpeechGenerator):
|
||||
if roledescription:
|
||||
result = [roledescription]
|
||||
result.extend(self.voice(speech_generator.SYSTEM, obj=obj, **args))
|
||||
# aria-roledescription replaces the standard role name, so return
|
||||
# early to avoid announcing both (e.g. "Message" + "article").
|
||||
return result
|
||||
|
||||
role = args.get('role', AXObject.get_role(obj))
|
||||
roleSoundPresentation = cthulhu.cthulhuApp.settingsManager.getSetting('roleSoundPresentation')
|
||||
|
||||
+25
-1
@@ -36,7 +36,7 @@ __license__ = "LGPL"
|
||||
|
||||
import importlib
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Optional, List, Dict, Any, Union
|
||||
from typing import TYPE_CHECKING, Optional, List, Dict, Any, Union, Callable
|
||||
|
||||
from . import debug
|
||||
from . import logger
|
||||
@@ -73,6 +73,9 @@ _speechserver: Optional[SpeechServer] = None
|
||||
# The last time something was spoken.
|
||||
_timestamp: float = 0.0
|
||||
|
||||
# Optional callback for live monitoring of spoken text.
|
||||
_monitorWriteTextCallback: Optional[Callable[[str], None]] = None
|
||||
|
||||
def _initSpeechServer(moduleName: Optional[str], speechServerInfo: Optional[Any]) -> None:
|
||||
|
||||
global _speechserver
|
||||
@@ -168,6 +171,21 @@ def setSpeechServer(speechServer: SpeechServer) -> None:
|
||||
global _speechserver
|
||||
_speechserver = speechServer
|
||||
|
||||
def set_monitor_callbacks(writeText: Optional[Callable[[str], None]] = None) -> None:
|
||||
"""Sets runtime callbacks for live speech monitoring."""
|
||||
global _monitorWriteTextCallback
|
||||
_monitorWriteTextCallback = writeText
|
||||
|
||||
def _write_to_monitor(text: str) -> None:
|
||||
"""Writes text to the active speech monitor callback if set."""
|
||||
if _monitorWriteTextCallback is None:
|
||||
return
|
||||
|
||||
try:
|
||||
_monitorWriteTextCallback(text)
|
||||
except Exception:
|
||||
debug.printException(debug.LEVEL_INFO)
|
||||
|
||||
def __resolveACSS(acss: Optional[Any] = None) -> ACSS:
|
||||
if isinstance(acss, ACSS):
|
||||
family = acss.get(acss.FAMILY)
|
||||
@@ -237,6 +255,8 @@ def _speak(text: str, acss: Optional[Any], interrupt: bool) -> None:
|
||||
except Exception:
|
||||
debug.printException(debug.LEVEL_INFO)
|
||||
|
||||
_write_to_monitor(text)
|
||||
|
||||
if not _speechserver:
|
||||
logLine = f"SPEECH OUTPUT: '{text}' {acss}"
|
||||
debug.printMessage(debug.LEVEL_INFO, logLine, True)
|
||||
@@ -393,6 +413,8 @@ def speakKeyEvent(event: Any, acss: Optional[Any] = None) -> None:
|
||||
if log:
|
||||
log.info(logLine)
|
||||
|
||||
_write_to_monitor(msg.strip())
|
||||
|
||||
if _speechserver:
|
||||
_speechserver.speakKeyEvent(event, acss) # type: ignore
|
||||
|
||||
@@ -425,6 +447,8 @@ def speakCharacter(character: str, acss: Optional[Any] = None) -> None:
|
||||
if log:
|
||||
log.info(f"SPEECH OUTPUT: '{character}'")
|
||||
|
||||
_write_to_monitor(character)
|
||||
|
||||
if _speechserver:
|
||||
_speechserver.speakCharacter(character, acss=acss) # type: ignore
|
||||
|
||||
|
||||
@@ -368,7 +368,11 @@ class SpeechAndVerbosityManager:
|
||||
if family is None:
|
||||
default_voice.pop(acss.ACSS.FAMILY, None)
|
||||
else:
|
||||
default_voice[acss.ACSS.FAMILY] = dict(family)
|
||||
family_dict = {
|
||||
key: value for key, value in dict(family).items()
|
||||
if value is not None
|
||||
}
|
||||
default_voice[acss.ACSS.FAMILY] = family_dict
|
||||
default_voice['established'] = True
|
||||
|
||||
def _get_current_speech_setting(self):
|
||||
@@ -581,7 +585,7 @@ class SpeechAndVerbosityManager:
|
||||
message = ""
|
||||
|
||||
if message:
|
||||
voice = self._get_default_voice() if setting == "voice" else None
|
||||
voice = self._get_default_voice() if setting in ("voice", "module") else None
|
||||
self._present_message(script, message, voice=voice)
|
||||
|
||||
@dbus_service.command
|
||||
@@ -698,7 +702,11 @@ class SpeechAndVerbosityManager:
|
||||
else:
|
||||
self._set_default_voice_family({})
|
||||
|
||||
self._present_message(script, messages.SPEECH_MODULE_VALUE % new_module)
|
||||
self._present_message(
|
||||
script,
|
||||
messages.SPEECH_MODULE_VALUE % new_module,
|
||||
voice=self._get_default_voice(),
|
||||
)
|
||||
return True
|
||||
|
||||
def _adjust_voice(self, script, decrease):
|
||||
|
||||
Reference in New Issue
Block a user