Skip to content

Commit b7abfe1

Browse files
authored
Merge 7e3adb5 into 021c13d
2 parents 021c13d + 7e3adb5 commit b7abfe1

9 files changed

Lines changed: 236 additions & 28 deletions

File tree

source/config/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -635,7 +635,12 @@ def dict(self):
635635
return self.rootSection.dict()
636636

637637
def listProfiles(self):
638-
for name in os.listdir(WritePaths.profilesDir):
638+
try:
639+
profileFiles = os.listdir(WritePaths.profilesDir)
640+
except FileNotFoundError:
641+
log.debugWarning("Profiles directory does not exist.")
642+
profileFiles = []
643+
for name in profileFiles:
639644
name, ext = os.path.splitext(name)
640645
if ext == ".ini":
641646
yield name

source/config/configSpec.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# A part of NonVisual Desktop Access (NVDA)
22
# Copyright (C) 2006-2023 NV Access Limited, Babbage B.V., Davy Kager, Bill Dengler, Julien Cochuyt,
33
# Joseph Lee, Dawid Pieper, mltony, Bram Duvigneau, Cyrille Bougot, Rob Meredith,
4-
# Burman's Computer and Education Ltd., Leonard de Ruijter
4+
# Burman's Computer and Education Ltd., Leonard de Ruijter, Łukasz Golonka
55
# This file is covered by the GNU General Public License.
66
# See the file COPYING for more details.
77

@@ -40,6 +40,7 @@
4040
autoLanguageSwitching = boolean(default=true)
4141
autoDialectSwitching = boolean(default=false)
4242
delayedCharacterDescriptions = boolean(default=false)
43+
excludedSpeechModes = int_list(default=list())
4344
4445
[[__many__]]
4546
capPitchChange = integer(default=30,min=-100,max=100)

source/globalCommands.py

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2023,29 +2023,26 @@ def script_review_currentSymbol(self,gesture):
20232023
@script(
20242024
description=_(
20252025
# Translators: Input help mode message for cycle speech mode command.
2026-
"Cycles between the speech modes of off, beep, talk and on-demand."
2026+
"Cycles between enabled speech modes."
20272027
),
20282028
category=SCRCAT_SPEECH,
20292029
gesture="kb:NVDA+s"
20302030
)
20312031
def script_speechMode(self, gesture: inputCore.InputGesture) -> None:
20322032
curMode = speech.getState().speechMode
20332033
speech.setSpeechMode(speech.SpeechMode.talk)
2034-
newMode = (curMode + 1) % len(speech.SpeechMode)
2035-
if newMode == speech.SpeechMode.off:
2036-
# Translators: A speech mode which disables speech output.
2037-
name=_("Speech mode off")
2038-
elif newMode == speech.SpeechMode.beeps:
2039-
# Translators: A speech mode which will cause NVDA to beep instead of speaking.
2040-
name=_("Speech mode beeps")
2041-
elif newMode == speech.SpeechMode.talk:
2042-
# Translators: The normal speech mode; i.e. NVDA will talk as normal.
2043-
name=_("Speech mode talk")
2044-
elif newMode == speech.SpeechMode.onDemand:
2045-
# Translators: The on-demand speech mode; i.e. NVDA will talk only on commands asking to report something.
2046-
name = _("Speech mode on-demand")
2034+
modesList = list(speech.SpeechMode)
2035+
currModeIndex = modesList.index(curMode)
2036+
excludedModesIndexes = config.conf["speech"]["excludedSpeechModes"]
2037+
possibleIndexes = [i for i in range(len(modesList)) if i not in excludedModesIndexes]
2038+
# Sorting uses `<=` since when sorting booleans they are threated as integers,
2039+
# so `False` (0) comes before `True` (1).
2040+
newModeIndex = sorted(possibleIndexes, key=lambda i: i <= currModeIndex)[0]
2041+
newMode = modesList[newModeIndex]
20472042
speech.cancelSpeech()
2048-
ui.message(name)
2043+
# Translators: Announced when user switches to another speech mode.
2044+
# 'mode' is replaced with the translated name of the new mode.
2045+
ui.message(_("Speech mode {mode}").format(mode=newMode.displayString))
20492046
speech.setSpeechMode(newMode)
20502047

