diff --git a/source/NVDAObjects/UIA/__init__.py b/source/NVDAObjects/UIA/__init__.py index 42c16b01994..8d1e4a4b436 100644 --- a/source/NVDAObjects/UIA/__init__.py +++ b/source/NVDAObjects/UIA/__init__.py @@ -30,6 +30,7 @@ import controlTypes from controlTypes import TextPosition, TextAlign import config +from config.configFlags import ReportSpellingErrors import speech import api import textInfos @@ -312,7 +313,7 @@ def _getFormatFieldAnnotationTypes( # Always mutate to a tuple to allow for a generic x in y matching if not isinstance(annotationTypes, tuple): annotationTypes = (annotationTypes,) - if formatConfig["reportSpellingErrors"]: + if formatConfig["reportSpellingErrors2"] != ReportSpellingErrors.OFF.value: if UIAHandler.AnnotationType_SpellingError in annotationTypes: formatField["invalid-spelling"] = True if UIAHandler.AnnotationType_GrammarError in annotationTypes: @@ -367,7 +368,7 @@ def _getFormatFieldAtRange( # noqa: C901 if not isinstance(textRange, UIAHandler.IUIAutomationTextRange): raise ValueError("%s is not a text range" % textRange) fetchAnnotationTypes = ( - formatConfig["reportSpellingErrors"] + formatConfig["reportSpellingErrors2"] != ReportSpellingErrors.OFF.value or formatConfig["reportComments"] or formatConfig["reportRevisions"] or formatConfig["reportBookmarks"] diff --git a/source/NVDAObjects/behaviors.py b/source/NVDAObjects/behaviors.py index e958230bbb5..c92ec761371 100755 --- a/source/NVDAObjects/behaviors.py +++ b/source/NVDAObjects/behaviors.py @@ -2,7 +2,7 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. # Copyright (C) 2006-2025 NV Access Limited, Peter Vágner, Joseph Lee, Bill Dengler, -# Burman's Computer and Education Ltd, Cary-rowen +# Burman's Computer and Education Ltd, Cary-rowen, Cyrille Bougot """Mix-in classes which provide common behaviour for particular types of controls across different APIs. Behaviors described in this mix-in include providing table navigation commands for certain table rows, terminal input and output support, announcing notifications and suggestion items and so on. @@ -31,7 +31,10 @@ import globalVars from typing import List, Union import diffHandler -from config.configFlags import TypingEcho +from config.configFlags import ( + TypingEcho, + ReportSpellingErrors, +) class ProgressBar(NVDAObject): @@ -297,7 +300,7 @@ def _delayedDetection(): def event_typedCharacter(self, ch: str): if ( - config.conf["documentFormatting"]["reportSpellingErrors"] + config.conf["documentFormatting"]["reportSpellingErrors2"] != ReportSpellingErrors.OFF.value and config.conf["keyboard"]["alertForSpellingErrors"] and ( # Not alpha, apostrophe or control. diff --git a/source/config/__init__.py b/source/config/__init__.py index 8e674888d40..bdc76cdf6eb 100644 --- a/source/config/__init__.py +++ b/source/config/__init__.py @@ -1286,7 +1286,7 @@ def __setitem__( # Alias old config items to their new counterparts for backwards compatibility. # Uncomment when there are new links that need to be made. - # if BACK_COMPAT_TO < (2026, 1, 0) and NVDAState._allowDeprecatedAPI(): + # if BACK_COMPAT_TO < (2027, 1, 0) and NVDAState._allowDeprecatedAPI(): # self._linkDeprecatedValues(key, val) def _linkDeprecatedValues(self, key: aggregatedSection._cacheKeyT, val: aggregatedSection._cacheValueT): diff --git a/source/config/configFlags.py b/source/config/configFlags.py index 5b3d5bc8638..dd1ce7aa693 100644 --- a/source/config/configFlags.py +++ b/source/config/configFlags.py @@ -180,6 +180,39 @@ def _displayStringLabels(self): } +@unique +class ReportSpellingErrors(DisplayStringIntFlag): + """IntFlag enumeration containing the possible config values to report spelling errors while reading. + + Use ReportSpellingErrors.MEMBER.value to compare with the config; + the config stores a bitwise combination of zero, one or more of these values. + Use ReportSpellingErrors.MEMBER.displayString in the UI for a translatable description of this member. + """ + + OFF = 0b0 + SPEECH = 0b1 + SOUND = 0b10 + SPEECH_AND_SOUND = SPEECH | SOUND + + @property + def _displayStringLabels(self) -> dict["ReportSpellingErrors", str]: + return { + # Translators: A value reported by the cycle script defining how spelling errors are reported. + ReportSpellingErrors.OFF: pgettext("reportSpellingErrorsSetting", "Off"), + # Translators: A value reported by the cycle script defining how spelling errors are reported, also used + # as choice in a checklist box in the document formatting dialog to report spelling errors with speech. + ReportSpellingErrors.SPEECH: pgettext("reportSpellingErrorsSetting", "Speech"), + # Translators: A value reported by the cycle script defining how spelling errors are reported, also used + # as choice in a checklist box in the document formatting dialog to report spelling errors with a sound. + ReportSpellingErrors.SOUND: pgettext("reportSpellingErrorsSetting", "Sound"), + ReportSpellingErrors.SPEECH_AND_SOUND: pgettext( + "reportSpellingErrorsSetting", + # Translators: A value reported by the cycle script defining how spelling errors are reported. + "Speech and sound", + ), + } + + @unique class ReportTableHeaders(DisplayStringIntEnum): """Enumeration containing the possible config values to report table headers. diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 69864c00ad2..bf411ce42af 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -13,7 +13,7 @@ #: provide an upgrade step (@see profileUpgradeSteps.py). An upgrade step does not need to be added when #: just adding a new element to (or removing from) the schema, only when old versions of the config #: (conforming to old schema versions) will not work correctly with the new schema. -latestSchemaVersion = 18 +latestSchemaVersion = 19 #: The configuration specification string #: @type: String @@ -227,7 +227,9 @@ reportAlignment = boolean(default=false) reportLineSpacing = boolean(default=false) reportStyle = boolean(default=false) - reportSpellingErrors = boolean(default=true) + # Bitwise combination of none, some or all values of ReportSpellingErrors + # 1: Speech, 2: Sound + reportSpellingErrors2 = integer(min=0, max=3, default=1) reportPage = boolean(default=true) reportLineNumber = boolean(default=False) # 0: Off, 1: Speech, 2: Tones, 3: Both Speech and Tones diff --git a/source/config/profileUpgradeSteps.py b/source/config/profileUpgradeSteps.py index fbc35e379bf..d19a848a1f6 100644 --- a/source/config/profileUpgradeSteps.py +++ b/source/config/profileUpgradeSteps.py @@ -24,6 +24,7 @@ OutputMode, ReportCellBorders, ReportLineIndentation, + ReportSpellingErrors, ReportTableHeaders, ShowMessages, TetherTo, @@ -597,3 +598,27 @@ def upgradeConfigFrom_17_to_18(profile: ConfigObj) -> None: "dotPad added to braille display auto detection excluded displays due to generic USB PID/VID. " f"List is now: {excludedDisplays}", ) + + +def upgradeConfigFrom_18_to_19(profile: ConfigObj): + """Convert report spelling errors configurations from boolean to integer values.""" + + section = "documentFormatting" + key = "reportSpellingErrors" + newKey = "reportSpellingErrors2" + try: + oldValue: bool = profile[section].as_bool(key) + except KeyError: + log.debug(f"'{key}' not present in config, no action taken.") + return + except ValueError: + log.error(f"'{key}' is not a boolean, got {profile[section][key]!r}. No action taken.") + return + + newValue = ReportSpellingErrors.SPEECH.value if oldValue else ReportSpellingErrors.OFF.value + profile[section][newKey] = newValue + del profile[section][key] + log.debug( + f"Converted '{key}' with value {oldValue} to '{newKey}' with value {newValue}" + f" ({ReportSpellingErrors(newValue).name}). The old key '{key}' has been deleted.", + ) diff --git a/source/globalCommands.py b/source/globalCommands.py index ac5f79f8f1f..a98cf05bcc4 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -45,6 +45,7 @@ BrailleMode, OutputMode, TypingEcho, + ReportSpellingErrors, ) from config.featureFlag import FeatureFlag from config.featureFlagEnums import BoolFlag @@ -788,19 +789,18 @@ def script_toggleReportStyle(self, gesture): @script( # Translators: Input help mode message for toggle report spelling errors command. - description=_("Toggles on and off the reporting of spelling errors"), + description=_("Cycles through options for how to report spelling errors"), category=SCRCAT_DOCUMENTFORMATTING, ) - def script_toggleReportSpellingErrors(self, gesture): - if config.conf["documentFormatting"]["reportSpellingErrors"]: - # Translators: The message announced when toggling the report spelling errors document formatting setting. - state = _("report spelling errors off") - config.conf["documentFormatting"]["reportSpellingErrors"] = False - else: - # Translators: The message announced when toggling the report spelling errors document formatting setting. - state = _("report spelling errors on") - config.conf["documentFormatting"]["reportSpellingErrors"] = True - ui.message(state) + def script_toggleReportSpellingErrors(self, gesture: inputCore.InputGesture): + toggleIntegerValue( + configSection="documentFormatting", + configKey="reportSpellingErrors2", + enumClass=ReportSpellingErrors, + # Translators: Reported when the user cycles through the choices to report spelling errors. + # {mode} will be replaced with the mode; e.g. Off, Speech, Sound. + messageTemplate=_("Report spelling errors {mode}"), + ) @script( # Translators: Input help mode message for toggle report pages command. @@ -2536,7 +2536,7 @@ def _reportFormattingHelper(self, info, browseable=False): "reportColor", "reportStyle", "reportAlignment", - "reportSpellingErrors", + "reportSpellingErrors2", "reportLineIndentation", "reportParagraphIndentation", "reportLineSpacing", diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index a74ce64a32e..cabf7a00595 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -41,6 +41,7 @@ TetherTo, ParagraphStartMarker, ReportLineIndentation, + ReportSpellingErrors, ReportTableHeaders, ReportCellBorders, OutputMode, @@ -2124,7 +2125,7 @@ def makeSettings(self, settingsSizer): ) self.bindHelpEvent("KeyboardSettingsAlertForSpellingErrors", self.alertForSpellingErrorsCheckBox) self.alertForSpellingErrorsCheckBox.SetValue(config.conf["keyboard"]["alertForSpellingErrors"]) - if not config.conf["documentFormatting"]["reportSpellingErrors"]: + if not config.conf["documentFormatting"]["reportSpellingErrors2"]: self.alertForSpellingErrorsCheckBox.Disable() # Translators: This is the label for a checkbox in the @@ -2810,11 +2811,23 @@ def makeSettings(self, settingsSizer): self.revisionsCheckBox = docInfoGroup.addItem(wx.CheckBox(docInfoBox, label=revisionsText)) self.revisionsCheckBox.SetValue(config.conf["documentFormatting"]["reportRevisions"]) - # Translators: This is the label for a checkbox in the - # document formatting settings panel. - spellingErrorText = _("Spelling e&rrors") - self.spellingErrorsCheckBox = docInfoGroup.addItem(wx.CheckBox(docInfoBox, label=spellingErrorText)) - self.spellingErrorsCheckBox.SetValue(config.conf["documentFormatting"]["reportSpellingErrors"]) + self._spellingErrorsChecklist = docInfoGroup.addLabeledControl( + # Translators: This is the label for a checklist in the + # document formatting settings panel. + _("Spelling e&rrors"), + nvdaControls.CustomCheckListBox, + choices=[i.displayString for i in ReportSpellingErrors], + ) + checkedItems = [] + for i, mode in enumerate(ReportSpellingErrors): + if config.conf["documentFormatting"]["reportSpellingErrors2"] & mode.value: + checkedItems.append(i) + self._spellingErrorsChecklist.SetCheckedItems(checkedItems) + self._spellingErrorsChecklist.Select(0) + self.bindHelpEvent( + "reportSpellingErrors", + self._spellingErrorsChecklist, + ) # Translators: This is the label for a group of document formatting options in the # document formatting settings panel @@ -3037,7 +3050,11 @@ def onSave(self): config.conf["documentFormatting"]["reportHighlight"] = self.highlightCheckBox.IsChecked() config.conf["documentFormatting"]["reportAlignment"] = self.alignmentCheckBox.IsChecked() config.conf["documentFormatting"]["reportStyle"] = self.styleCheckBox.IsChecked() - config.conf["documentFormatting"]["reportSpellingErrors"] = self.spellingErrorsCheckBox.IsChecked() + config.conf["documentFormatting"]["reportSpellingErrors2"] = sum( + mode.value + for (n, mode) in enumerate(ReportSpellingErrors) + if self._spellingErrorsChecklist.IsChecked(n) + ) config.conf["documentFormatting"]["reportPage"] = self.pageCheckBox.IsChecked() config.conf["documentFormatting"]["reportLineNumber"] = self.lineNumberCheckBox.IsChecked() config.conf["documentFormatting"]["reportLineIndentation"] = self.lineIndentationCombo.GetSelection() diff --git a/source/speech/speech.py b/source/speech/speech.py index b25ca07a612..8173f898eca 100644 --- a/source/speech/speech.py +++ b/source/speech/speech.py @@ -39,6 +39,7 @@ EndUtteranceCommand, SuppressUnicodeNormalizationCommand, CharacterModeCommand, + WaveFileCommand, ) from .shortcutKeys import getKeyboardShortcutsSpeech @@ -66,6 +67,7 @@ import config from config.configFlags import ( ReportLineIndentation, + ReportSpellingErrors, ReportTableHeaders, ReportCellBorders, OutputMode, @@ -1501,7 +1503,7 @@ def speakTextInfo( def getTextInfoSpeech( # noqa: C901 info: textInfos.TextInfo, useCache: Union[bool, SpeakTextInfoState] = True, - formatConfig: Dict[str, bool] = None, + formatConfig: dict[str, bool | int] | None = None, unit: Optional[str] = None, reason: OutputReason = OutputReason.QUERY, _prefixSpeechCommand: Optional[SpeechCommand] = None, @@ -1525,7 +1527,7 @@ def getTextInfoSpeech( # noqa: C901 ) # For performance reasons, when navigating by paragraph or table cell, spelling errors will not be announced. if unit in (textInfos.UNIT_PARAGRAPH, textInfos.UNIT_CELL) and reason == OutputReason.CARET: - formatConfig["reportSpellingErrors"] = False + formatConfig["reportSpellingErrors2"] = 0 # Fetch the last controlFieldStack, or make a blank one controlFieldStackCache = speakTextInfoState.controlFieldStackCache if speakTextInfoState else [] @@ -1902,7 +1904,7 @@ def _getTextInfoSpeech_considerSpelling( speechSequence: SpeechSequence, language: str, ) -> Generator[SpeechSequence, None, None]: - if onlyInitialFields or any(isinstance(x, str) for x in speechSequence): + if onlyInitialFields or speechSequence: yield speechSequence if not onlyInitialFields: spellingSequence = list( @@ -3002,20 +3004,21 @@ def getFormatFieldSpeech( # noqa: C901 # Translators: Reported when text no longer contains a bookmark text = _("out of bookmark") textList.append(text) - if formatConfig["reportSpellingErrors"]: + if formatConfig["reportSpellingErrors2"]: invalidSpelling = attrs.get("invalid-spelling") oldInvalidSpelling = attrsCache.get("invalid-spelling") if attrsCache is not None else None if (invalidSpelling or oldInvalidSpelling is not None) and invalidSpelling != oldInvalidSpelling: + texts = [] if invalidSpelling: - # Translators: Reported when text contains a spelling error. - text = _("spelling error") + if formatConfig["reportSpellingErrors2"] & ReportSpellingErrors.SOUND.value: + texts.append(WaveFileCommand(r"waves\textError.wav")) + if formatConfig["reportSpellingErrors2"] & ReportSpellingErrors.SPEECH.value: + # Translators: Reported when text contains a spelling error. + texts.append(_("spelling error")) elif extraDetail: # Translators: Reported when moving out of text containing a spelling error. - text = _("out of spelling error") - else: - text = "" - if text: - textList.append(text) + texts.append(_("out of spelling error")) + textList.extend(texts) invalidGrammar = attrs.get("invalid-grammar") oldInvalidGrammar = attrsCache.get("invalid-grammar") if attrsCache is not None else None if (invalidGrammar or oldInvalidGrammar is not None) and invalidGrammar != oldInvalidGrammar: diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 723ca4d789a..2912e5445d1 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -32,11 +32,12 @@ _upgradeConfigFrom_8_to_9_cellBorders, _upgradeConfigFrom_8_to_9_showMessages, _upgradeConfigFrom_8_to_9_tetherTo, - upgradeConfigFrom_13_to_14, upgradeConfigFrom_9_to_10, upgradeConfigFrom_11_to_12, + upgradeConfigFrom_13_to_14, upgradeConfigFrom_16_to_17, upgradeConfigFrom_17_to_18, + upgradeConfigFrom_18_to_19, ) from config.configFlags import ( NVDAKey, @@ -45,6 +46,7 @@ ReportCellBorders, TetherTo, OutputMode, + ReportSpellingErrors, ) from utils.displayString import ( DisplayStringEnum, @@ -895,7 +897,7 @@ def test_update_FeatureFlag_defaultValue_fromValueOfDefault(self): class Config_AggregatedSection_pollution(unittest.TestCase): - """Ënsure that config profiles don't get polluted with overridden values equal to the base config""" + """Ensure that config profiles don't get polluted with overridden values equal to the base config""" def setUp(self): manager = ConfigManager() @@ -1161,3 +1163,57 @@ def test_dotPadAlreadyExcluded(self): upgradeConfigFrom_17_to_18(profile) expected = ["dotPad", "hidBrailleStandard"] self.assertEqual(profile["braille"]["auto"]["excludedDisplays"], expected) + + +class Config_upgradeProfileSteps_upgradeProfileFrom_18_to_19(unittest.TestCase): + def test_DefaultProfile_Unmodified(self): + """reportSpellingErrors unmodified.""" + configString = "[documentFormatting]" + profile = _loadProfile(configString) + upgradeConfigFrom_18_to_19(profile) + with self.assertRaises(KeyError): + profile["documentFormatting"]["reportSpellingErrors"] + with self.assertRaises(KeyError): + profile["documentFormatting"]["reportSpellingErrors2"] + + def test_defaultProfile_reportSpellingErrors_false(self): + """reportSpellingErrors set to False.""" + configString = """ + [documentFormatting] + reportSpellingErrors = False + """ + profile = _loadProfile(configString) + upgradeConfigFrom_18_to_19(profile) + with self.assertRaises(KeyError): + profile["documentFormatting"]["reportSpellingErrors"] + self.assertEqual( + profile["documentFormatting"]["reportSpellingErrors2"], + ReportSpellingErrors.OFF.value, + ) + + def test_defaultProfile_reportSpellingErrors_true(self): + """reportSpellingErrors set to True.""" + configString = """ + [documentFormatting] + reportSpellingErrors = True + """ + profile = _loadProfile(configString) + upgradeConfigFrom_18_to_19(profile) + with self.assertRaises(KeyError): + profile["documentFormatting"]["reportSpellingErrors"] + self.assertEqual( + profile["documentFormatting"]["reportSpellingErrors2"], + ReportSpellingErrors.SPEECH.value, + ) + + def test_defaultProfile_reportSpellingErrors_invalid(self): + """reportSpellingErrors set to a non-boolean value.""" + configString = """ + [documentFormatting] + reportSpellingErrors = notABool + """ + profile = _loadProfile(configString) + upgradeConfigFrom_18_to_19(profile) + self.assertEqual(profile["documentFormatting"]["reportSpellingErrors"], "notABool") + with self.assertRaises(KeyError): + profile["documentFormatting"]["reportSpellingErrors2"] diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index b5a3be1870d..098119d0405 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -16,6 +16,7 @@ We recommend updating to Windows 11, or when that's not possible, to the latest This can be enabled using the "Report when lists support multiple selection" setting in NVDA's object presentation settings. (#18365 @LeonarddeR) * In Visual Studio Code, the status bar is now reported when using the standard `NVDA+end` (desktop) / `NVDA+shift+end` (laptop) gesture. (#11064, @codeofdusk) * Performance improvements on ARM64 systems, such as with Qualcomm processors. (#18570, @leonarddeR) +* While reading text, spelling errors can now be reported with a sound instead of speech. (#4233, @jcsteh, @CyrilleB79) ### Changes @@ -58,6 +59,8 @@ These should be supported natively in Python 3.13. (#18689) Most API consumers should not be impacted by this change. Use `NVDAHelper.localLib.dll` for access to the `ctypes.CDLL` if necessary. (#18207) * `UIAHandler.autoSelectDetectionAvailable` is removed with no replacement. (#18684, @josephsl) +* The `bool` configuration key `[documentFormatting][reportSpellingErrors]` has been removed. +Use the `int` configuration key `[reportSpellingErrors2]` instead. (#17997, @CyrilleB79) #### Deprecations diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index 986f0d8ac3f..a6407f46b7a 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -2668,7 +2668,7 @@ When enabled, NVDA will announce all non-character keys you type on the keyboard ##### Play sound for spelling errors while typing {#KeyboardSettingsAlertForSpellingErrors} When enabled, a short buzzer sound will be played when a word you type contains a spelling error. -This option is only available if reporting of spelling errors is enabled in NVDA's [Document Formatting Settings](#DocumentFormattingSettings), found in the NVDA Settings dialog. +This option is only available if [reporting of spelling errors](#reportSpellingErrors) is enabled in NVDA's Document Formatting Settings. ##### Handle keys from other applications {#KeyboardSettingsHandleKeys} @@ -3016,7 +3016,7 @@ You can configure reporting of: * Comments * Bookmarks * Editor revisions - * Spelling errors + * Spelling errors [(Off, Speech, Sound)](#reportSpellingErrors) * Pages and spacing * Page numbers * Line numbers @@ -3065,6 +3065,18 @@ By default, NVDA will detect the formatting at the position of the System caret Enable this option while proof reading documents in applications such as WordPad, where formatting is important. +##### Spelling error reporting {#reportSpellingErrors} + +This option allows you to configure how spelling errors are reported while reading text. +This checklist box has two options: + +* Speech: NVDA will say "spelling error" when a spelling error is encountered while reading text +* Sound: NVDA will play a short buzzer sound when a spelling error is encountered while reading text + +When navigating word by word or character by character, "out of spelling error" is also reported if the "Speech" or "Sound" option is selected. + +Due to performance limitations, spelling errors are not reported when navigating by paragraph or by cell in tables, no matter the choice selected in this checklist box. + ##### Line indentation reporting {#DocumentFormattingSettingsLineIndentation} This option allows you to configure how indentation at the beginning of lines is reported.