Repository: nvaccess/nvda

Issue:
Python Console: Jump to previous/next result
<!--
Please thoroughly read NVDA's wiki article on how to fill in this template, including how to provide the required files.
Issues may be closed if the required information is not present.
https://github.com/nvaccess/nvda/wiki/Github-issue-template-explanation-and-examples
-->

### Is your feature request related to a problem? Please describe.

In the output pane of the Python Console, it can be tedious to inspect a series of lengthy output results.

### Describe the solution you'd like

Provide key bindings to jump to the previous/next output result.

### Describe alternatives you've considered

### Additional context

Upcoming PR


Discussion:

Resolving patch:
diff --git a/source/appModules/nvda.py b/source/appModules/nvda.py
--- a/source/appModules/nvda.py
+++ b/source/appModules/nvda.py
@@ -1,20 +1,30 @@
-#appModules/nvda.py
-#A part of NonVisual Desktop Access (NVDA)
-#Copyright (C) 2008-2017 NV Access Limited
-#This file is covered by the GNU General Public License.
-#See the file COPYING for more details.
+# A part of NonVisual Desktop Access (NVDA)
+# Copyright (C) 2008-2021 NV Access Limited, James Teh, Michael Curran, Leonard de Ruijter, Reef Turner,
+# Julien Cochuyt
+# This file may be used under the terms of the GNU General Public License, version 2 or later.
+# For more details see: https://www.gnu.org/licenses/gpl-2.0.html
+
+
+import typing
 
 import appModuleHandler
 import api
 import controlTypes
 import versionInfo
 from NVDAObjects.IAccessible import IAccessible
+from baseObject import ScriptableObject
 import gui
+from scriptHandler import script
 import speech
+import textInfos
 import braille
 import config
 from logHandler import log
 
+if typing.TYPE_CHECKING:
+	import inputCore
+
+
 nvdaMenuIaIdentity = None
 
 class NvdaDialog(IAccessible):
@@ -44,6 +54,118 @@ def _get_description(self):
 		"""
 		return ""
 
+
+# Translators: The name of a category of NVDA commands.
+SCRCAT_PYTHON_CONSOLE = _("Python Console")
+
+
+class NvdaPythonConsoleUIOutputClear(ScriptableObject):
+
+	# Allow the bound gestures to be edited through the Input Gestures dialog (see L{gui.prePopup})
+	isPrevFocusOnNvdaPopup = True
+
+	@script(
+		gesture="kb:control+l",
+		# Translators: Description of a command to clear the Python Console output pane
+		description=_("Clear the output pane"),
+		category=SCRCAT_PYTHON_CONSOLE,
+	)
+	def script_clearOutput(self, gesture: "inputCore.InputGesture"):
+		from pythonConsole import consoleUI
+		consoleUI.clear()
+
+
+class NvdaPythonConsoleUIOutputCtrl(ScriptableObject):
+
+	# Allow the bound gestures to be edited through the Input Gestures dialog (see L{gui.prePopup})
+	isPrevFocusOnNvdaPopup = True
+
+	@script(
+		gesture="kb:alt+downArrow",
+		# Translators: Description of a command to move to the next result in the Python Console output pane
+		description=_("Move to the next result"),
+		category=SCRCAT_PYTHON_CONSOLE
+	)
+	def script_moveToNextResult(self, gesture: "inputCore.InputGesture"):
+		self._resultNavHelper(direction="next", select=False)
+
+	@script(
+		gesture="kb:alt+upArrow",
+		# Translators: Description of a command to move to the previous result
+		# in the Python Console output pane
+		description=_("Move to the previous result"),
+		category=SCRCAT_PYTHON_CONSOLE
+	)
+	def script_moveToPrevResult(self, gesture: "inputCore.InputGesture"):
+		self._resultNavHelper(direction="previous", select=False)
+
+	@script(
+		gesture="kb:alt+downArrow+shift",
+		# Translators: Description of a command to select from the current caret position to the end
+		# of the current result in the Python Console output pane
+		description=_("Select until the end of the current result"),
+		category=SCRCAT_PYTHON_CONSOLE
+	)
+	def script_selectToResultEnd(self, gesture: "inputCore.InputGesture"):
+		self._resultNavHelper(direction="next", select=True)
+
+	@script(
+		gesture="kb:alt+shift+upArrow",
+		# Translators: Description of a command to select from the current caret position to the start
+		# of the current result in the Python Console output pane
+		description=_("Select until the start of the current result"),
+		category=SCRCAT_PYTHON_CONSOLE
+	)
+	def script_selectToResultStart(self, gesture: "inputCore.InputGesture"):
+		self._resultNavHelper(direction="previous", select=True)
+
+	def _resultNavHelper(self, direction: str = "next", select: bool = False):
+		from pythonConsole import consoleUI
+		startPos, endPos = consoleUI.outputCtrl.GetSelection()
+		if self.isTextSelectionAnchoredAtStart:
+			curPos = endPos
+		else:
+			curPos = startPos
+		if direction == "previous":
+			for pos in reversed(consoleUI.outputPositions):
+				if pos < curPos:
+					break
+			else:
+				# Translators: Reported when attempting to move to the previous result in the Python Console
+				# output pane while there is no previous result.
+				speech.speakMessage(_("Top"))
+				return
+		elif direction == "next":
+			for pos in consoleUI.outputPositions:
+				if pos > curPos:
+					break
+			else:
+				# Translators: Reported when attempting to move to the next result in the Python Console
+				# output pane while there is no next result.
+				speech.speakMessage(_("Bottom"))
+				return
+		else:
+			raise ValueError(u"Unexpected direction: {!r}".format(direction))
+		if select:
+			consoleUI.outputCtrl.Freeze()
+			anchorPos = startPos if self.isTextSelectionAnchoredAtStart else endPos
+			consoleUI.outputCtrl.SetSelection(anchorPos, pos)
+			consoleUI.outputCtrl.Thaw()
+			self.detectPossibleSelectionChange()
+			self.isTextSelectionAnchoredAtStart = anchorPos < pos
+		else:
+			consoleUI.outputCtrl.SetSelection(pos, pos)
+		info = self.makeTextInfo(textInfos.POSITION_CARET)
+		copy = info.copy()
+		info.expand(textInfos.UNIT_LINE)
+		if (
+			copy.move(textInfos.UNIT_CHARACTER, 4, endPoint="end") == 4
+			and copy.text == ">>> "
+		):
+			info.move(textInfos.UNIT_CHARACTER, 4, endPoint="start")
+		speech.speakTextInfo(info, reason=controlTypes.OutputReason.CARET)
+
+
 class AppModule(appModuleHandler.AppModule):
 	# The configuration profile that has been previously edited.
 	# This ought to be a class property.
@@ -108,8 +230,26 @@ def isNvdaSettingsDialog(self, obj):
 			return True
 		return False
 
+	def isNvdaPythonConsoleUIInputCtrl(self, obj):
+		from pythonConsole import consoleUI
+		if not consoleUI:
+			return
+		return obj.windowHandle == consoleUI.inputCtrl.GetHandle()
+
+	def isNvdaPythonConsoleUIOutputCtrl(self, obj):
+		from pythonConsole import consoleUI
+		if not consoleUI:
+			return
+		return obj.windowHandle == consoleUI.outputCtrl.GetHandle()
+
 	def chooseNVDAObjectOverlayClasses(self, obj, clsList):
 		if obj.windowClassName == "#32770" and obj.role == controlTypes.ROLE_DIALOG:
 			clsList.insert(0, NvdaDialog)
 			if self.isNvdaSettingsDialog(obj):
 				clsList.insert(0, NvdaDialogEmptyDescription)
+				return
+		if self.isNvdaPythonConsoleUIInputCtrl(obj):
+			clsList.insert(0, NvdaPythonConsoleUIOutputClear)
+		elif self.isNvdaPythonConsoleUIOutputCtrl(obj):
+			clsList.insert(0, NvdaPythonConsoleUIOutputClear)
+			clsList.insert(0, NvdaPythonConsoleUIOutputCtrl)
diff --git a/source/gui/__init__.py b/source/gui/__init__.py
--- a/source/gui/__init__.py
+++ b/source/gui/__init__.py
@@ -85,7 +85,12 @@ def prePopup(self):
 		"""
 		nvdaPid = os.getpid()
 		focus = api.getFocusObject()
