From bbd0f03c9586e22abfe6e41afcc8d0a1649a1dc3 Mon Sep 17 00:00:00 2001 From: Nguyen Anh Duc Date: Tue, 3 Mar 2026 13:30:12 +0700 Subject: [PATCH] Add snippet navigation and extra slot mappings Introduce per-slot cursor tracking and interactive navigation for saved snippets: next/previous character, word, and line commands with localized names for special characters. Add helper functions for word/line parsing and a small _get_char_name utility; import re for parsing. Track current selected memory slot and initialize cursors when a slot is opened. Expand gesture mappings to include navigation shortcuts and many additional memory slots (minus/equals, numpad keys, backspace/numpadPlus/numpadMinus), and wire gestures for next/prev char/word/line. Also set current_slot_key to None when an empty slot is accessed. --- .../globalPlugins/snippetsForNVDA/__init__.py | 186 +++++++++++++++++- 1 file changed, 184 insertions(+), 2 deletions(-) diff --git a/addon/globalPlugins/snippetsForNVDA/__init__.py b/addon/globalPlugins/snippetsForNVDA/__init__.py index 42f5968..6a1a124 100644 --- a/addon/globalPlugins/snippetsForNVDA/__init__.py +++ b/addon/globalPlugins/snippetsForNVDA/__init__.py @@ -8,6 +8,7 @@ import config import json import os +import re # Fix for the Russian keyboard layout -- We can't use # keyboardInputGesture.fromName() because it triggers a LookupError @@ -36,6 +37,10 @@ class GlobalPlugin(globalPluginHandler.GlobalPlugin): memory = {} lastPressedKey = 0 + + # Virtual View states + current_slot_key = None + cursors = {} # { keyCode: int } def script_saveToMemory(self, gesture): focus = api.getFocusObject() @@ -62,6 +67,10 @@ def script_speakAndCopyMemory(self, gesture): keyCode = str(gesture.vkCode) try: data = self.memory[keyCode] + self.current_slot_key = keyCode + if keyCode not in self.cursors: + self.cursors[keyCode] = 0 + if getLastScriptRepeatCount() == 0: ui.message(data) self.lastPressedKey = keyCode @@ -79,6 +88,7 @@ def script_speakAndCopyMemory(self, gesture): # Translators: The message when the user checks a memory slot but there is no data in it ui.message(_("No data at this position")) self.lastPressedKey = 0 + self.current_slot_key = None # Translators: the documentation of the speak and copy memory slot command, displayed on the input help mode. script_speakAndCopyMemory.__doc__ = _("""Pressing this key combination once , the content of this memory slot will be spoken. @@ -98,15 +108,187 @@ def script_loadSnippets(self, gesture): script_loadSnippets.__doc__ = _("""Loads previously saved snippets""") + def _get_char_name(self, char): + if char == " ": + return _("space") + elif char == "\n": + return _("new line") + elif char == "\t": + return _("tab") + return char + + def script_nextChar(self, gesture): + if not self.current_slot_key: + ui.message(_("No memory slot selected")) + return + + data = self.memory.get(self.current_slot_key, "") + if not data: + return + + cursor = self.cursors.get(self.current_slot_key, 0) + if cursor < len(data) - 1: + cursor += 1 + self.cursors[self.current_slot_key] = cursor + char = data[cursor] + ui.message(self._get_char_name(char)) + else: + ui.message(_("Bottom")) + + script_nextChar.__doc__ = _("""Moves to the next character in the current memory slot.""") + + def script_prevChar(self, gesture): + if not self.current_slot_key: + ui.message(_("No memory slot selected")) + return + + data = self.memory.get(self.current_slot_key, "") + if not data: + return + + cursor = self.cursors.get(self.current_slot_key, 0) + if cursor > 0: + cursor -= 1 + self.cursors[self.current_slot_key] = cursor + char = data[cursor] + ui.message(self._get_char_name(char)) + else: + ui.message(_("Top")) + + script_prevChar.__doc__ = _("""Moves to the previous character in the current memory slot.""") + + def _get_words(self, text): + return [(m.start(0), m.end(0), m.group(0)) for m in re.finditer(r'\w+|[^\w\s]+', text)] + + def script_nextWord(self, gesture): + if not self.current_slot_key: + ui.message(_("No memory slot selected")) + return + + data = self.memory.get(self.current_slot_key, "") + if not data: + return + + cursor = self.cursors.get(self.current_slot_key, 0) + words = self._get_words(data) + + for start, end, word in words: + if start > cursor: + self.cursors[self.current_slot_key] = start + ui.message(word) + return + + ui.message(_("Bottom")) + + script_nextWord.__doc__ = _("""Moves to the next word in the current memory slot.""") + + def script_prevWord(self, gesture): + if not self.current_slot_key: + ui.message(_("No memory slot selected")) + return + + data = self.memory.get(self.current_slot_key, "") + if not data: + return + + cursor = self.cursors.get(self.current_slot_key, 0) + words = self._get_words(data) + + for i in range(len(words) - 1, -1, -1): + start, end, word = words[i] + if end <= cursor or start < cursor: + self.cursors[self.current_slot_key] = start + ui.message(word) + return + + ui.message(_("Top")) + + script_prevWord.__doc__ = _("""Moves to the previous word in the current memory slot.""") + + def _get_lines(self, text): + lines = [] + start = 0 + for line in text.splitlines(False): # keep line string, do not keep \n + end = start + len(line) + lines.append((start, end, line)) + start = end + 1 # +1 for \n + return lines + + def script_nextLine(self, gesture): + if not self.current_slot_key: + ui.message(_("No memory slot selected")) + return + + data = self.memory.get(self.current_slot_key, "") + if not data: + return + + cursor = self.cursors.get(self.current_slot_key, 0) + lines = self._get_lines(data) + + for start, end, line in lines: + if start > cursor: + self.cursors[self.current_slot_key] = start + ui.message(line if line.strip() else _("blank")) + return + + ui.message(_("Bottom")) + + script_nextLine.__doc__ = _("""Moves to the next line in the current memory slot.""") + + def script_prevLine(self, gesture): + if not self.current_slot_key: + ui.message(_("No memory slot selected")) + return + + data = self.memory.get(self.current_slot_key, "") + if not data: + return + + cursor = self.cursors.get(self.current_slot_key, 0) + lines = self._get_lines(data) + + for i in range(len(lines) - 1, -1, -1): + start, end, line = lines[i] + if start < cursor: + self.cursors[self.current_slot_key] = start + ui.message(line if line.strip() else _("blank")) + return + + ui.message(_("Top")) + + script_prevLine.__doc__ = _("""Moves to the previous line in the current memory slot.""") + def isLastPressedKey(self, keyCode): return self.lastPressedKey == keyCode __gestures = {} __gestures["kb:NVDA+ALT+S"] = "saveSnippets" __gestures["kb:NVDA+ALT+L"] = "loadSnippets" + __gestures["kb:ALT+SHIFT+rightArrow"] = "nextChar" + __gestures["kb:ALT+SHIFT+leftArrow"] = "prevChar" + __gestures["kb:CONTROL+ALT+SHIFT+rightArrow"] = "nextWord" + __gestures["kb:CONTROL+ALT+SHIFT+leftArrow"] = "prevWord" + __gestures["kb:ALT+SHIFT+downArrow"] = "nextLine" + __gestures["kb:ALT+SHIFT+upArrow"] = "prevLine" # Maps all 10 numeric keyboard keys to the apropriate gesture. # It was done this way to avoid code repetition and to facilitate adding more commands in the future. for keyboardKey in range(10): - __gestures[f"kb:NVDA+CONTROL+{keyboardKey}"] = "saveToMemory" - __gestures[f"kb:NVDA+CONTROL+SHIFT+{keyboardKey}"] = "speakAndCopyMemory" + __gestures[f"kb:NVDA+CONTROL+{keyboardKey}"] = "saveToMemory" + __gestures[f"kb:NVDA+CONTROL+SHIFT+{keyboardKey}"] = "speakAndCopyMemory" + + # Maps minus and equals for 2 additional slots + for key in ("-", "="): + __gestures[f"kb:NVDA+CONTROL+{key}"] = "saveToMemory" + __gestures[f"kb:NVDA+CONTROL+SHIFT+{key}"] = "speakAndCopyMemory" + + # Maps numpad keys for 10 additional slots + for keyboardKey in range(10): + __gestures[f"kb:NVDA+CONTROL+numpad{keyboardKey}"] = "saveToMemory" + __gestures[f"kb:NVDA+CONTROL+SHIFT+numpad{keyboardKey}"] = "speakAndCopyMemory" + + # Maps backspace, numpad minus and numpad plus for 3 additional slots + for key in ("backspace", "numpadMinus", "numpadPlus"): + __gestures[f"kb:NVDA+CONTROL+{key}"] = "saveToMemory" + __gestures[f"kb:NVDA+CONTROL+SHIFT+{key}"] = "speakAndCopyMemory"