diff --git a/source/inputCore.py b/source/inputCore.py index 3bcfc19625a..d34c0979f57 100644 --- a/source/inputCore.py +++ b/source/inputCore.py @@ -150,6 +150,17 @@ def _get_displayName(self): """ return self.getDisplayTextForIdentifier(self.normalizedIdentifiers[0])[1] + def _get__nameForInputHelp(self) -> List[str]: + """The name of this gesture as presented to the user in input help mode. + + The base implementation returns a list containing self.displayName. + Subclasses can override this to provide more specific behavior, + such as including the character that would be typed. + + :return: The list of names to be displayed in input help mode. + """ + return [self.displayName] + #: Whether this gesture should be reported when reporting of command gestures is enabled. #: @type: bool shouldReportAsCommand = True @@ -646,7 +657,7 @@ def _inputHelpCaptor(self, gesture): return bypass def _handleInputHelp(self, gesture, onlyLog=False): - textList = [gesture.displayName] + helpItems = list(gesture._nameForInputHelp) script = gesture.script runScript = False logMsg = "Input help: gesture %s" % gesture.identifiers[0] @@ -661,7 +672,7 @@ def _handleInputHelp(self, gesture, onlyLog=False): else: desc = script.__doc__ if desc: - textList.append(desc) + helpItems.append(desc) log.info(logMsg) if onlyLog: @@ -669,14 +680,17 @@ def _handleInputHelp(self, gesture, onlyLog=False): import braille - braille.handler.message("\t\t".join(textList)) - # Punctuation must be spoken for the gesture name (the first chunk) so that punctuation keys are spoken. - speech.speakText( - textList[0], - reason=controlTypes.OutputReason.MESSAGE, - symbolLevel=characterProcessing.SymbolLevel.ALL, - ) - for text in textList[1:]: + braille.handler.message("\t\t".join(helpItems)) + # Punctuation must be spoken for the gesture names (the first chunk(s)) + # so that punctuation keys are spoken. + nameCount = len(gesture._nameForInputHelp) + for i in range(nameCount): + speech.speakText( + helpItems[i], + reason=controlTypes.OutputReason.MESSAGE, + symbolLevel=characterProcessing.SymbolLevel.ALL, + ) + for text in helpItems[nameCount:]: speech.speakMessage(text) if runScript: diff --git a/source/keyboardHandler.py b/source/keyboardHandler.py index f8115d497f2..181715c25f2 100644 --- a/source/keyboardHandler.py +++ b/source/keyboardHandler.py @@ -566,6 +566,122 @@ def _get_displayName(self): for key in self._keyNamesInDisplayOrder ) + def _get_character(self) -> Optional[str]: + """Get the character this key combination would produce. + + Uses ToUnicodeEx with 0x4 flag to avoid modifying keyboard state. + For dead keys, returns the dead key character itself. + Returns None for unprintable characters or when Windows key is pressed. + """ + # Key state value indicating key is pressed (high bit set) + KEY_PRESSED_STATE: int = -128 + + try: + threadID = api.getFocusObject().windowThreadID + except AttributeError: + return None + keyboardLayout = ctypes.windll.user32.GetKeyboardLayout(threadID) + buffer = ctypes.create_unicode_buffer(5) + states = (ctypes.c_byte * 256)() + + modifierList = [] + for mod, _ in self.modifiers: + if mod not in self.NORMAL_MODIFIER_KEYS.values(): + modifier = self.NORMAL_MODIFIER_KEYS.get(mod) + else: + modifier = mod + if modifier: + modifierList.append(modifier) + + # Check for Windows key - characters with Windows key are invalid + if VK_WIN in [self.getVkName(m, e) for m, e in self.modifiers]: + return None + + for i in range(256): + if i in modifierList: + states[i] = KEY_PRESSED_STATE + else: + states[i] = ctypes.windll.user32.GetKeyState(i) + + # Call ToUnicodeEx with 0x04 flag (don't modify keyboard state) + res = ctypes.windll.user32.ToUnicodeEx( + self.vkCode, + self.scanCode, + states, + buffer, + ctypes.sizeof(buffer), + 0x04, + keyboardLayout, + ) + + # res < 0 means dead key - return the dead key character + if res < 0: + # Dead key: buffer contains the dead key character + # Call ToUnicodeEx again to get and clear the dead key from buffer + ctypes.windll.user32.ToUnicodeEx( + self.vkCode, + self.scanCode, + states, + buffer, + ctypes.sizeof(buffer), + 0x04, + keyboardLayout, + ) + return buffer.value[:1] if buffer.value else None + + if res == 0: + return None + + # Check alt key behavior - alt sometimes gives same character as without alt + if winUser.VK_MENU in modifierList: + newBuffer = ctypes.create_unicode_buffer(5) + altStates = (ctypes.c_byte * 256)() + for i in range(256): + if i in modifierList and i != winUser.VK_MENU: + altStates[i] = KEY_PRESSED_STATE + else: + altStates[i] = ctypes.windll.user32.GetKeyState(i) + ctypes.windll.user32.ToUnicodeEx( + self.vkCode, + self.scanCode, + altStates, + newBuffer, + ctypes.sizeof(newBuffer), + 0x04, + keyboardLayout, + ) + # If same character with and without alt, it's not valid + if buffer.value == newBuffer.value: + return None + + return buffer.value[:res] + + def _get__nameForInputHelp(self) -> List[str]: + """Returns the name of this gesture for input help mode. + + For keyboard gestures that produce printable characters, + the character will be included in the list, + unless it contains NVDA modifier. + """ + displayName = self.displayName + + # NVDA commands keep original behavior + if any(isNVDAModifierKey(mod, ext) for mod, ext in self.modifiers): + return [displayName] + + char = self.character + if not char: + return [displayName] + + if not char.isprintable(): + return [displayName] + + # Avoid duplicating if displayName matches character (case insensitive) + if displayName.lower() == char.lower(): + return [displayName] + + return [char, displayName] + def _get_identifiers(self): keyName = "+".join(self._keyNamesInDisplayOrder) return ( @@ -736,7 +852,9 @@ def fromName(cls, name): if not keys: raise ValueError - return cls(keys[:-1], vk, 0, ext) + hkl = getInputHkl() + scanCode = user32.MapVirtualKeyEx(vk, winUser.MAPVK_VK_TO_VSC, hkl) + return cls(keys[:-1], vk, scanCode, ext) RE_IDENTIFIER = re.compile(r"^kb(?:\((.+?)\))?:(.*)$") diff --git a/source/winUser.py b/source/winUser.py index 17ae727c92f..db7cf7f5185 100644 --- a/source/winUser.py +++ b/source/winUser.py @@ -257,8 +257,11 @@ class NMHdrStruct(Structure): # Clipboard formats CF_TEXT = 1 # mapVirtualKey constants +MAPVK_VK_TO_VSC = 0 +MAPVK_VSC_TO_VK = 1 MAPVK_VK_TO_CHAR = 2 MAPVK_VSC_TO_VK_EX = 3 +MAPVK_VK_TO_VSC_EX = 4 # Virtual key codes VK_LBUTTON = 1 VK_RBUTTON = 2 diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 213e9432598..f28896fd7e9 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -9,6 +9,9 @@ * A new command, assigned to `NVDA+x`, has been introduced to repeat the last information spoken by NVDA; pressing it twice shows it in a browseable message. (#625, @CyrilleB79) * Added an unassigned command to toggle keyboard layout. (#19211, @CyrilleB79) * Added an unassigned Quick Navigation Command for jumping to next/previous slider in browse mode. (#17005, @hdzrvcc0X74) +* Input help mode has been improved: (#17629, @Cary-rowen, @Emil-18) + * When a key combination would produce a character in normal input mode, the character is reported first, followed by the key combination. + * If the key combination corresponds to an NVDA command, the behavior remains the same as before, i.e. the description of the command is reported. ### Changes