diff --git a/source/braille.py b/source/braille.py index c1c0abd4d8c..6e1d36be208 100644 --- a/source/braille.py +++ b/source/braille.py @@ -1712,10 +1712,11 @@ def nextLine(self): try: dest.obj.turnPage() except RuntimeError: - pass + handler.autoScroll(enable=False) else: dest = dest.obj.makeTextInfo(textInfos.POSITION_FIRST) else: # no page turn support + handler.autoScroll(enable=False) shouldCollapseToEnd = True dest.collapse(shouldCollapseToEnd) self._setCursor(dest) @@ -2483,6 +2484,7 @@ def __init__(self): self._cursorBlinkUp = True self._cells = [] self._cursorBlinkTimer = None + self._autoScrollCallLater: wx.CallLater | None = None config.post_configProfileSwitch.register(self.handlePostConfigProfileSwitch) if config.conf["braille"]["tetherTo"] == TetherTo.AUTO.value: self._tether = TetherTo.FOCUS.value @@ -2518,6 +2520,7 @@ def terminate(self): if self._cursorBlinkTimer: self._cursorBlinkTimer.Stop() self._cursorBlinkTimer = None + self.autoScroll(enable=False) config.post_configProfileSwitch.unregister(self.handlePostConfigProfileSwitch) post_secureDesktopStateChange.unregister(self._onSecureDesktopStateChanged) post_sessionLockStateChanged.unregister(self._onSessionLockStateChanged) @@ -2533,12 +2536,14 @@ def terminate(self): def _clearAll(self) -> None: """Clear the braille buffers and update the braille display.""" + self.autoScroll(enable=False) self.mainBuffer.clear() if self.buffer is self.messageBuffer: self._dismissMessage(False) self.update() def _onSecureDesktopStateChanged(self, isSecureDesktop: bool): + self.autoScroll(enable=False) self.mainBuffer.clear() if not easeOfAccess.isRegistered(): if isSecureDesktop: @@ -2992,13 +2997,20 @@ def scrollForward(self): self.buffer.scrollForward() if self.buffer is self.messageBuffer: self._resetMessageTimer() + if self._autoScrollCallLater: + # Reset the timer. + self._resetAutoScroll() def scrollBack(self): self.buffer.scrollBack() if self.buffer is self.messageBuffer: self._resetMessageTimer() + if self._autoScrollCallLater: + # Reset the timer. + self._resetAutoScroll() def routeTo(self, windowPos): + self.autoScroll(enable=False) self.buffer.routeTo(windowPos) if self.buffer is self.messageBuffer: self._dismissMessage() @@ -3023,6 +3035,7 @@ def message(self, text): ): return _pre_showBrailleMessage.notify() + self.autoScroll(enable=False) if self.buffer is self.messageBuffer: self.buffer.clear() else: @@ -3063,6 +3076,38 @@ def _dismissMessage(self, shouldUpdate: bool = True): self.update() _post_dismissBrailleMessage.notify() + def autoScroll(self, enable: bool) -> None: + """ + Enable or disable automatic scroll. + + :param enable: ``True`` if automatic scroll should be enabled, ``False`` otherwise. + """ + + if not self.enabled: + return + if enable and self._autoScrollCallLater is None: + self._autoScrollCallLater = wx.CallLater(self._calculateAutoScrollTimeout(), self.scrollForward) + elif not enable and self._autoScrollCallLater is not None: + self._autoScrollCallLater.Stop() + self._autoScrollCallLater = None + + def _calculateAutoScrollTimeout(self) -> int: + """ + Calculate the timeout for automatic scroll. + + :return: The number of milliseconds to wait until the next scroll. + """ + + autoScrollRate = config.conf["braille"]["autoScrollRate"] + return int((self.displaySize / autoScrollRate) * 1000) + + def _resetAutoScroll(self) -> None: + """ + Reset autoScroll. + """ + + self._autoScrollCallLater.Restart() + def handleGainFocus(self, obj: "NVDAObject", shouldAutoTether: bool = True) -> None: if not self.enabled or config.conf["braille"]["mode"] == BrailleMode.SPEECH_OUTPUT.value: return @@ -3086,6 +3131,7 @@ def handleGainFocus(self, obj: "NVDAObject", shouldAutoTether: bool = True) -> N ) def _doNewObject(self, regions): + self.autoScroll(enable=False) self.mainBuffer.clear() focusToHardLeftSet = False for region in regions: diff --git a/source/config/__init__.py b/source/config/__init__.py index 16dcbbf98d6..c9b3f600708 100644 --- a/source/config/__init__.py +++ b/source/config/__init__.py @@ -33,6 +33,7 @@ import easeOfAccess from fileUtils import FaultTolerantFile import extensionPoints +import functools from . import profileUpgrader from . import aggregatedSection @@ -1046,6 +1047,87 @@ def getConfigValidation(self, keyPath): data.default = conf.validator.get_default_value(spec) return data + def getConfigValue(self, *keyPath: *tuple[str, str, *tuple[str, ...]]) -> any: + """ + Retrieves the value of a configuration key. + :param keyPath: The path to the configuration key to retrieve. + :return: The value of the specified configuration key. + """ + return functools.reduce(lambda d, x: d.get(x), keyPath, self) + + def setConfigValue( + self, + value: bool | int | float | str, + *keyPath: *tuple[str, str, *tuple[str, ...]], + ) -> None: + """ + Sets the value of a configuration key. + :param value: The value to set for the configuration key. + :param keyPath: The path to the configuration key to set. + :return: None. + """ + dictToUpdate = functools.reduce(lambda d, x: d.get(x), keyPath[:-1], self) + dictToUpdate[keyPath[-1]] = value + + def _getConfigValueRange(self, *keyPath: tuple[str, str, *tuple[str, ...]]) -> tuple[float, float]: + """ + Gets the minimum and maximum allowed values for a configuration key. + :param keyPath: The path to The configuration key to evaluate. + :return: A tuple of (minValue, maxValue). + """ + validation = self.getConfigValidation(keyPath) + minValue = float(validation.kwargs["min"]) + maxValue = float(validation.kwargs["max"]) + return minValue, maxValue + + def _clampValue(self, currentValue: float, minValue: float, maxValue: float, step: float) -> float: + """ + Calculates a new value by applying a step, constrained within min/max bounds. + :param currentValue: The current value. + :param minValue: The minimum allowed value. + :param maxValue: The maximum allowed value. + :param step: The amount to change the value by (positive or negative). + :return: The new value, clamped between min and max. + """ + return min(max(currentValue + step, minValue), maxValue) + + def valueToPercentage(self, *keyPath: tuple[str, str, *tuple[str, ...]]) -> int: + """ + Calculates the percentage representation of a configuration value within its defined range. + :param keyPath: The path to the configuration key to evaluate. + :return: The percentage (0-100) of the value within the range. + """ + minValue, maxValue = self._getConfigValueRange(*keyPath) + currentValue = self.getConfigValue(*keyPath) + return round((currentValue - minValue) / (maxValue - minValue) * 100) + + def percentageToValue(self, *keyPath: tuple[str, str, *tuple[str, ...]], percentage: int) -> float: + """ + Calculates the configuration value corresponding to a given percentage within its defined range. + :param keyPath: The path to the configuration key to evaluate. + :param percentage: The percentage (0-100) to convert to a value. + :return: The value corresponding to the given percentage within the defined range. + """ + minValue, maxValue = self._getConfigValueRange(*keyPath) + percentage = max(0, min(100, percentage)) + value = minValue + (maxValue - minValue) * (percentage / 100) + return value + + def clampedIncrementAndUpdateConfig( + self, + *keyPath: tuple[str, str, *tuple[str, ...]], + step: float, + ) -> None: + """ + Updates a configuration value by applying a step, constrained within its valid range. + :param keyPath: The path to the configuration key to update. + :param step: The step adjustment value (positive, negative, or 0). + """ + currentValue = self.getConfigValue(*keyPath) + minValue, maxValue = self._getConfigValueRange(*keyPath) + newValue = self._clampValue(currentValue, minValue, maxValue, step) + self.setConfigValue(newValue, *keyPath) + class ConfigValidationData(object): validationFuncName: str | None = None diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 1cc61e0c6a1..e570b24e0b7 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -84,6 +84,8 @@ showMessages = integer(0, 2, default=1) # Timeout after the message will disappear from braille display messageTimeout = integer(default=4, min=1, max=20) + # Rate for automatic scroll (cells/sec) + autoScrollRate = float(default=10, min=1, max=20) tetherTo = option("auto", "focus", "review", default="auto") reviewRoutingMovesSystemCaret = featureFlag(\ optionsEnum="ReviewRoutingMovesSystemCaretFlag", behaviorOfDefault="NEVER") diff --git a/source/globalCommands.py b/source/globalCommands.py index de4a04cfe6d..048ec2d3512 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -841,6 +841,51 @@ def script_toggleReportSpellingErrorsInBraille(self, gesture: inputCore.InputGes # Translators: Message presented when turning off reporting spelling errors or grammar in braille. ui.message(_("Report errors in braille off")) + @script( + # Translators: Input help mode message for command to toggle braille automatic scroll. + description=_("Toggles braille automatic scroll"), + category=SCRCAT_BRAILLE, + ) + def script_toggleBrailleAutoScroll(self, gesture: inputCore.InputGesture): + shouldEnableAutoScroll = braille.handler._autoScrollCallLater is None + timeout = 0 + if shouldEnableAutoScroll: + # Translators: Message reported when automatic scrolling has been enabled in braille. + ui.message(_("Automatic scrolling enabled")) + if not ( + config.conf["braille"]["showMessages"] == ShowMessages.DISABLED + or config.conf["braille"]["mode"] == BrailleMode.SPEECH_OUTPUT.value + ): + timeout = config.conf["braille"]["messageTimeout"] * 1000 + else: + # Translators: Message reported when automatic scrolling has been disabled in braille. + ui.message(_("Automatic scrolling disabled")) + core.callLater(timeout, braille.handler.autoScroll, shouldEnableAutoScroll) + + @script( + # Translators: Input help mode message for command to increase the rate for braille automatic scroll. + description=_("Increases the rate for braille automatic scroll"), + category=SCRCAT_BRAILLE, + ) + def script_increaseBrailleAutoScrollRate(self, gesture: inputCore.InputGesture): + config.conf.clampedIncrementAndUpdateConfig("braille", "autoScrollRate", step=0.5) + percentage = config.conf.valueToPercentage("braille", "autoScrollRate") + # Translators: Message shown when increasing the braille auto scroll rate. + # {rate} will be replaced with the rate as a whole number from 0 to 100. + ui.message(_("Scroll rate {rate}").format(rate=percentage)) + + @script( + # Translators: Input help mode message for command to decrease the rate for braille automatic scroll. + description=_("Decreases the rate for braille automatic scroll"), + category=SCRCAT_BRAILLE, + ) + def script_decreaseBrailleAutoScrollRate(self, gesture: inputCore.InputGesture): + config.conf.clampedIncrementAndUpdateConfig("braille", "autoScrollRate", step=-0.5) + percentage = config.conf.valueToPercentage("braille", "autoScrollRate") + # Translators: Message shown when decreasing the braille auto scroll rate. + # {rate} will be replaced with the rate as a whole number from 0 to 100. + ui.message(_("Scroll rate {rate}").format(rate=percentage)) + @script( # Translators: Input help mode message for toggle report pages command. description=_("Toggles on and off the reporting of pages"), diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index a78b64fa446..0f85ce274ff 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -5502,6 +5502,24 @@ def makeSettings(self, settingsSizer): ) self.bindHelpEvent("BrailleSettingsInterruptSpeech", self.brailleInterruptSpeechCombo) + # Translators: The label for a setting in braille settings to change the rate for autoscroll. + autoScrollRateText = _("Auto&matic scroll rate") + self.autoScrollRateSlider: nvdaControls.EnhancedInputSlider = sHelper.addLabeledControl( + autoScrollRateText, + nvdaControls.EnhancedInputSlider, + minValue=0, + maxValue=100, + ) + + self.autoScrollRateSlider.SetValue( + config.conf.valueToPercentage( + "braille", + "autoScrollRate", + ), + ) + self.autoScrollRateSlider.SetPageSize(10) + self.bindHelpEvent("BrailleAutoScrollRate", self.autoScrollRateSlider) + if gui._isDebug(): log.debug("Finished making settings, now at %.2f seconds from start" % (time.time() - startTime)) @@ -5532,6 +5550,12 @@ def onSave(self): ] config.conf["braille"]["showMessages"] = self.showMessagesList.GetSelection() config.conf["braille"]["messageTimeout"] = self.messageTimeoutEdit.GetValue() + + config.conf["braille"]["autoScrollRate"] = config.conf.percentageToValue( + "braille", + "autoScrollRate", + percentage=self.autoScrollRateSlider.GetValue(), + ) tetherChoice = [x.value for x in TetherTo][self.tetherList.GetSelection()] if tetherChoice == TetherTo.AUTO.value: config.conf["braille"]["tetherTo"] = TetherTo.AUTO.value diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index cefe0a8ddc9..c83179dc7dc 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -6,6 +6,7 @@ ### New Features +* Added the ability to automatically scroll the braille display. (#18573, @nvdaes) * After installing or updating NVDA, a dialog now offers options to restart Windows, start the installed copy, or exit the installer. (#19268, #19718, @kefaslungu) * NVDA now includes a built-in Magnifier feature that allows you to zoom and magnify parts of the screen. (#19228, @Boumtchack) * The magnifier supports various zoom levels, color filters (normal, grayscale, inverted), and different focus tracking modes. @@ -79,6 +80,14 @@ The `scons checkPot` target has also been replaced with `runcheckpot.bat`. Use the individual test commands instead: `runcheckpot.bat`, `rununittests.bat`, `runsystemtests.bat`, `runlint.bat`. (#19606, #19676, @bramd) * Updated Python 3.13.11 to 3.13.12 (#19572, @dpy013) * Added a private `_asyncioEventLoop` module that provides an asyncio event loop running on a background thread for use by NVDA components. (#19816, @bramd) +* Added several functions related to the braille auto-scroll feature. (#18573, @nvdaes): + * Added an `autoScroll` method to `braille.handler`. + * Added several functions for handling configuration value conversions and updates in `config.conf`: + * Added a `getConfigValue` function to get the value for a provided configuration key path. + * Added a `setConfigValue` function to set a value for a provided configuration key path. + * Added a `valueToPercentage` function to calculate the percentage representation of a configuration value within its range. + * Added a `percentageToValue` function to convert a percentage to the corresponding configuration value. + * Added a `clampedIncrementAndUpdateConfig` function to update a configuration value by applying a step, constrained within its valid range. #### Deprecations diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index 9261ec36a76..e67d182c95e 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -2562,6 +2562,19 @@ Enabling this option will cause NVDA to speak lines or paragraphs reached using To toggle this option from anywhere, please assign a custom gesture to "speakOnNavigatingByUnit" in the "Braille" section of the [Input Gestures dialog](#InputGestures). +##### Automatic Scroll Rate {#BrailleAutoScrollRate} + +This slider controls the rate of automatic braille display scrolling, measured in cells per second. +The minimum value (0%) will be 1 cell per second, and the maximum value (100%), 20 cells per second. +For example, with the default value of 10 cells/sec, if a braille display with 40 cells is used, the number of seconds between automatic scrolls will be 4. +If the display had 20 cells, each line of braille would be shown for 2 seconds. + +While the automatic scroll option is enabled, you can still use the scroll back command to read previous contents again, and scroll forward, for example, to skip a blank line, or if the line being read is too short. + +Automatic scrolling will be disabled if a routing key is pressed, if a message is presented in braille, if a new object is displayed, when entering a secure screen, when the session is locked, or when the end of the window is reached. + +Commands can be assigned to toggle the automatic scroll option, and to increase or decrease the scroll rate, from the "Braille" section of the [Input Gestures dialog](#InputGestures). + ##### Avoid splitting words when possible {#BrailleSettingsWordWrap} If this is enabled, a word which is too large to fit at the end of the braille display will not be split.