20512048
@script(

source/gui/settingsDialogs.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -404,13 +404,12 @@ def onSave(self):
404404
"""
405405
raise NotImplementedError
406406

407-
def isValid(self):
407+
def isValid(self) -> bool:
408408
"""Evaluate whether the current circumstances of this panel are valid
409409
and allow saving all the settings in a L{MultiCategorySettingsDialog}.
410410
Sub-classes may extend this method.
411411
@returns: C{True} if validation should continue,
412412
C{False} otherwise.
413-
@rtype: bool
414413
"""
415414
return True
416415

@@ -1094,6 +1093,10 @@ def onDiscard(self):
10941093
def onSave(self):
10951094
self.voicePanel.onSave()
10961095

1096+
def isValid(self) -> bool:
1097+
return self.voicePanel.isValid()
1098+
1099+
10971100
class SynthesizerSelectionDialog(SettingsDialog):
10981101
# Translators: This is the label for the synthesizer selection dialog
10991102
title = _("Select Synthesizer")
@@ -1625,6 +1628,22 @@ def makeSettings(self, settingsSizer):
16251628
self.useSpellingFunctionalityCheckBox.SetValue(
16261629
config.conf["speech"][self.driver.name]["useSpellingFunctionality"]
16271630
)
1631+
self._appendSpeechModesList(settingsSizerHelper)
1632+
1633+
def _appendSpeechModesList(self, settingsSizerHelper: guiHelper.BoxSizerHelper) -> None:
1634+
self._allSpeechModes = [mode for mode in speech.SpeechMode]
1635+
self.speechModesList: nvdaControls.CustomCheckListBox = settingsSizerHelper.addLabeledControl(
1636+
# Translators: Label of the list where user can enable or disable speech modes.
1637+
_("Switch between the following speech &modes:"),
1638+
nvdaControls.CustomCheckListBox,
1639+
choices=[mode.displayString for mode in self._allSpeechModes]
1640+
)
1641+
self.bindHelpEvent("SpeechModesDisabling", self.speechModesList)
1642+
excludedModes = config.conf["speech"]["excludedSpeechModes"]
1643+
self.speechModesList.Checked = [
1644+
mIndex for mIndex in range(len(self._allSpeechModes)) if mIndex not in excludedModes
1645+
]
1646+
self.speechModesList.Select(0)
16281647

16291648
def _appendDelayedCharacterDescriptions(self, settingsSizerHelper: guiHelper.BoxSizerHelper) -> None:
16301649
# Translators: This is the label for a checkbox in the voice settings panel.
@@ -1657,6 +1676,37 @@ def onSave(self):
16571676
config.conf["speech"][self.driver.name]["sayCapForCapitals"]=self.sayCapForCapsCheckBox.IsChecked()
16581677
config.conf["speech"][self.driver.name]["beepForCapitals"]=self.beepForCapsCheckBox.IsChecked()
16591678
config.conf["speech"][self.driver.name]["useSpellingFunctionality"]=self.useSpellingFunctionalityCheckBox.IsChecked()
1679+
config.conf["speech"]["excludedSpeechModes"] = [
1680+
mIndex for mIndex in range(len(self._allSpeechModes)) if mIndex not in self.speechModesList.CheckedItems
1681+
]
1682+
1683+
def isValid(self) -> bool:
1684+
enabledSpeechModes = self.speechModesList.CheckedItems
1685+
if len(enabledSpeechModes) < 2:
1686+
log.debugWarning("Too few speech modes enabled.")
1687+
gui.messageBox(
1688+
# translators: Message shown when not enough speech modes are enabled.
1689+
_("At least two speech modes have to be checked."),
1690+
# Translators: The title of the message box
1691+
_("Error"),
1692+
wx.OK | wx.ICON_ERROR,
1693+
self,
1694+
)
1695+
return False
1696+
if not any(
1697+
self._allSpeechModes[i].producesSpeech for i in enabledSpeechModes
1698+
):
1699+
log.debugWarning("None of the speech modes producing speech are enabled. This configuration is invalid.")
1700+
gui.messageBox(
1701+
# translators: Message shown when none of required speech modes are enabled.
1702+
_("One of speech mode talk or on-demand has to be enabled."),
1703+
# Translators: The title of the message box
1704+
_("Error"),
1705+
wx.OK | wx.ICON_ERROR,
1706+
self
1707+
)
1708+
return super().isValid()
1709+
16601710

16611711
class KeyboardSettingsPanel(SettingsPanel):
16621712
# Translators: This is the label for the keyboard settings panel.

source/speech/speech.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# This file is covered by the GNU General Public License.
33
# See the file COPYING for more details.
44
# Copyright (C) 2006-2023 NV Access Limited, Peter Vágner, Aleksey Sadovoy, Babbage B.V., Bill Dengler,
5-
# Julien Cochuyt, Derek Riemer, Cyrille Bougot, Leonard de Ruijter
5+
# Julien Cochuyt, Derek Riemer, Cyrille Bougot, Leonard de Ruijter, Łukasz Golonka
66

77
"""High-level functions to speak information.
88
"""
@@ -55,6 +55,7 @@
5555
Generator,
5656
Union,
5757
Tuple,
58+
Self,
5859
)
5960
from logHandler import log
6061
import config
@@ -65,10 +66,10 @@
6566
)
6667
import aria
6768
from .priorities import Spri
68-
from enum import IntEnum
6969
from dataclasses import dataclass
7070
from copy import copy
7171
from utils.security import objectBelowLockScreenAndWindowsIsLocked
72+
from utils.displayString import DisplayStringIntEnum
7273

7374
if typing.TYPE_CHECKING:
7475
import NVDAObjects
@@ -78,12 +79,31 @@
7879
_curWordChars: List[str] = []
7980

8081

81-
class SpeechMode(IntEnum):
82+
class SpeechMode(DisplayStringIntEnum):
8283
off = 0
8384
beeps = 1
8485
talk = 2
8586
onDemand = 3
8687

88+
@property
89+
def _displayStringLabels(self) -> dict[Self, str]:
90+
return {
91+
# Translators: Name of the speech mode which disables speech output.
92+
self.off: pgettext("speechModes", "off"),
93+
# Translators: Name of the speech mode which will cause NVDA to beep instead of speaking.
94+
self.beeps: pgettext("speechModes", "beeps"),
95+
# Translators: Name of the speech mode, which, when enabled, causes NVDA to talk as normal.
96+
self.talk: pgettext("speechModes", "talk"),
97+
# Translators: Name of the on-demand speech mode;
98+
# i.e. NVDA will talk only on commands asking to report something.
99+
self.onDemand: pgettext("speechModes", "on-demand"),
100+
}
101+
102+
@property
103+
def producesSpeech(self) -> bool:
104+
"""Is any speech produced when this mode is enabled?"""
105+
return self in (SpeechMode.talk, SpeechMode.onDemand)
106+
87107

88108
@dataclass
89109
class SpeechState:

source/utils/displayString.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
IntEnum,
1212
IntFlag,
1313
)
14-
from typing import Dict
14+
from typing import (
15+
Self,
16+
)
1517

1618
from logHandler import log
1719

@@ -44,11 +46,10 @@ class ExampleIntEnum(_DisplayStringEnumMixin, IntEnum, metaclass=_DisplayStringE
4446
```
4547
"""
4648
@abstractproperty
47-
def _displayStringLabels(self) -> Dict[Enum, str]:
49+
def _displayStringLabels(self) -> dict[Self, str]:
4850
"""
4951
Specify a dictionary which takes members of the Enum and returns the translated display string.
5052
"""
51-
pass
5253

5354
@property
5455
def displayString(self) -> str:

tests/unit/test_globalCommands.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# A part of NonVisual Desktop Access (NVDA)
2+
# Copyright (C) 2023 NV Access Limited, Łukasz Golonka
3+
# This file may be used under the terms of the GNU General Public License, version 2 or later.
4+
# For more details see: https://www.gnu.org/licenses/gpl-2.0.html
5+
6+
"""Set of unit tests veryfying behavior of scripts defined in ``globalCommands``."""
7+
8+
import unittest
9+
10+
import config
11+
import globalCommands
12+
import inputCore
13+
import speech
14+
15+
16+
class _FakeInputGesture(inputCore.InputGesture):
17+
18+
"""An input gesture which does nothing, but can be passed to scripts."""
19+
20+
def _get_identifiers(self):
21+
"""Implemented just to satisfy base class requirements, where this is defined as abstract."""
22+
raise RuntimeError("Should not be required in tests.")
23+
24+
25+
class SpeechModeSwitching(unittest.TestCase):
26+
27+
"""Verifies that switching between speech modes with `NVDA+S` works.
28+
29+
Ideally we will also ensure that name of the new speech mode is presented to the user,
30+
but we don't yet track calls to ``speech.speak``, so can't make any assertions on what has been spoken.
31+
"""
32+
33+
@staticmethod
34+
def _getCurrSpeechMode() -> speech.SpeechMode:
35+
"""A convenience helper which retrieves currently set speech mode."""
36+
return speech.getState().speechMode
37+
38+
@staticmethod
39+
def _setDisabledSpeechModes(modesToExclude: tuple[speech.SpeechMode, ...]) -> None:
40+
"""Disables given modes in the config."""
41+
disabledModes: list[int] = []
42+
for modeIndex, mode in enumerate(speech.SpeechMode):
43+
if mode in modesToExclude:
44+
disabledModes.append(modeIndex)
45+
if len(disabledModes) > len(speech.SpeechMode) - 2:
46+
raise RuntimeError("At least two modes have to be enabled.")
47+
config.conf["speech"]["excludedSpeechModes"] = disabledModes
48+
49+
def setUp(self) -> None:
50+
self._origSpeechMode = speech.getState().speechMode
51+
self._defaultDisabledSpeechModes = config.conf["speech"]["excludedSpeechModes"]
52+
# The new speech mode is shown in Braille, but displaying messages requires WX to be initialized.
53+
# Just disable showing messages for these tests.
54+
self._oldShowBraileMessagesVal = config.conf["braille"]["showMessages"]
55+
config.conf["braille"]["showMessages"] = 0 # Disabled
56+
57+
def tearDown(self) -> None:
58+
config.conf["speech"]["excludedSpeechModes"] = self._defaultDisabledSpeechModes
59+
speech.setSpeechMode(self._origSpeechMode)
60+
config.conf["braille"]["showMessages"] = self._oldShowBraileMessagesVal
61+
62+
@staticmethod
63+
def _executeSpeechModeCycleScript():
64+
globalCommands.commands.script_speechMode(_FakeInputGesture())
65+
66+
def test_cyclesThroughAllModesByDefault(self):
67+
"""By default keyboard command should switch between all available speech modes."""
68+
seenModes = set()
69+
for __ in range(len(speech.SpeechMode)):
70+
self._executeSpeechModeCycleScript()
71+
seenModes.add(self._getCurrSpeechMode())
72+
self.assertEqual(seenModes, set(speech.SpeechMode))
73+
# Just to make sure, verify that we returned to the mode set initially.
74+
self.assertEqual(self._getCurrSpeechMode(), self._origSpeechMode)
75+
76+
def test_nextModeIsUsed(self):
77+
"""Next speech mode is picked when the currently selected is not the last one."""
78+
# Verify that expected mode is set initially.
79+
self.assertEqual(self._getCurrSpeechMode(), speech.SpeechMode.talk)
80+
self._executeSpeechModeCycleScript()
81+
self.assertEqual(self._getCurrSpeechMode(), speech.SpeechMode.onDemand)
82+
83+
def test_cyclingWrapsNoMoreModes(self):
84+
"""When the selected speech mode is last on the list, the next press should switch to a first one."""
85+
speech.setSpeechMode(speech.SpeechMode.onDemand)
86+
self._executeSpeechModeCycleScript()
87+
self.assertEqual(self._getCurrSpeechMode(), speech.SpeechMode.off)
88+
89+
def test_nextModePickedWhenEnabled(self):
90+
"""When the next mode in the list is enabled, pressing the command once should switch to it."""
91+
self._setDisabledSpeechModes((speech.SpeechMode.off, speech.SpeechMode.beeps))
92+
self._executeSpeechModeCycleScript()
93+
self.assertEqual(self._getCurrSpeechMode(), speech.SpeechMode.onDemand)
94+
95+
def test_nextModeDisabledMoreModesInTheList(self):
96+
"""Next mode is disabled, yet there are more modes in the list, so no need to wrap to the first one."""
97+
self._setDisabledSpeechModes((speech.SpeechMode.beeps,))
98+
speech.setSpeechMode(speech.SpeechMode.off)
99+
self._executeSpeechModeCycleScript()
100+
self.assertEqual(self._getCurrSpeechMode(), speech.SpeechMode.talk)
101+
102+
def test_nextModeDisabledNoMoreModes(self):
103+
"""When the next mode is disabled, switching wraps to the first mode."""
104+
self._setDisabledSpeechModes((speech.SpeechMode.onDemand,))
105+
self._executeSpeechModeCycleScript()
106+
self.assertEqual(self._getCurrSpeechMode(), speech.SpeechMode.off)
107+
108+
def test_nextModesDisabledFirstEnabledNotAtTheBeginning(self):
109+
"""Script has to wrap, mode at the beginning of the list is disabled, first enabled one is picked."""
110+
self._setDisabledSpeechModes((speech.SpeechMode.off, speech.SpeechMode.onDemand))
111+
self._executeSpeechModeCycleScript()
112+
self.assertEqual(self._getCurrSpeechMode(), speech.SpeechMode.beeps)
113+
114+
def test_onlyEnabledModesAvailableForSwitching(self):
115+
"""Execute script multiple times and make sure we never switched to one of the disabled modes."""
116+
self._setDisabledSpeechModes((speech.SpeechMode.off, speech.SpeechMode.onDemand))
117+
seenModes = set()
118+
for __ in range(30): # Chosen arbitrarily, so that script wraps multipe times.
119+
self._executeSpeechModeCycleScript()
120+
seenModes.add(self._getCurrSpeechMode())
121+
self.assertEqual(seenModes, {speech.SpeechMode.talk, speech.SpeechMode.beeps})

user_docs/en/changes.t2t

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@ Windows 8.1 is the minimum Windows version supported.
3131
- Added support for Bluetooth Low Energy HID Braille displays. (#15470)
3232
- A new "on-demand" speech mode has been added.
3333
When speech is on-demand, NVDA does not speak automatically (e.g. when moving the cursor) but still speaks when calling commands whose goal is explicitly to report something (e.g. report window title). (#481, @CyrilleB79)
34+
- it is now possible to exclude unwanted speech modes when cycling between them using ``NVDA+s`` from the Speech category in NVDA's settings. (#15806, @lukaszgo1)
35+
- If you are currently using the NoBeepsSpeechMode add-on consider uninstalling it ,and disabling "beeps" and "on-demand" modes in the settings.
36+
-
3437
-
3538

36-
3739
== Changes ==
3840
- NVDA no longer supports Windows 7 and Windows 8.
3941
Windows 8.1 is the minimum Windows version supported. (#15544)

0 commit comments

Comments
 (0)