-		if focus.processID != nvdaPid:
+		# Do not set prevFocus if the focus is on a control rendered by NVDA itself, such as the NVDA menu.
+		# This allows to refer to the control that had focus before opening the menu while still using NVDA
+		# on its own controls. The L{nvdaPid} check can be bypassed by setting the optional attribute
+		# L{isPrevFocusOnNvdaPopup} to L{True} when a NVDA dialog offers customizable bound gestures,
+		# eg. the NVDA Python Console.
+		if focus.processID != nvdaPid or getattr(focus, "isPrevFocusOnNvdaPopup", False):
 			self.prevFocus = focus
 			self.prevFocusAncestors = api.getFocusAncestors()
 		if winUser.getWindowThreadProcessID(winUser.getForegroundWindow())[0] != nvdaPid:
diff --git a/source/pythonConsole.py b/source/pythonConsole.py
--- a/source/pythonConsole.py
+++ b/source/pythonConsole.py
@@ -1,8 +1,8 @@
-#pythonConsole.py
-#A part of NonVisual Desktop Access (NVDA)
-#This file is covered by the GNU General Public License.
-#See the file COPYING for more details.
-#Copyright (C) 2008-2019 NV Access Limited, Leonard de Ruijter
+# pythonConsole.py
+# A part of NonVisual Desktop Access (NVDA)
+# This file is covered by the GNU General Public License.
+# See the file COPYING for more details.
+# Copyright (C) 2008-2020 NV Access Limited, Leonard de Ruijter, Julien Cochuyt
 
 import watchdog
 
@@ -12,6 +12,7 @@
 
 import builtins
 import os
+from typing import Sequence
 import code
 import codeop
 import sys
@@ -253,6 +254,7 @@ def __init__(self, parent):
 		# Even the most recent line has a position in the history, so initialise with one blank line.
 		self.inputHistory = [""]
 		self.inputHistoryPos = 0
+		self.outputPositions: Sequence[int] = [0]
 
 	def onActivate(self, evt):
 		if evt.GetActive():
@@ -268,6 +270,12 @@ def output(self, data):
 		if data and not data.isspace():
 			queueHandler.queueFunction(queueHandler.eventQueue, speech.speakText, data)
 
+	def clear(self):
+		"""Clear the output.
+		"""
+		self.outputCtrl.Clear()
+		self.outputPositions[:] = [0]
+
 	def echo(self, data):
 		self.outputCtrl.write(data)
 
@@ -292,6 +300,8 @@ def execute(self):
 			self.inputHistory.append("")
 		self.inputHistoryPos = len(self.inputHistory) - 1
 		self.inputCtrl.ChangeValue("")
+		if self.console.prompt != "...":
+			self.outputPositions.append(self.outputCtrl.GetInsertionPoint())
 
 	def historyMove(self, movement):
 		newIndex = self.inputHistoryPos + movement


Test patch: