14 Commits

Author SHA1 Message Date
Storm Dragon 523b896053 Couple minor bug fixes, new speech code added in prefs. 2026-02-18 06:10:57 -05:00
Storm Dragon 9152455227 Speech in settings now uses the voice when switched to it. 2026-02-18 06:07:55 -05:00
Storm Dragon c0fdaca4d0 Merge remote-tracking branch 'origin/web-and-punctuation-fixes' into testing 2026-02-17 14:50:01 -05:00
destructatron 8312a842c1 Fix duplicate role name when aria-roledescription is present
When an element has aria-roledescription (e.g. Discord messages use
'Message'), _generateRoleName was adding both the roledescription AND
the standard localized role name (e.g. 'article'). This resulted in
the role being announced twice.

The aria-roledescription attribute is intended to replace the standard
role name, not supplement it. Return early after adding the
roledescription to match Orca's behavior.
2026-02-17 19:21:36 +00:00
destructatron 07138197cb Fix web content editable label, line boundary, and punctuation bugs
Bug 1: Content editable entry label not announced on caret-nav focus
- When navigating into a content editable via caret navigation (e.g. down
  arrow from message list to message entry in Discord), the label and role
  were suppressed because the code treated entering the field the same as
  navigating within it.
- Fixed locus_of_focus_changed in web/script.py to detect when focus enters
  a content editable from outside and generate full object speech.
- Fixed _generateLabelOrName in web/speech_generator.py to preserve the
  label when the prior object was outside the content editable.

Bug 2: Adjacent button included in line contents on first line
- When reading line contents inside a content editable, the layout-mode
  expansion could walk outside the content editable boundary and include
  adjacent UI elements (e.g. a 'More options' button) that happened to
  share the same visual line.
- Fixed _getLineContentsAtOffset in web/script_utilities.py to stop
  expanding at content editable boundaries in both directions.

Bug 3: Punctuation stripped from live regions and AT-SPI announcements
- presentMessage uses resetStyles=True by default, which sets punctuation
  to NONE for system voice messages. This is correct for generated text
  but wrong for user content in live regions and AT-SPI announcements.
- Fixed liveregions.py pumpMessages and default.py onAnnouncement to pass
  resetStyles=False, preserving the user's punctuation settings.
2026-02-17 19:07:41 +00:00
Storm Dragon ed78ffc248 Visual speech monitor added to speech history plugin. Toggle with cthulhu+shift+D 2026-02-17 08:40:51 -05:00
Storm Dragon 4add36f5ca latest changed merged, seems to be reasonably stable. 2026-02-17 08:01:47 -05:00
Storm Dragon 40e63150a6 Read a list of applications that should always be started in sleep mode from sleep.toml. 2026-02-17 07:57:14 -05:00
Storm Dragon 4dba0ec0cd Fixed a bug with sleep mode, was not suspending review keys for sleeping applications. 2026-02-16 14:55:56 -05:00
Storm Dragon e6f780c38b New code tested and seems pretty stable so merged and bumped version. 2026-02-15 12:10:36 -05:00
Storm Dragon 0f7f73a6a0 Fixed flat review bug affecting some sites in chrome. 2026-02-13 13:23:14 -05:00
Storm Dragon aa71d02036 Merge remote-tracking branch 'origin/keyboard-monitoring-api' into testing 2026-02-13 11:19:40 -05:00
Storm Dragon f873fcee11 Use toml instead of json for settings. 2026-02-13 11:13:38 -05:00
Storm Dragon 13976b7235 Look for missing sound files if not found in 1 directory e.g. if not found in ~/.local look in system themes. 2026-02-07 22:40:21 -05:00
26 changed files with 775 additions and 236 deletions
+2 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: Storm Dragon <storm_dragon@stormux.org>
pkgname=cthulhu
pkgver=2026.01.26
pkgver=2026.02.18
pkgrel=1
pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca"
url="https://git.stormux.org/storm/cthulhu"
@@ -27,6 +27,7 @@ depends=(
# Plugin system and D-Bus remote control
python-pluggy
python-tomlkit
python-dasbus
# AI Assistant dependencies (for screenshots, HTTP requests, and actions)
+46
View File
@@ -0,0 +1,46 @@
# Cthulhu Logging Guidelines
This document defines the preferred format for debug logging in Cthulhu.
The goal is to keep logs consistent, searchable, and easy to scan.
## Helpers
Use the helpers in `cthulhu.debug` for new logs:
- `debug.print_log(level, prefix, message, reason=None, timestamp=False, stack=False)`
- `debug.print_log_tokens(level, prefix, tokens, reason=None, timestamp=False, stack=False)`
These helpers ensure the prefix and optional reason tag are formatted consistently.
Timestamps are appended at the end of the message when enabled.
## Prefixes
Use short, uppercase prefixes that identify the subsystem:
- `EVENT MANAGER`
- `FOCUS MANAGER`
- `INPUT EVENT`
- `SCRIPT MANAGER`
- `WEB`
## Messages
- Keep messages short and action-focused.
- Do not include the prefix in the message.
- Prefer consistent verbs (e.g., "Not using …", "Setting …", "Ignoring …").
## Reason Tags
Use reason tags to explain decisions or early exits.
- Lowercase with hyphens (e.g., `focus-mode`, `no-active-script`).
- Use a short phrase rather than a full sentence.
- If a human-readable reason is already available, it can be used directly.
## Examples
```text
WEB: Not using caret navigation (reason=disabled)
FOCUS MANAGER: Setting locus of focus to existing locus of focus (reason=no-change)
SCRIPT MANAGER: Setting active script to [script] (reason=focus: active-window)
```
+1 -1
View File
@@ -290,7 +290,7 @@ toggle the reading of tables, either by single cell or whole row.
.B Cthulhu
user preferences directory
.TP
.BI ~/.local/share/cthulhu/user-settings.conf
.BI ~/.local/share/cthulhu/user-settings.toml
.B Cthulhu
user preferences configuration file.
.TP
+1 -1
View File
@@ -1,5 +1,5 @@
project('cthulhu',
version: '2026.01.26-master',
version: '2026.02.18-master',
meson_version: '>= 1.0.0',
)
+1
View File
@@ -12,6 +12,7 @@ license = { text = "LGPL-2.1-or-later" }
dependencies = [
"pygobject>=3.18",
"pluggy",
"tomlkit",
"brlapi; extra == 'braille'",
"python-speechd; extra == 'speech'",
"piper-tts; extra == 'piper'",
+2 -2
View File
@@ -1,9 +1,9 @@
backends_python_sources = files([
'__init__.py',
'json_backend.py',
'toml_backend.py',
])
python3.install_sources(
backends_python_sources,
subdir: 'cthulhu/backends'
)
)
@@ -23,7 +23,7 @@
# Forked from Orca screen reader.
# Cthulhu project: https://git.stormux.org/storm/cthulhu
"""JSON backend for Cthulhu settings"""
"""TOML backend for Cthulhu settings"""
__id__ = "$Id$"
__version__ = "$Revision$"
@@ -31,20 +31,22 @@ __date__ = "$Date$"
__copyright__ = "Copyright (c) 2010-2011 Consorcio Fernando de los Rios."
__license__ = "LGPL"
from json import load, dump
import os
from tomlkit import parse, dumps, document
from cthulhu import settings, acss
class Backend:
def __init__(self, prefsDir):
""" Initialize the JSON Backend.
"""
""" Initialize the TOML Backend.
"""
self.general = {}
self.pronunciations = {}
self.keybindings = {}
self.profiles = {}
self.settingsFile = os.path.join(prefsDir, "user-settings.conf")
self.settingsFile = os.path.join(prefsDir, "user-settings.toml")
self.appPrefsDir = os.path.join(prefsDir, "app-settings")
self._defaultProfiles = {'default': { 'profile': settings.profile,
@@ -53,75 +55,137 @@ class Backend:
}
}
def _stripNone(self, value):
if isinstance(value, dict):
cleaned = {}
for key, item in value.items():
if item is None:
continue
cleanedItem = self._stripNone(item)
if cleanedItem is None:
continue
cleaned[key] = cleanedItem
return cleaned
if isinstance(value, list):
cleanedList = []
for item in value:
if item is None:
continue
cleanedList.append(self._stripNone(item))
return cleanedList
return value
def _readDocument(self, fileName):
if os.path.exists(fileName):
with open(fileName, 'r', encoding='utf-8') as settingsFile:
return parse(settingsFile.read())
return document()
def _writeDocument(self, fileName, prefsDoc):
with open(fileName, 'w', encoding='utf-8') as settingsFile:
settingsFile.write(dumps(prefsDoc))
def _updateTable(self, targetTable, newValues):
if not isinstance(newValues, dict):
return
for key in list(targetTable.keys()):
if key not in newValues:
del targetTable[key]
continue
newValue = newValues[key]
existingValue = targetTable.get(key)
if isinstance(newValue, dict) and isinstance(existingValue, dict):
self._updateTable(existingValue, newValue)
continue
if existingValue != newValue:
targetTable[key] = newValue
for key, newValue in newValues.items():
if key not in targetTable:
targetTable[key] = newValue
def saveDefaultSettings(self, general, pronunciations, keybindings):
""" Save default settings for all the properties from
cthulhu.settings. """
prefs = {'general': general,
'profiles': self._defaultProfiles,
'pronunciations': pronunciations,
'keybindings': keybindings}
prefs = {'general': self._stripNone(general),
'profiles': self._stripNone(self._defaultProfiles),
'pronunciations': self._stripNone(pronunciations),
'keybindings': self._stripNone(keybindings)}
self.general = general
self.profiles = self._defaultProfiles
self.pronunciations = pronunciations
self.keybindings = keybindings
settingsFile = open(self.settingsFile, 'w')
dump(prefs, settingsFile, indent=4)
settingsFile.close()
prefsDoc = document()
prefsDoc['general'] = prefs['general']
prefsDoc['profiles'] = prefs['profiles']
prefsDoc['pronunciations'] = prefs['pronunciations']
prefsDoc['keybindings'] = prefs['keybindings']
self._writeDocument(self.settingsFile, prefsDoc)
def getAppSettings(self, appName):
fileName = os.path.join(self.appPrefsDir, f"{appName}.conf")
if os.path.exists(fileName):
settingsFile = open(fileName, 'r')
prefs = load(settingsFile)
settingsFile.close()
else:
prefs = {}
return prefs
fileName = os.path.join(self.appPrefsDir, f"{appName}.toml")
return self._readDocument(fileName)
def saveAppSettings(self, appName, profile, general, pronunciations, keybindings):
prefs = self.getAppSettings(appName)
profiles = prefs.get('profiles', {})
profiles[profile] = {'general': general,
'pronunciations': pronunciations,
'keybindings': keybindings}
prefs['profiles'] = profiles
prefsDoc = self.getAppSettings(appName)
profiles = prefsDoc.get('profiles')
if profiles is None or not isinstance(profiles, dict):
prefsDoc['profiles'] = {}
profiles = prefsDoc['profiles']
fileName = os.path.join(self.appPrefsDir, f"{appName}.conf")
settingsFile = open(fileName, 'w')
dump(prefs, settingsFile, indent=4)
settingsFile.close()
profileTable = profiles.get(profile)
if profileTable is None or not isinstance(profileTable, dict):
profiles[profile] = {}
profileTable = profiles[profile]
self._updateTable(profileTable, {
'general': self._stripNone(general),
'pronunciations': self._stripNone(pronunciations),
'keybindings': self._stripNone(keybindings),
})
fileName = os.path.join(self.appPrefsDir, f"{appName}.toml")
self._writeDocument(fileName, prefsDoc)
def saveProfileSettings(self, profile, general,
pronunciations, keybindings):
""" Save minimal subset defined in the profile against current
""" Save minimal subset defined in the profile against current
defaults. """
if profile is None:
profile = 'default'
general['pronunciations'] = pronunciations
general['keybindings'] = keybindings
general = self._stripNone(general)
with open(self.settingsFile, 'r+') as settingsFile:
prefs = load(settingsFile)
prefs['profiles'][profile] = general
settingsFile.seek(0)
settingsFile.truncate()
dump(prefs, settingsFile, indent=4)
prefsDoc = self._readDocument(self.settingsFile)
profiles = prefsDoc.get('profiles')
if profiles is None or not isinstance(profiles, dict):
prefsDoc['profiles'] = {}
profiles = prefsDoc['profiles']
profileTable = profiles.get(profile)
if profileTable is None or not isinstance(profileTable, dict):
profiles[profile] = {}
profileTable = profiles[profile]
self._updateTable(profileTable, general)
self._writeDocument(self.settingsFile, prefsDoc)
def _getSettings(self):
""" Load from config file all settings """
settingsFile = open(self.settingsFile)
prefsDoc = self._readDocument(self.settingsFile)
try:
prefs = load(settingsFile)
except ValueError:
self.general = dict(prefsDoc.get('general', {}))
self.pronunciations = dict(prefsDoc.get('pronunciations', {}))
self.keybindings = dict(prefsDoc.get('keybindings', {}))
self.profiles = dict(prefsDoc.get('profiles', {}))
except Exception:
return
self.general = prefs['general'].copy()
self.pronunciations = prefs['pronunciations']
self.keybindings = prefs['keybindings']
self.profiles = prefs['profiles'].copy()
def _migrateSettings(self, settingsDict):
"""Migrate old setting names to new ones."""
@@ -195,18 +259,24 @@ class Backend:
def isFirstStart(self):
""" Check if we're in first start. """
return not os.path.exists(self.settingsFile)
def _setProfileKey(self, key, value):
self.general[key] = value
with open(self.settingsFile, 'r+') as settingsFile:
prefs = load(settingsFile)
prefs['general'][key] = value
settingsFile.seek(0)
settingsFile.truncate()
dump(prefs, settingsFile, indent=4)
prefsDoc = self._readDocument(self.settingsFile)
general = prefsDoc.get('general')
if general is None or not isinstance(general, dict):
prefsDoc['general'] = {}
general = prefsDoc['general']
if value is None:
if key in general:
del general[key]
else:
general[key] = value
self._writeDocument(self.settingsFile, prefsDoc)
def setFirstStart(self, value=False):
"""Set firstStart. This user-configurable setting is primarily
@@ -238,10 +308,8 @@ class Backend:
if profile in self.profiles:
removeProfileFrom(self.profiles)
with open(self.settingsFile, 'r+') as settingsFile:
prefs = load(settingsFile)
if profile in prefs['profiles']:
removeProfileFrom(prefs['profiles'])
settingsFile.seek(0)
settingsFile.truncate()
dump(prefs, settingsFile, indent=4)
prefsDoc = self._readDocument(self.settingsFile)
profiles = prefsDoc.get('profiles')
if isinstance(profiles, dict) and profile in profiles:
removeProfileFrom(profiles)
self._writeDocument(self.settingsFile, prefsDoc)
+1 -1
View File
@@ -23,5 +23,5 @@
# Forked from Orca screen reader.
# Cthulhu project: https://git.stormux.org/storm/cthulhu
version = "2026.01.26"
version = "2026.02.18"
codeName = "master"
+52 -5
View File
@@ -180,7 +180,10 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
self.savedGain = None
self.savedPitch = None
self.savedRate = None
self.soundThemeCombo = None
self.roleSoundPresentationCombo = None
self._isInitialSetup = False
self._updatingSpeechFamilies = False
self.selectedFamilyChoices = {}
self.selectedLanguageChoices = {}
self.profilesCombo = None
@@ -1269,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
@@ -1293,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.
@@ -1351,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
@@ -2671,6 +2683,8 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
# Get widget references
self.soundThemeCombo = self.get_widget("soundThemeCombo")
self.roleSoundPresentationCombo = self.get_widget("roleSoundPresentationCombo")
self.soundThemeCombo.set_can_focus(False)
self.roleSoundPresentationCombo.set_can_focus(False)
# Populate sound theme combo box
themeManager = sound_theme_manager.getManager()
@@ -2728,6 +2742,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
value = self._roleSoundPresentationChoices[activeIndex][0]
self.prefsDict["roleSoundPresentation"] = value
def _updateCthulhuModifier(self):
combobox = self.get_widget("cthulhuModifierComboBox")
keystring = ", ".join(self.prefsDict["cthulhuModifierKeys"])
@@ -2894,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.
@@ -3311,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)
+34
View File
@@ -844,6 +844,11 @@ class KeyboardEvent(InputEvent):
"shouldConsume: No handler found",
reason="no-handler", timestamp=True)
if self._isSleepModeActive():
if self._isSleepModeToggleHandler():
return True, 'Sleep mode toggle command'
return False, 'Sleep mode active'
self._script.updateKeyboardEventState(self, self._handler)
scriptConsumes = self._script.shouldConsumeKeyboardEvent(self, self._handler)
if globalHandlerUsed:
@@ -881,6 +886,35 @@ class KeyboardEvent(InputEvent):
return None
return global_bindings.getInputHandler(self)
def _isSleepModeActive(self):
"""Returns True if the script for this event is in sleep mode."""
if not self._script:
return False
if "scripts.sleepmode" in self._script.__module__:
return True
app = getattr(self._script, "app", None)
if app is None:
return False
try:
from . import sleep_mode_manager
manager = sleep_mode_manager.getManager()
return bool(manager and manager.isActiveForApp(app))
except Exception:
return False
def _isSleepModeToggleHandler(self):
"""Returns True if the resolved handler toggles sleep mode."""
if not self._handler or not self._handler.function:
return False
functionName = getattr(self._handler.function, "__name__", "")
return "toggleSleepMode" in functionName
def didConsume(self):
"""Returns True if this event was consumed."""
+3 -1
View File
@@ -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)
+11 -13
View File
@@ -140,21 +140,19 @@ Access comprehensive OCR settings through Cthulhu Preferences:
### Configuration File
Settings are automatically stored in Cthulhu's configuration system:
- **Global Settings**: `~/.local/share/cthulhu/user-settings.conf`
- **Profile Settings**: `~/.local/share/cthulhu/app-settings/[profile]/`
- **Global Settings**: `~/.local/share/cthulhu/user-settings.toml`
- **Application Settings**: `~/.local/share/cthulhu/app-settings/<app>.toml`
### Example Configuration Values
```json
{
"ocrLanguageCode": "eng",
"ocrScaleFactor": 3,
"ocrGrayscaleImg": false,
"ocrInvertImg": false,
"ocrBlackWhiteImg": false,
"ocrBlackWhiteImgValue": 200,
"ocrColorCalculation": false,
"ocrCopyToClipboard": true
}
```toml
ocrLanguageCode = "eng"
ocrScaleFactor = 3
ocrGrayscaleImg = false
ocrInvertImg = false
ocrBlackWhiteImg = false
ocrBlackWhiteImgValue = 200
ocrColorCalculation = false
ocrCopyToClipboard = true
```
## Troubleshooting
@@ -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
+213 -85
View File
@@ -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")
+10
View File
@@ -282,6 +282,16 @@ class ScriptManager:
Returns an instance of a Script.
"""
if app:
try:
from . import sleep_mode_manager
sleepModeManager = sleep_mode_manager.getManager()
sleepModeManager.refreshAutoSleepConfig()
if sleepModeManager and sleepModeManager.isActiveForApp(app):
return self.get_or_create_sleep_mode_script(app)
except Exception as error:
_log_tokens(["Could not check sleep mode for", app, ":", error], "sleep-mode-check-failed")
customScript = None
appScript = None
toolkitScript = None
+3 -1
View File
@@ -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."""
+10 -29
View File
@@ -75,9 +75,6 @@ class Script(default.Script):
"""Called when this script is deactivated."""
debug.printMessage(debug.LEVEL_INFO, "SLEEP MODE SCRIPT: Deactivating", True)
# Restore key grabs
self.addKeyGrabs()
cthulhu_modifier_manager.getManager().refreshCthulhuModifiers("Exiting sleep mode.")
super().deactivate()
@@ -86,18 +83,23 @@ class Script(default.Script):
"""Remove key grabs except for sleep mode toggle."""
try:
# First remove all grabs inherited from default activation,
# including modifier grabs.
super().removeKeyGrabs()
self.grab_ids = []
for keyBinding in self.keyBindings:
for keyBinding in self.keyBindings.keyBindings:
if hasattr(keyBinding, 'handler') and hasattr(keyBinding.handler, 'function'):
if hasattr(keyBinding.handler.function, '__name__'):
if 'toggleSleepMode' in keyBinding.handler.function.__name__:
# Keep sleep mode toggle
try:
import cthulhu
grab_id = cthulhu.addKeyGrab(keyBinding)
if grab_id:
self.grab_ids.append(grab_id)
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Kept sleep toggle key grab: {grab_id}", True)
grabIds = cthulhu.addKeyGrab(keyBinding)
if grabIds:
for grabId in grabIds:
self.grab_ids.append(grabId)
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Kept sleep toggle key grab: {grabId}", True)
except Exception as e:
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Error keeping key grab: {e}", True)
else:
@@ -106,27 +108,6 @@ class Script(default.Script):
except Exception as e:
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Error in removeKeyGrabs: {e}", True)
def addKeyGrabs(self):
"""Add back all key grabs."""
try:
# Remove our limited grabs first
if hasattr(self, 'grab_ids'):
import cthulhu
for grab_id in self.grab_ids:
try:
cthulhu.removeKeyGrab(grab_id)
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Removed key grab: {grab_id}", True)
except Exception as e:
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Error removing key grab {grab_id}: {e}", True)
self.grab_ids = []
# Let the parent class restore all grabs
super().addKeyGrabs()
except Exception as e:
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Error in addKeyGrabs: {e}", True)
# Block common event handlers as an additional layer of protection
def onCaretMoved(self, event):
"""Block caret movement events."""
+15 -3
View File
@@ -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]
+10 -1
View File
@@ -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')
+26 -7
View File
@@ -36,11 +36,11 @@ __license__ = "LGPL"
import copy
import importlib
import json
import os
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
from gi.repository import Gio, GLib
from tomlkit import parse
from . import debug
from . import cthulhu_i18n
@@ -62,7 +62,7 @@ class SettingsManager(object):
"""Settings backend manager. This class manages cthulhu user's settings
using different backends"""
def __init__(self, app: Cthulhu, backend: str = 'json') -> None: # Modified signature
def __init__(self, app: Cthulhu, backend: str = 'toml') -> None: # Modified signature
debug.printMessage(debug.LEVEL_INFO, 'SETTINGS MANAGER: Initializing', True)
self.app: Cthulhu = app # Store app instance
@@ -214,6 +214,25 @@ class SettingsManager(object):
if not os.path.exists(userCustomFile):
os.close(os.open(userCustomFile, os.O_CREAT, 0o700))
sleepConfigFile = os.path.join(cthulhuDir, "sleep.toml")
if not os.path.exists(sleepConfigFile):
sleepTemplate = (
"# Cthulhu auto-sleep apps\n"
"#\n"
"# List current app names with:\n"
"# cthulhu --list-apps\n"
"# Use the middle app-name column from that output.\n"
"#\n"
"# Add app names to auto-enable sleep mode:\n"
"# apps = [\"qemu\"]\n"
"#\n"
"# Or use a section:\n"
"# [sleep]\n"
"# apps = [\"qemu\"]\n"
)
with open(sleepConfigFile, "w", encoding="utf-8") as configFile:
configFile.write(sleepTemplate)
if self.isFirstStart() and self._backend:
self._backend.saveDefaultSettings(self.defaultGeneral,
self.defaultPronunciations,
@@ -259,13 +278,13 @@ class SettingsManager(object):
if not self._prefsDir:
return {}
settings_path = os.path.join(self._prefsDir, "user-settings.conf")
settings_path = os.path.join(self._prefsDir, "user-settings.toml")
if not os.path.exists(settings_path):
return {}
try:
with open(settings_path, "r", encoding="utf-8") as settings_file:
prefs = json.load(settings_file)
prefs = parse(settings_file.read())
except Exception as error:
msg = f"SETTINGS MANAGER: Unable to read default settings from {settings_path}: {error}"
debug.printMessage(debug.LEVEL_WARNING, msg, True)
@@ -572,9 +591,10 @@ class SettingsManager(object):
"""Return the current general settings.
Those settings comes from updating the default settings
with the profiles' ones"""
general = self.defaultGeneral.copy()
if self._backend:
return self._backend.getGeneral(profile)
return {}
general.update(self._backend.getGeneral(profile))
return general
def getPronunciations(self, profile: str = 'default') -> Dict[str, Any]:
"""Return the current pronunciations settings.
@@ -821,4 +841,3 @@ def getManager() -> Optional[SettingsManager]:
# During import phase, cthulhuApp may not exist yet
pass
return _managerInstance
+112 -5
View File
@@ -31,7 +31,9 @@ __copyright__ = "Copyright (c) 2024 Stormux"
__license__ = "LGPL"
import time
import os
from gi.repository import GLib
from tomlkit import parse
import cthulhu.braille as braille
import cthulhu.cmdnames as cmdnames
import cthulhu.debug as debug
@@ -47,7 +49,12 @@ class SleepModeManager:
def __init__(self):
self._handlers = self.getHandlers(True)
self._bindings = keybindings.KeyBindings()
self._apps = []
self._apps = set()
self._disabledAutoSleepApps = set()
self._autoSleepAppNames = set()
self._autoSleepPath = self._getAutoSleepPath()
self._autoSleepConfigMTime = None
self._loadAutoSleepConfig()
self._lastToggleTime = 0
self._toggleDebounceDelay = 0.1 # 100ms debounce (reduced for better responsiveness)
@@ -76,12 +83,106 @@ class SleepModeManager:
def isActiveForApp(self, app):
"""Returns True if sleep mode is active for app."""
result = bool(app and hash(app) in self._apps)
if not app:
return False
appHash = hash(app)
result = appHash in self._apps
if not result and self._isAutoSleepConfiguredForApp(app):
result = appHash not in self._disabledAutoSleepApps
if result:
tokens = ["SLEEP MODE MANAGER: Is active for", app]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return result
def _getAutoSleepPath(self):
prefsDir = os.path.join(GLib.get_user_data_dir(), "cthulhu")
try:
from . import cthulhu
app = cthulhu.cthulhuApp
if app and app.settingsManager:
configuredDir = app.settingsManager.getPrefsDir()
if configuredDir:
prefsDir = configuredDir
except Exception:
pass
return os.path.join(prefsDir, "sleep.toml")
def _refreshAutoSleepPath(self):
latestPath = self._getAutoSleepPath()
if latestPath != self._autoSleepPath:
self._autoSleepPath = latestPath
self._autoSleepConfigMTime = None
self._loadAutoSleepConfig()
return
try:
latestMTime = os.path.getmtime(self._autoSleepPath)
except OSError:
latestMTime = None
if latestMTime != self._autoSleepConfigMTime:
self._loadAutoSleepConfig()
def refreshAutoSleepConfig(self):
"""Refresh auto-sleep config if prefs directory has changed."""
self._refreshAutoSleepPath()
def _loadAutoSleepConfig(self):
self._autoSleepAppNames = set()
self._autoSleepConfigMTime = None
if not os.path.isfile(self._autoSleepPath):
msg = f"SLEEP MODE MANAGER: No sleep config at {self._autoSleepPath}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return
try:
self._autoSleepConfigMTime = os.path.getmtime(self._autoSleepPath)
except OSError:
self._autoSleepConfigMTime = None
try:
with open(self._autoSleepPath, "r", encoding="utf-8") as configFile:
config = parse(configFile.read() or "")
except Exception as error:
tokens = ["SLEEP MODE MANAGER: Failed to parse", self._autoSleepPath, ":", error]
debug.printTokens(debug.LEVEL_WARNING, tokens, True)
return
appNames = []
topLevelApps = config.get("apps", [])
if isinstance(topLevelApps, list):
appNames.extend(topLevelApps)
sleepSection = config.get("sleep", {})
if isinstance(sleepSection, dict):
sectionApps = sleepSection.get("apps", [])
if isinstance(sectionApps, list):
appNames.extend(sectionApps)
for appName in appNames:
if not isinstance(appName, str):
continue
normalizedName = appName.strip().lower()
if normalizedName:
self._autoSleepAppNames.add(normalizedName)
msg = f"SLEEP MODE MANAGER: Loaded {len(self._autoSleepAppNames)} auto-sleep apps from {self._autoSleepPath}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
def _isAutoSleepConfiguredForApp(self, app):
if not app:
return False
if not self._autoSleepAppNames:
return False
appName = (AXObject.get_name(app) or "").strip().lower()
return bool(appName and appName in self._autoSleepAppNames)
def _setupHandlers(self):
"""Sets up and returns the sleep-mode-manager input event handlers."""
@@ -132,12 +233,16 @@ class SleepModeManager:
if not (script and script.app):
return True
from . import cthulhu_state
self.refreshAutoSleepConfig()
scriptManager = script_manager.get_manager()
if self.isActiveForApp(script.app):
# Turning OFF sleep mode
self._apps.remove(hash(script.app))
appHash = hash(script.app)
self._apps.discard(appHash)
if self._isAutoSleepConfiguredForApp(script.app):
self._disabledAutoSleepApps.add(appHash)
newScript = scriptManager.get_script(script.app)
if notifyUser:
newScript.presentMessage(
@@ -177,7 +282,9 @@ class SleepModeManager:
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Active script set successfully", True)
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Adding app to sleep list", True)
self._apps.append(hash(script.app))
appHash = hash(script.app)
self._disabledAutoSleepApps.discard(appHash)
self._apps.add(appHash)
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Enabled for {AXObject.get_name(script.app)} (delayed)", True)
# Reset debounce timer after successful toggle
self._lastToggleTime = 0
+28 -8
View File
@@ -215,16 +215,37 @@ class SoundThemeManager:
def getSoundPath(self, themeName, soundName):
"""Get path to a specific sound file.
Checks for common audio file extensions: .wav, .ogg, .mp3, .flac
Checks for common audio file extensions: .wav, .ogg, .mp3, .flac.
User themes are checked first, then system themes as fallback so
partial user themes can inherit missing sounds.
"""
themePath = self.getThemePath(themeName)
if not themePath:
themePaths = []
userPath = os.path.join(self.getUserSoundsDir(), themeName)
if os.path.isdir(userPath):
themePaths.append(userPath)
systemPath = os.path.join(self.getSystemSoundsDir(), themeName)
if os.path.isdir(systemPath):
themePaths.append(systemPath)
fallbackDirs = [
'/usr/share/cthulhu/sounds',
'/usr/local/share/cthulhu/sounds',
]
for fallbackDir in fallbackDirs:
fallbackPath = os.path.join(fallbackDir, themeName)
if os.path.isdir(fallbackPath) and fallbackPath not in themePaths:
themePaths.append(fallbackPath)
if not themePaths:
return None
for ext in ['.wav', '.ogg', '.mp3', '.flac']:
soundPath = os.path.join(themePath, soundName + ext)
if os.path.isfile(soundPath):
return soundPath
for themePath in themePaths:
for ext in ['.wav', '.ogg', '.mp3', '.flac']:
soundPath = os.path.join(themePath, soundName + ext)
if os.path.isfile(soundPath):
return soundPath
return None
@@ -395,4 +416,3 @@ def getManager():
from . import cthulhu
_manager = SoundThemeManager(cthulhu.cthulhuApp)
return _manager
+25 -1
View File
@@ -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
+11 -3
View File
@@ -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):
+11 -6
View File
@@ -748,15 +748,20 @@ class SpeechGenerator(generator.Generator):
method for scripts to call.
"""
generated = self._generateRoleName(obj, **args)
if generated:
return generated[0]
return ""
return self._getFirstString(generated)
def getName(self, obj, **args):
generated = self._generateName(obj, **args)
if generated:
return generated[0]
return self._getFirstString(generated)
def _getFirstString(self, generated):
for item in generated or []:
if isinstance(item, str):
return item
if isinstance(item, list):
nestedString = self._getFirstString(item)
if nestedString:
return nestedString
return ""