From a7fa0d680270d4295f874b89c1438b743d4b26f2 Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter <3049216+LeonarddeR@users.noreply.github.com> Date: Fri, 3 Jan 2025 04:40:23 +0100 Subject: [PATCH 1/8] Add ability to add more constraints to bdDetect USB device registration (#17537) Fixes #17521 Summary of the issue: In several cases, registering a USB device in bdDetect with just VID and PID is not enough. We need an extra filter to ensure that only the right devices are detected. This was fixed at the driver level until now, but fixing this in bdDetect improves performance and stability. Description of user facing changes More stability and quicker detection times. Description of development approach Added the ability to register a match func that further constraints device detection for specific VID/PID combinations. --- source/bdDetect.py | 231 +++++++++++++----- source/braille.py | 2 +- .../brailleDisplayDrivers/albatross/driver.py | 17 +- source/brailleDisplayDrivers/alva.py | 4 +- source/brailleDisplayDrivers/baum.py | 6 +- source/brailleDisplayDrivers/brailleNote.py | 2 +- source/brailleDisplayDrivers/brailliantB.py | 49 ++-- .../eurobraille/driver.py | 8 +- .../freedomScientific.py | 4 +- source/brailleDisplayDrivers/handyTech.py | 8 +- .../hidBrailleStandard.py | 2 +- source/brailleDisplayDrivers/hims.py | 16 +- source/brailleDisplayDrivers/nattiqbraille.py | 2 +- source/brailleDisplayDrivers/seikantk.py | 8 +- source/brailleDisplayDrivers/superBrl.py | 2 +- source/hwPortUtils.py | 41 +++- source/winAPI/constants.py | 2 + user_docs/en/changes.md | 9 + 18 files changed, 276 insertions(+), 137 deletions(-) diff --git a/source/bdDetect.py b/source/bdDetect.py index 28a32d7929a..03cb71ef710 100644 --- a/source/bdDetect.py +++ b/source/bdDetect.py @@ -12,6 +12,8 @@ For drivers in add-ons, this must be done in a global plugin. """ +from dataclasses import dataclass, field +from functools import partial import itertools import threading from concurrent.futures import ThreadPoolExecutor, Future @@ -51,15 +53,46 @@ USB_ID_REGEX = re.compile(r"^VID_[0-9A-F]{4}&PID_[0-9A-F]{4}$", re.U) -class DeviceType(StrEnum): +class ProtocolType(StrEnum): HID = "hid" """HID devices""" SERIAL = "serial" """Serial devices (COM ports)""" CUSTOM = "custom" - """Devices with a manufacturer specific driver""" + """Devices with a manufacturer specific protocol""" + + +class CommunicationType(StrEnum): BLUETOOTH = "bluetooth" """Bluetooth devices""" + USB = "usb" + """USB devices""" + + +class _DeviceTypeMeta(type): + # Mapping old attributes to the new enums + _mapping = { + "HID": ProtocolType.HID, + "SERIAL": ProtocolType.SERIAL, + "CUSTOM": ProtocolType.CUSTOM, + "BLUETOOTH": CommunicationType.BLUETOOTH, + } + + def __getattr__(cls, name: str) -> ProtocolType | CommunicationType: + repl = cls._mapping.get(name) + if repl is not None and NVDAState._allowDeprecatedAPI(): + log.warning( + f"{cls.__name__}.{name} is deprecated. Use {repl.__class__.__name__}.{repl} instead.", + ) + return repl + raise AttributeError(f"'{cls.__name__}' object has no attribute '{name}'") + + +class DeviceType(metaclass=_DeviceTypeMeta): + """This class is kept for backwards compatibility. + Former members were split into the L{ProtocolType} and L{CommunicationType} enums.""" + + ... def __getattr__(attrName: str) -> Any: @@ -71,25 +104,26 @@ def __getattr__(attrName: str) -> Any: log.warning(f"{attrName} is deprecated.") return 2 _deprecatedConstantsMap = { - "KEY_HID": DeviceType.HID, - "KEY_SERIAL": DeviceType.SERIAL, - "KEY_BLUETOOTH": DeviceType.BLUETOOTH, - "KEY_CUSTOM": DeviceType.CUSTOM, + "KEY_HID": ProtocolType.HID, + "KEY_SERIAL": ProtocolType.SERIAL, + "KEY_BLUETOOTH": CommunicationType.BLUETOOTH, + "KEY_CUSTOM": ProtocolType.CUSTOM, } if attrName in _deprecatedConstantsMap and NVDAState._allowDeprecatedAPI(): replacementSymbol = _deprecatedConstantsMap[attrName] log.warning( - f"{attrName} is deprecated. " f"Use bdDetect.DeviceType.{replacementSymbol.name} instead. ", + f"{attrName} is deprecated. " + f"Use bdDetect.{replacementSymbol.__class__.__name__}.{replacementSymbol.value} instead. ", ) - return replacementSymbol + return replacementSymbol.value raise AttributeError(f"module {repr(__name__)} has no attribute {repr(attrName)}") class DeviceMatch(NamedTuple): """Represents a detected device.""" - type: DeviceType - """The type of the device.""" + type: ProtocolType + """The protocol type of the device.""" id: str """The identifier of the device.""" port: str @@ -99,15 +133,41 @@ class DeviceMatch(NamedTuple): MatchFuncT = Callable[[DeviceMatch], bool] -DriverDictT = defaultdict[DeviceType, set[str] | MatchFuncT] -_driverDevices = OrderedDict[str, DriverDictT]() -fallBackDevices: set[tuple[str, DeviceType, str]] = set() -""" -Used to store fallback devices. -When registered as a fallback device, it will be yielded last among the connected USB devices. -""" +@dataclass(frozen=True) +class _UsbDeviceRegistryEntry: + """An internal class that contains information specific to an USB device registration.""" + + id: str + """The identifier of the device.""" + type: ProtocolType + """The protocol type of the device.""" + useAsFallback: bool = field(default=False, compare=False) + """ + determine how a device is associated with a driver. + If False (default), the device is immediately available for use with the driver. + If True, the device is added to a fallback list and is used only if the primary driver cannot use + initial devices, serving as a backup option in case of compatibility issues. + This provides flexibility and robustness in managing driver-device connections. + """ + matchFunc: MatchFuncT | None = field(default=None, compare=False) + """ + An optional function which determines whether a given device matches. + It takes a L{DeviceMatch} as its only argument + and returns a C{bool} indicating whether it matched.""" + + def matches(self, deviceMatch: DeviceMatch) -> bool: + """Returns whether this registry entry matches a specific device.""" + if deviceMatch.type != self.type or deviceMatch.id != self.id: + return False + if self.matchFunc is None: + return True + return self.matchFunc(deviceMatch) + + +DriverDictT = defaultdict[CommunicationType, set[_UsbDeviceRegistryEntry] | MatchFuncT] +_driverDevices = OrderedDict[str, DriverDictT]() scanForDevices = extensionPoints.Chain[Tuple[str, DeviceMatch]]() """ @@ -139,11 +199,11 @@ def getDriversForConnectedUsbDevices( @return: Generator of pairs of drivers and device information. """ usbCustomDeviceMatches = ( - DeviceMatch(DeviceType.CUSTOM, port["usbID"], port["devicePath"], port) + DeviceMatch(ProtocolType.CUSTOM, port["usbID"], port["devicePath"], port) for port in deviceInfoFetcher.usbDevices ) usbComDeviceMatches = ( - DeviceMatch(DeviceType.SERIAL, port["usbID"], port["port"], port) + DeviceMatch(ProtocolType.SERIAL, port["usbID"], port["port"], port) for port in deviceInfoFetcher.usbComPorts ) # Tee is used to ensure that the DeviceMatches aren't created multiple times. @@ -153,9 +213,9 @@ def getDriversForConnectedUsbDevices( # device matches), if one is found early the iteration can stop. usbHidDeviceMatches, usbHidDeviceMatchesForCustom = itertools.tee( ( - DeviceMatch(DeviceType.HID, port["usbID"], port["devicePath"], port) + DeviceMatch(ProtocolType.HID, port["usbID"], port["devicePath"], port) for port in deviceInfoFetcher.hidDevices - if port["provider"] == "usb" + if port["provider"] == CommunicationType.USB ) ) @@ -164,12 +224,13 @@ def getDriversForConnectedUsbDevices( for driver, devs in _driverDevices.items(): if limitToDevices and driver not in limitToDevices: continue - for type, ids in devs.items(): - if match.type == type and match.id in ids: - if (driver, match.type, match.id) in fallBackDevices: + usbDefinitions = devs[CommunicationType.USB] + for definition in usbDefinitions: + if definition.matches(match): + if definition.useAsFallback: fallbackDriversAndMatches.append({driver, match}) else: - yield driver, match + yield (driver, match) hidName = _getStandardHidDriverName() if limitToDevices and hidName not in limitToDevices: @@ -179,13 +240,10 @@ def getDriversForConnectedUsbDevices( # This ensures that a vendor specific driver is preferred over the braille HID protocol. # This preference may change in the future. if _isHIDBrailleMatch(match): - if (driver, match.type, match.id) in fallBackDevices: - fallbackDriversAndMatches.append({hidName, match}) - else: - yield hidName, match + yield (hidName, match) for driver, match in fallbackDriversAndMatches: - yield driver, match + yield (driver, match) def _getStandardHidDriverName() -> str: @@ -195,8 +253,21 @@ def _getStandardHidDriverName() -> str: return brailleDisplayDrivers.hidBrailleStandard.HidBrailleDriver.name +def _isHIDUsagePageMatch(match: DeviceMatch, usagePage: int) -> bool: + return match.type == ProtocolType.HID and match.deviceInfo.get("HIDUsagePage") == usagePage + + def _isHIDBrailleMatch(match: DeviceMatch) -> bool: - return match.type == DeviceType.HID and match.deviceInfo.get("HIDUsagePage") == HID_USAGE_PAGE_BRAILLE + return _isHIDUsagePageMatch(match, HID_USAGE_PAGE_BRAILLE) + + +def HIDUsagePageMatchFuncFactory(usagePage: int) -> MatchFuncT: + """ + Creates a match function that checks if a given HID usage page matches the specified usage page. + :param usagePage: The HID usage page to match against. + :returns: A partial function that takes an HID usage page and returns True if it matches the specified usage page, False otherwise. + """ + return partial(_isHIDUsagePageMatch, usagePage=usagePage) def getDriversForPossibleBluetoothDevices( @@ -210,7 +281,7 @@ def getDriversForPossibleBluetoothDevices( @return: Generator of pairs of drivers and port information. """ btSerialMatchesForCustom = ( - DeviceMatch(DeviceType.SERIAL, port["bluetoothName"], port["port"], port) + DeviceMatch(ProtocolType.SERIAL, port["bluetoothName"], port["port"], port) for port in deviceInfoFetcher.comPorts if "bluetoothName" in port ) @@ -221,20 +292,20 @@ def getDriversForPossibleBluetoothDevices( # device matches), if one is found early the iteration can stop. btHidDevMatchesForHid, btHidDevMatchesForCustom = itertools.tee( ( - DeviceMatch(DeviceType.HID, port["hardwareID"], port["devicePath"], port) + DeviceMatch(ProtocolType.HID, port["hardwareID"], port["devicePath"], port) for port in deviceInfoFetcher.hidDevices - if port["provider"] == "bluetooth" + if port["provider"] == CommunicationType.BLUETOOTH ) ) for match in itertools.chain(btSerialMatchesForCustom, btHidDevMatchesForCustom): for driver, devs in _driverDevices.items(): if limitToDevices and driver not in limitToDevices: continue - matchFunc = devs[DeviceType.BLUETOOTH] + matchFunc = devs[CommunicationType.BLUETOOTH] if not callable(matchFunc): continue if matchFunc(match): - yield driver, match + yield (driver, match) hidName = _getStandardHidDriverName() if limitToDevices and hidName not in limitToDevices: @@ -467,8 +538,6 @@ def terminate(self): appModuleHandler.post_appSwitch.unregister(self.pollBluetoothDevices) messageWindow.pre_handleWindowMessage.unregister(self.handleWindowMessage) self._stopBgScan() - # Clear the fallback devices - fallBackDevices.clear() # Clear the cache of bluetooth devices so new devices can be picked up with a new instance. deviceInfoFetcher.btDevsCache = None self._executor.shutdown(wait=False) @@ -482,16 +551,16 @@ def getConnectedUsbDevicesForDriver(driver: str) -> Iterator[DeviceMatch]: """ usbDevs = itertools.chain( ( - DeviceMatch(DeviceType.CUSTOM, port["usbID"], port["devicePath"], port) + DeviceMatch(ProtocolType.CUSTOM, port["usbID"], port["devicePath"], port) for port in deviceInfoFetcher.usbDevices ), ( - DeviceMatch(DeviceType.HID, port["usbID"], port["devicePath"], port) + DeviceMatch(ProtocolType.HID, port["usbID"], port["devicePath"], port) for port in deviceInfoFetcher.hidDevices - if port["provider"] == "usb" + if port["provider"] == CommunicationType.USB ), ( - DeviceMatch(DeviceType.SERIAL, port["usbID"], port["port"], port) + DeviceMatch(ProtocolType.SERIAL, port["usbID"], port["port"], port) for port in deviceInfoFetcher.usbComPorts ), ) @@ -501,15 +570,12 @@ def getConnectedUsbDevicesForDriver(driver: str) -> Iterator[DeviceMatch]: for match in usbDevs: if driver == _getStandardHidDriverName(): if _isHIDBrailleMatch(match): - if (driver, match.type, match.id) in fallBackDevices: - fallbackMatches.append(match) - else: - yield match + yield match else: - devs = _driverDevices[driver] - for type, ids in devs.items(): - if match.type == type and match.id in ids: - if (driver, match.type, match.id) in fallBackDevices: + usbDefinitions = _driverDevices[driver][CommunicationType.USB] + for definition in usbDefinitions: + if definition.matches(match): + if definition.useAsFallback: fallbackMatches.append(match) else: yield match @@ -527,19 +593,19 @@ def getPossibleBluetoothDevicesForDriver(driver: str) -> Iterator[DeviceMatch]: if driver == _getStandardHidDriverName(): matchFunc = _isHIDBrailleMatch else: - matchFunc = _driverDevices[driver][DeviceType.BLUETOOTH] + matchFunc = _driverDevices[driver][CommunicationType.BLUETOOTH] if not callable(matchFunc): return btDevs = itertools.chain( ( - DeviceMatch(DeviceType.SERIAL, port["bluetoothName"], port["port"], port) + DeviceMatch(ProtocolType.SERIAL, port["bluetoothName"], port["port"], port) for port in deviceInfoFetcher.comPorts if "bluetoothName" in port ), ( - DeviceMatch(DeviceType.HID, port["hardwareID"], port["devicePath"], port) + DeviceMatch(ProtocolType.HID, port["hardwareID"], port["devicePath"], port) for port in deviceInfoFetcher.hidDevices - if port["provider"] == "bluetooth" + if port["provider"] == CommunicationType.BLUETOOTH ), ) for match in btDevs: @@ -602,7 +668,7 @@ def getBrailleDisplayDriversEnabledForDetection() -> Generator[str, Any, Any]: def initialize(): """Initializes bdDetect, such as detection data. - Calls to addUsbDevices, and addBluetoothDevices. + Calls to addUsbDevice, addUsbDevices, and addBluetoothDevices. Specify the requirements for a detected device to be considered a match for a specific driver. """ @@ -646,18 +712,58 @@ def _getDriverDict(self) -> DriverDictT: ret = _driverDevices[self._driver] = DriverDictT(set) return ret - def addUsbDevices(self, type: DeviceType, ids: set[str], useAsFallBack: bool = False): + def addUsbDevice( + self, + type: ProtocolType, + id: str, + useAsFallback: bool = False, + matchFunc: MatchFuncT | None = None, + ): + """Associate an USB device with the driver on this instance. + :param type: The type of the driver. + :param id: A USB ID in the form C{"VID_xxxx&PID_XXXX"}. + Note that alphabetical characters in hexadecimal numbers should be uppercase. + :param useAsFallback: A boolean flag to determine how this USB device is associated with the driver. + If False (default), the device is added directly to the primary driver list for the specified type, + meaning it is immediately available for use with the driver. + If True, the device is used only if the primary driver cannot use + the initial devices, serving as a backup option in case of compatibility issues. + This provides flexibility and robustness in managing driver-device connections. + @param matchFunc: An optional function which determines whether a given device matches. + It takes a L{DeviceMatch} as its only argument + and returns a C{bool} indicating whether it matched. + It can be used to further constrain a device registration, such as for a specific HID usage page. + :raise ValueError: When the provided ID is malformed. + """ + if not isinstance(id, str) or not USB_ID_REGEX.match(id): + raise ValueError( + f"Invalid ID provided for driver {self._driver!r}, type {type!r}: " f"{id!r}", + ) + devs = self._getDriverDict() + driverUsb = devs[CommunicationType.USB] + driverUsb.add(_UsbDeviceRegistryEntry(type, id, useAsFallback, matchFunc)) + + def addUsbDevices( + self, + type: DeviceType, + ids: set[str], + useAsFallback: bool = False, + matchFunc: MatchFuncT = None, + ): """Associate USB devices with the driver on this instance. :param type: The type of the driver. :param ids: A set of USB IDs in the form C{"VID_xxxx&PID_XXXX"}. Note that alphabetical characters in hexadecimal numbers should be uppercase. - :param useAsFallBack: A boolean flag to determine how USB devices are associated with the driver. - + :param useAsFallback: A boolean flag to determine how USB devices are associated with the driver. If False (default), the devices are added directly to the primary driver list for the specified type, meaning they are immediately available for use with the driver. If True, the devices are added to a fallback list and are used only if the primary driver cannot use the initial devices, serving as a backup option in case of compatibility issues. This provides flexibility and robustness in managing driver-device connections. + @param matchFunc: An optional function which determines whether a given device matches. + It takes a L{DeviceMatch} as its only argument + and returns a C{bool} indicating whether it matched. + It can be used to further constrain device registrations, such as for a specific HID usage page. :raise ValueError: When one of the provided IDs is malformed. """ malformedIds = [id for id in ids if not isinstance(id, str) or not USB_ID_REGEX.match(id)] @@ -666,12 +772,9 @@ def addUsbDevices(self, type: DeviceType, ids: set[str], useAsFallBack: bool = F f"Invalid IDs provided for driver {self._driver!r}, type {type!r}: " f"{', '.join(malformedIds)}", ) - if useAsFallBack: - fallBackDevices.update((self._driver, type, id) for id in ids) - devs = self._getDriverDict() - driverUsb = devs[type] - driverUsb.update(ids) + driverUsb = devs[CommunicationType.USB] + driverUsb.update((_UsbDeviceRegistryEntry(id, type, useAsFallback, matchFunc) for id in ids)) def addBluetoothDevices(self, matchFunc: MatchFuncT): """Associate Bluetooth HID or COM ports with the driver on this instance. @@ -680,7 +783,7 @@ def addBluetoothDevices(self, matchFunc: MatchFuncT): and returns a C{bool} indicating whether it matched. """ devs = self._getDriverDict() - devs[DeviceType.BLUETOOTH] = matchFunc + devs[CommunicationType.BLUETOOTH] = matchFunc def addDeviceScanner( self, diff --git a/source/braille.py b/source/braille.py index ba44325c07b..54a70dac138 100644 --- a/source/braille.py +++ b/source/braille.py @@ -3517,7 +3517,7 @@ def _getTryPorts( pass else: yield bdDetect.DeviceMatch( - bdDetect.DeviceType.SERIAL, + bdDetect.ProtocolType.SERIAL, portInfo["bluetoothName" if "bluetoothName" in portInfo else "friendlyName"], portInfo["port"], portInfo, diff --git a/source/brailleDisplayDrivers/albatross/driver.py b/source/brailleDisplayDrivers/albatross/driver.py index 311dd145ceb..55a8568268e 100644 --- a/source/brailleDisplayDrivers/albatross/driver.py +++ b/source/brailleDisplayDrivers/albatross/driver.py @@ -12,7 +12,7 @@ import time from collections import deque -from bdDetect import DeviceType, DriverRegistrar +from bdDetect import DriverRegistrar, ProtocolType from logHandler import log from serial.win32 import ( PURGE_RXABORT, @@ -84,11 +84,11 @@ class BrailleDisplayDriver(braille.BrailleDisplayDriver): @classmethod def registerAutomaticDetection(cls, driverRegistrar: DriverRegistrar): - driverRegistrar.addUsbDevices( - DeviceType.SERIAL, - { - "VID_0403&PID_6001", # Caiku Albatross 46/80 - }, + driverRegistrar.addUsbDevice( + ProtocolType.SERIAL, + VID_AND_PID, # Caiku Albatross 46/80 + # Filter for bus reported device description, which should be "Albatross Braille Display". + matchFunc=lambda match: match.deviceInfo.get("busReportedDeviceDescription") == BUS_DEVICE_DESC, ) @classmethod @@ -168,11 +168,6 @@ def _searchPorts(self, originalPort: str): """ for self._baudRate in BAUD_RATE: for portType, portId, port, portInfo in self._getTryPorts(originalPort): - # Block port if its vid and pid are correct but bus reported - # device description is not "Albatross Braille Display". - if portId == VID_AND_PID and portInfo.get("busReportedDeviceDescription") != BUS_DEVICE_DESC: - log.debug(f"port {port} blocked; port information: {portInfo}") - continue # For reconnection self._currentPort = port self._tryToConnect = True diff --git a/source/brailleDisplayDrivers/alva.py b/source/brailleDisplayDrivers/alva.py index 0402bf31692..73f8aaf63be 100644 --- a/source/brailleDisplayDrivers/alva.py +++ b/source/brailleDisplayDrivers/alva.py @@ -158,7 +158,7 @@ class BrailleDisplayDriver(braille.BrailleDisplayDriver, ScriptableObject): @classmethod def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): driverRegistrar.addUsbDevices( - bdDetect.DeviceType.HID, + bdDetect.ProtocolType.HID, { "VID_0798&PID_0640", # BC640 "VID_0798&PID_0680", # BC680 @@ -216,7 +216,7 @@ def __init__(self, port="auto"): self._deviceId = None for portType, portId, port, portInfo in self._getTryPorts(port): - self.isHid = portType == bdDetect.DeviceType.HID + self.isHid = portType == bdDetect.ProtocolType.HID # Try talking to the display. try: if self.isHid: diff --git a/source/brailleDisplayDrivers/baum.py b/source/brailleDisplayDrivers/baum.py index 5ba90c7cc36..e734cf7e234 100644 --- a/source/brailleDisplayDrivers/baum.py +++ b/source/brailleDisplayDrivers/baum.py @@ -84,7 +84,7 @@ class BrailleDisplayDriver(braille.BrailleDisplayDriver): @classmethod def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): driverRegistrar.addUsbDevices( - bdDetect.DeviceType.HID, + bdDetect.ProtocolType.HID, { "VID_0904&PID_3001", # RefreshaBraille 18 "VID_0904&PID_6101", # VarioUltra 20 @@ -111,7 +111,7 @@ def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): ) driverRegistrar.addUsbDevices( - bdDetect.DeviceType.SERIAL, + bdDetect.ProtocolType.SERIAL, { "VID_0403&PID_FE70", # Vario 40 "VID_0403&PID_FE71", # PocketVario @@ -164,7 +164,7 @@ def __init__(self, port="auto"): for portType, portId, port, portInfo in self._getTryPorts(port): # At this point, a port bound to this display has been found. # Try talking to the display. - self.isHid = portType == bdDetect.DeviceType.HID + self.isHid = portType == bdDetect.ProtocolType.HID try: if self.isHid: self._dev = hwIo.Hid(port, onReceive=self._onReceive) diff --git a/source/brailleDisplayDrivers/brailleNote.py b/source/brailleDisplayDrivers/brailleNote.py index e3ca3b471de..3d843cf8e3d 100644 --- a/source/brailleDisplayDrivers/brailleNote.py +++ b/source/brailleDisplayDrivers/brailleNote.py @@ -130,7 +130,7 @@ class BrailleDisplayDriver(braille.BrailleDisplayDriver): @classmethod def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): driverRegistrar.addUsbDevices( - bdDetect.DeviceType.SERIAL, + bdDetect.ProtocolType.SERIAL, { "VID_1C71&PID_C004", # Apex }, diff --git a/source/brailleDisplayDrivers/brailliantB.py b/source/brailleDisplayDrivers/brailliantB.py index 033ce1add0d..e411a7c8b31 100644 --- a/source/brailleDisplayDrivers/brailliantB.py +++ b/source/brailleDisplayDrivers/brailliantB.py @@ -35,6 +35,7 @@ HR_KEYS = b"\x04" HR_BRAILLE = b"\x05" HR_POWEROFF = b"\x07" +HID_USAGE_PAGE = 0x93 KEY_NAMES = { 1: "power", # Brailliant BI 32, 40 and 80. @@ -89,22 +90,28 @@ class BrailleDisplayDriver(braille.BrailleDisplayDriver): @classmethod def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): driverRegistrar.addUsbDevices( - bdDetect.DeviceType.HID, + bdDetect.ProtocolType.HID, { "VID_1C71&PID_C111", # Mantis Q 40 "VID_1C71&PID_C101", # Chameleon 20 + "VID_1C71&PID_C131", # Brailliant BI 40X + "VID_1C71&PID_C141", # Brailliant BI 20X + }, + matchFunc=bdDetect.HIDUsagePageMatchFuncFactory(HID_USAGE_PAGE), + ) + driverRegistrar.addUsbDevices( + bdDetect.ProtocolType.HID, + { "VID_1C71&PID_C121", # Humanware BrailleOne 20 HID "VID_1C71&PID_CE01", # NLS eReader 20 HID "VID_1C71&PID_C006", # Brailliant BI 32, 40 and 80 "VID_1C71&PID_C022", # Brailliant BI 14 - "VID_1C71&PID_C131", # Brailliant BI 40X - "VID_1C71&PID_C141", # Brailliant BI 20X "VID_1C71&PID_C00A", # BrailleNote Touch "VID_1C71&PID_C00E", # BrailleNote Touch v2 }, ) driverRegistrar.addUsbDevices( - bdDetect.DeviceType.SERIAL, + bdDetect.ProtocolType.SERIAL, { "VID_1C71&PID_C005", # Brailliant BI 32, 40 and 80 "VID_1C71&PID_C021", # Brailliant BI 14 @@ -112,24 +119,32 @@ def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): ) driverRegistrar.addBluetoothDevices( lambda m: ( - m.type == bdDetect.DeviceType.SERIAL + m.type == bdDetect.ProtocolType.SERIAL and ( m.id.startswith("Brailliant B") or m.id == "Brailliant 80" or "BrailleNote Touch" in m.id ) ) or ( - m.type == bdDetect.DeviceType.HID + m.type == bdDetect.ProtocolType.HID and m.deviceInfo.get("manufacturer") == "Humanware" - and m.deviceInfo.get("product") - in ( - "Brailliant HID", - "APH Chameleon 20", - "APH Mantis Q40", - "Humanware BrailleOne", - "NLS eReader", - "NLS eReader Humanware", - "Brailliant BI 40X", - "Brailliant BI 20X", + and ( + ( + m.deviceInfo.get("product") + in ( + "APH Chameleon 20", + "APH Mantis Q40", + "Brailliant BI 40X", + "Brailliant BI 20X", + ) + and bdDetect._isHIDUsagePageMatch(m, HID_USAGE_PAGE) + ) + or m.deviceInfo.get("product") + in ( + "Brailliant HID", + "Humanware BrailleOne", + "NLS eReader", + "NLS eReader Humanware", + ) ) ), ) @@ -143,7 +158,7 @@ def __init__(self, port="auto"): self.numCells = 0 for portType, portId, port, portInfo in self._getTryPorts(port): - self.isHid = portType == bdDetect.DeviceType.HID + self.isHid = portType == bdDetect.ProtocolType.HID # Try talking to the display. try: if self.isHid: diff --git a/source/brailleDisplayDrivers/eurobraille/driver.py b/source/brailleDisplayDrivers/eurobraille/driver.py index 2911d90b9fc..ddad0ad50db 100644 --- a/source/brailleDisplayDrivers/eurobraille/driver.py +++ b/source/brailleDisplayDrivers/eurobraille/driver.py @@ -45,7 +45,7 @@ class BrailleDisplayDriver(braille.BrailleDisplayDriver, ScriptableObject): @classmethod def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): driverRegistrar.addUsbDevices( - bdDetect.DeviceType.HID, + bdDetect.ProtocolType.HID, { "VID_C251&PID_1122", # Esys (version < 3.0, no SD card "VID_C251&PID_1123", # Esys (version >= 3.0, with HID keyboard, no SD card @@ -67,7 +67,7 @@ def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): }, ) driverRegistrar.addUsbDevices( - bdDetect.DeviceType.SERIAL, + bdDetect.ProtocolType.SERIAL, { "VID_28AC&PID_0012", # b.note "VID_28AC&PID_0013", # b.note 2 @@ -97,7 +97,7 @@ def __init__(self, port="Auto"): for portType, portId, port, portInfo in self._getTryPorts(port): # At this point, a port bound to this display has been found. # Try talking to the display. - self.isHid = portType == bdDetect.DeviceType.HID + self.isHid = portType == bdDetect.ProtocolType.HID try: if self.isHid: self._dev = hwIo.Hid( @@ -173,7 +173,7 @@ def terminate(self): def _prepFirstByteStreamAndData( self, data: bytes, - ) -> (bytes, Union[BytesIO, hwIo.IoBase], bytes): + ) -> tuple[bytes, Union[BytesIO, hwIo.IoBase], bytes]: if self.isHid: # data contains the entire packet. # HID Packets start with 0x00. diff --git a/source/brailleDisplayDrivers/freedomScientific.py b/source/brailleDisplayDrivers/freedomScientific.py index fe00e79e721..ce6ebb00901 100755 --- a/source/brailleDisplayDrivers/freedomScientific.py +++ b/source/brailleDisplayDrivers/freedomScientific.py @@ -199,7 +199,7 @@ class BrailleDisplayDriver(braille.BrailleDisplayDriver, ScriptableObject): @classmethod def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): driverRegistrar.addUsbDevices( - bdDetect.DeviceType.CUSTOM, + bdDetect.ProtocolType.CUSTOM, { "VID_0F4E&PID_0100", # Focus 1 "VID_0F4E&PID_0111", # PAC Mate @@ -243,7 +243,7 @@ def __init__(self, port="auto"): self.gestureMap.add("br(freedomScientific):rightWizWheelDown", *action[2]) super(BrailleDisplayDriver, self).__init__() for portType, portId, port, portInfo in self._getTryPorts(port): - self.isUsb = portType == bdDetect.DeviceType.CUSTOM + self.isUsb = portType == bdDetect.ProtocolType.CUSTOM # Try talking to the display. try: if self.isUsb: diff --git a/source/brailleDisplayDrivers/handyTech.py b/source/brailleDisplayDrivers/handyTech.py index a5a808027ef..e68555bf72d 100644 --- a/source/brailleDisplayDrivers/handyTech.py +++ b/source/brailleDisplayDrivers/handyTech.py @@ -689,7 +689,7 @@ class BrailleDisplayDriver(braille.BrailleDisplayDriver, ScriptableObject): @classmethod def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): driverRegistrar.addUsbDevices( - bdDetect.DeviceType.SERIAL, + bdDetect.ProtocolType.SERIAL, { "VID_0403&PID_6001", # FTDI chip "VID_0921&PID_1200", # GoHubs chip @@ -698,7 +698,7 @@ def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): # Newer Handy Tech displays have a native HID processor driverRegistrar.addUsbDevices( - bdDetect.DeviceType.HID, + bdDetect.ProtocolType.HID, { "VID_1FE4&PID_0054", # Active Braille "VID_1FE4&PID_0055", # Connect Braille @@ -723,7 +723,7 @@ def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): # Some older HT displays use a HID converter and an internal serial interface driverRegistrar.addUsbDevices( - bdDetect.DeviceType.HID, + bdDetect.ProtocolType.HID, { "VID_1FE4&PID_0003", # USB-HID adapter "VID_1FE4&PID_0074", # Braille Star 40 @@ -774,7 +774,7 @@ def __init__(self, port="auto"): for portType, portId, port, portInfo in self._getTryPorts(port): # At this point, a port bound to this display has been found. # Try talking to the display. - self.isHid = portType == bdDetect.DeviceType.HID + self.isHid = portType == bdDetect.ProtocolType.HID self.isHidSerial = portId in USB_IDS_HID_CONVERTER self.port = port try: diff --git a/source/brailleDisplayDrivers/hidBrailleStandard.py b/source/brailleDisplayDrivers/hidBrailleStandard.py index 3e06d5e3912..c7e27ea5300 100644 --- a/source/brailleDisplayDrivers/hidBrailleStandard.py +++ b/source/brailleDisplayDrivers/hidBrailleStandard.py @@ -95,7 +95,7 @@ def __init__(self, port="auto"): self.numCols = 0 for portType, portId, port, portInfo in self._getTryPorts(port): - if portType != bdDetect.DeviceType.HID: + if portType != bdDetect.ProtocolType.HID: continue # Try talking to the display. try: diff --git a/source/brailleDisplayDrivers/hims.py b/source/brailleDisplayDrivers/hims.py index a129c8c68fd..25bd648bebd 100644 --- a/source/brailleDisplayDrivers/hims.py +++ b/source/brailleDisplayDrivers/hims.py @@ -281,20 +281,20 @@ class BrailleDisplayDriver(braille.BrailleDisplayDriver): @classmethod def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): deviceTypes = { - bdDetect.DeviceType.HID: ( + bdDetect.ProtocolType.HID: ( { "VID_045E&PID_940A", # Braille Edge3S 40 }, True, ), - bdDetect.DeviceType.CUSTOM: ( + bdDetect.ProtocolType.CUSTOM: ( { "VID_045E&PID_930A", # Braille Sense & Smart Beetle "VID_045E&PID_930B", # Braille EDGE 40 }, False, ), - bdDetect.DeviceType.SERIAL: ( + bdDetect.ProtocolType.SERIAL: ( { "VID_0403&PID_6001", "VID_1A86&PID_55D3", # Braille Edge2S 40 @@ -329,17 +329,17 @@ def __init__(self, port="auto"): for match in self._getTryPorts(port): portType, portId, port, portInfo = match - self.isBulk = portType == bdDetect.DeviceType.CUSTOM - self.isHID = portType == bdDetect.DeviceType.HID + self.isBulk = portType == bdDetect.ProtocolType.CUSTOM + self.isHID = portType == bdDetect.ProtocolType.HID # Try talking to the display. try: match portType: - case bdDetect.DeviceType.HID: + case bdDetect.ProtocolType.HID: self._dev = hwIo.Hid(port, onReceive=self._hidOnReceive) - case bdDetect.DeviceType.CUSTOM: + case bdDetect.ProtocolType.CUSTOM: # onReceiveSize based on max packet size according to USB endpoint information. self._dev = hwIo.Bulk(port, 0, 1, self._onReceive, onReceiveSize=64) - case bdDetect.DeviceType.SERIAL: + case bdDetect.ProtocolType.SERIAL: self._dev = hwIo.Serial( port, baudrate=BAUD_RATE, diff --git a/source/brailleDisplayDrivers/nattiqbraille.py b/source/brailleDisplayDrivers/nattiqbraille.py index d83955d8130..9088ed5c080 100644 --- a/source/brailleDisplayDrivers/nattiqbraille.py +++ b/source/brailleDisplayDrivers/nattiqbraille.py @@ -39,7 +39,7 @@ class BrailleDisplayDriver(braille.BrailleDisplayDriver): @classmethod def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): driverRegistrar.addUsbDevices( - bdDetect.DeviceType.SERIAL, + bdDetect.ProtocolType.SERIAL, { "VID_2341&PID_8036", # Atmel-based USB Serial for Nattiq nBraille }, diff --git a/source/brailleDisplayDrivers/seikantk.py b/source/brailleDisplayDrivers/seikantk.py index 28beb399ae2..e7e1b0d16b4 100644 --- a/source/brailleDisplayDrivers/seikantk.py +++ b/source/brailleDisplayDrivers/seikantk.py @@ -16,7 +16,7 @@ import serial import braille -from bdDetect import DeviceType, DeviceMatch, DriverRegistrar +from bdDetect import DeviceMatch, DriverRegistrar import brailleInput import inputCore import bdDetect @@ -107,7 +107,7 @@ class BrailleDisplayDriver(braille.BrailleDisplayDriver): @classmethod def registerAutomaticDetection(cls, driverRegistrar: DriverRegistrar): driverRegistrar.addUsbDevices( - DeviceType.HID, + bdDetect.ProtocolType.HID, { vidpid, # Seika Notetaker }, @@ -134,8 +134,8 @@ def __init__(self, port: typing.Union[None, str, DeviceMatch]): log.debug(f"Seika Notetaker braille driver: ({port!r})") dev: typing.Optional[typing.Union[hwIo.Hid, hwIo.Serial]] = None for match in self._getTryPorts(port): - self.isHid = match.type == bdDetect.DeviceType.HID - self.isSerial = match.type == bdDetect.DeviceType.SERIAL + self.isHid = match.type == bdDetect.ProtocolType.HID + self.isSerial = match.type == bdDetect.ProtocolType.SERIAL try: if self.isHid: log.info("Trying Seika notetaker on USB-HID") diff --git a/source/brailleDisplayDrivers/superBrl.py b/source/brailleDisplayDrivers/superBrl.py index 10a28ee856a..beef924445f 100644 --- a/source/brailleDisplayDrivers/superBrl.py +++ b/source/brailleDisplayDrivers/superBrl.py @@ -34,7 +34,7 @@ class BrailleDisplayDriver(braille.BrailleDisplayDriver): @classmethod def registerAutomaticDetection(cls, driverRegistrar: bdDetect.DriverRegistrar): driverRegistrar.addUsbDevices( - bdDetect.DeviceType.SERIAL, + bdDetect.ProtocolType.SERIAL, { "VID_10C4&PID_EA60", # SuperBraille 3.2 }, diff --git a/source/hwPortUtils.py b/source/hwPortUtils.py index 29dc7ed0002..de926c07a78 100644 --- a/source/hwPortUtils.py +++ b/source/hwPortUtils.py @@ -16,6 +16,7 @@ import hidpi import winKernel from logHandler import log +from winAPI.constants import SystemErrorCodes from winKernel import SYSTEMTIME @@ -496,7 +497,10 @@ def __init__(self, **kwargs): super().__init__(Size=ctypes.sizeof(HIDD_ATTRIBUTES), **kwargs) -def _getHidInfo(hwId, path): +_getHidInfoCache: dict[str, dict] = {} + + +def _getHidInfo(hwId: str, path: str) -> dict[str, typing.Any]: info = { "hardwareID": hwId, "devicePath": path, @@ -517,18 +521,28 @@ def _getHidInfo(hwId, path): # Fetch additional info about the HID device. from serial.win32 import FILE_FLAG_OVERLAPPED, INVALID_HANDLE_VALUE, CreateFile - handle = CreateFile( - path, - 0, - winKernel.FILE_SHARE_READ | winKernel.FILE_SHARE_WRITE, - None, - winKernel.OPEN_EXISTING, - FILE_FLAG_OVERLAPPED, - None, - ) - if handle == INVALID_HANDLE_VALUE: - if _isDebug(): - log.debugWarning(f"Opening device {path} to get additional info failed: {ctypes.WinError()}") + if ( + handle := CreateFile( + path, + 0, + winKernel.FILE_SHARE_READ | winKernel.FILE_SHARE_WRITE, + None, + winKernel.OPEN_EXISTING, + FILE_FLAG_OVERLAPPED, + None, + ) + ) == INVALID_HANDLE_VALUE: + if (err := ctypes.GetLastError()) == SystemErrorCodes.SHARING_VIOLATION: + if _isDebug(): + log.debugWarning( + f"Opening device {path} to get additional info failed because the device is being used. " + "Falling back to cache for device info", + ) + if cachedInfo := _getHidInfoCache.get(path): + cachedInfo.update(info) + return cachedInfo + elif _isDebug(): + log.debugWarning(f"Opening device {path} to get additional info failed: {ctypes.WinError(err)}") return info try: attribs = HIDD_ATTRIBUTES() @@ -555,6 +569,7 @@ def _getHidInfo(hwId, path): ctypes.windll.hid.HidD_FreePreparsedData(pd) finally: winKernel.closeHandle(handle) + _getHidInfoCache[path] = info return info diff --git a/source/winAPI/constants.py b/source/winAPI/constants.py index a7f1bc07fe3..41e60a3562b 100644 --- a/source/winAPI/constants.py +++ b/source/winAPI/constants.py @@ -28,6 +28,8 @@ class SystemErrorCodes(enum.IntEnum): ACCESS_DENIED = 0x5 INVALID_DATA = 0xD NOT_READY = 0x15 + SHARING_VIOLATION = 0x20 + """The process cannot access the file because it is being used by another process.""" INVALID_PARAMETER = 0x57 MOD_NOT_FOUND = 0x7E CANCELLED = 0x4C7 diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 5d2a74f72ab..fa7846e2977 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -81,6 +81,8 @@ Specifically, MathML inside of span and other elements that have the attribute ` In any document, if the cursor is on the last line, it will be moved to the end when using this command. (#17251, #17430, @nvdaes) * In web browsers, changes to text selection no longer sometimes fail to be reported in editable text controls. (#17501, @jcsteh) +* When the Standard HID Braille Display driver is explicitly selected as the braille display driver, and the braille display list is opened, NVDA correctly identifies the HID driver as the selected driver instead of showing no driver selected. (#17537, @LeonarddeR) +* The Humanware Brailliant driver is now more reliable in selecting the right connection endpoint, resulting in better connection stability and less errors. (#17537, @LeonarddeR) * Custom braille tables in the developer scratchpad are now properly ignored when running with add-ons disabled. (#17565, @LeonarddeR) ### Changes for Developers @@ -125,6 +127,11 @@ Add-ons will need to be re-tested and have their manifest updated. * Added the following extension points (#17428, @ctoth): * `inputCore.decide_handleRawKey`: called on each keypress * `speech.extensions.post_speechPaused`: called when speech is paused or unpaused +* Changes to braille display auto detection registration in `bdDetect.DriverRegistrar`: (#17521, @LeonarddeR) + * Added the `addUsbDevice` method to register one USB device at a time. + * Added the `matchFunc` parameter to `addUsbDevices` which is also available on `addUsbDevice`. + * This way device detection can be constrained further in cases where a VID/PID-combination is shared by multiple devices across multiple drivers, or when a HID device offers multiple endpoints, for example. + * See the method documentation as well as examples in the albatross and brailliantB drivers for more information. #### API Breaking Changes @@ -152,6 +159,7 @@ As the NVDA update check URL is now configurable directly within NVDA, no replac * In `NVDAObjects.window.scintilla.ScintillaTextInfo`, if no text is selected, the `collapse` method is overriden to expand to line if the `end` parameter is set to `True` (#17431, @nvdaes) * The following symbols have been removed with no replacement: `languageHandler.getLanguageCliArgs`, `__main__.quitGroup` and `__main__.installGroup` . (#17486, @CyrilleB79) * Prefix matching on command line flags, e.g. using `--di` for `--disable-addons` is no longer supported. (#11644, @CyrilleB79) +* The `useAsFallBack` keyword argument of `bdDetect.DriverRegistrar` has been renamed to `useAsFallback`. (#17521, @LeonarddeR) #### Deprecations @@ -160,6 +168,7 @@ Please use `braille.filter_displayDimensions` instead. (#17011) * The following symbols are deprecated (#17486, @CyrilleB79): * `NoConsoleOptionParser`, `stringToBool`, `stringToLang` in `__main__`; use the same symbols in `argsParsing` instead. * `__main__.parser`; use `argsParsing.getParser()` instead. +* `bdDetect.DeviceType` is deprecated in favour of `bdDetect.ProtocolType` and `bdDetect.CommunicationType` to take into account the fact that both HID and Serial communication can take place over USB and Bluetooth. (#17537 , @LeonarddeR) ## 2024.4.2 From 0f7e961a06c5bfb11f7528e81020b1f1047a2fe3 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 6 Jan 2025 17:25:15 +1100 Subject: [PATCH 2/8] Use endpoint IDs instead of device friendly names to store user's preferred output device (#17547) Closes #17497 Summary of the issue: NVDA currently stores the friendly name of the user's preferred audio output device. Description of user facing changes None. Description of development approach In `nvdaHelper/local/wasapi.cpp`, rewrote `getPreferredDevice` to fetch the preferred device directly via `MMDeviceEnumerator.GetDevice`. Added manual checks that the fetched device is a render device, and that its status is active, since these conditions were guaranteed to be met since the previous code only iterated over devices which met those prerequisites. Renamed `deviceName` to `endpointId`. In `source/mmwave.py`, added a parameter to `_getOutputDevices` to return a value representing the system default output device. Also made the type hints more self-documenting by using a `NamedTuple`. In `source/gui/settingsDialogs.py`, used `nvwave._getOutputDevices` rather than `nvwave.getOutputDeviceNames` to fetch the available output devices. When saving, used the selection index of `AudioSettingsPanel.deviceList` to index into the tuple of IDs to get the value to save to config. In `source/config/configSpec.py`, moved the `outputDevice` key from `speech` to `audio`, and incremented the schema version to 14. Added an associated profile upgrade function in `profileUpgradeSteps.py`, and tests for same in `tests/unit/test_config.py`. Updated all occurrences of this config key that I am aware of to point to the new location. In `source/synthDrivers/sapi5.py`, rewrote the device selection logic again to work with endpoint IDs. Testing strategy: Built from source and ensured that changing output devices works as expected. Ensured that saving the config worked, and that the output device was selected correctly when restarting NVDA. Tested activating SAPI5 with different output devices selected. Known issues with pull request: SAPI4 still doesn't work (#17516 ), this will be fixed in a future PR. Endpoint ID strings are not human-readable. --- nvdaHelper/local/wasapi.cpp | 90 +++++++++--------- source/config/configSpec.py | 4 +- source/config/profileUpgradeSteps.py | 57 +++++++++++ source/gui/settingsDialogs.py | 19 ++-- source/nvwave.py | 39 ++++++-- source/synthDriverHandler.py | 6 +- source/synthDrivers/_espeak.py | 4 +- source/synthDrivers/oneCore.py | 4 +- source/synthDrivers/sapi4.py | 4 +- source/synthDrivers/sapi5.py | 16 +++- source/tones.py | 4 +- tests/unit/test_config.py | 135 ++++++++++++++++++++++++++- user_docs/en/changes.md | 2 + 13 files changed, 299 insertions(+), 85 deletions(-) diff --git a/nvdaHelper/local/wasapi.cpp b/nvdaHelper/local/wasapi.cpp index 244b379920b..e942f6952db 100644 --- a/nvdaHelper/local/wasapi.cpp +++ b/nvdaHelper/local/wasapi.cpp @@ -1,7 +1,7 @@ /* This file is a part of the NVDA project. URL: http://www.nvda-project.org/ -Copyright 2023 James Teh. +Copyright 2023-2024 NV Access Limited, James Teh. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License version 2.0, as published by the Free Software Foundation. @@ -45,6 +45,7 @@ const IID IID_IMMNotificationClient = __uuidof(IMMNotificationClient); const IID IID_IAudioStreamVolume = __uuidof(IAudioStreamVolume); const IID IID_IAudioSessionManager2 = __uuidof(IAudioSessionManager2); const IID IID_IAudioSessionControl2 = __uuidof(IAudioSessionControl2); +const IID IID_IMMEndpoint = __uuidof(IMMEndpoint); /** * C++ RAII class to manage the lifecycle of a standard Windows HANDLE closed @@ -167,9 +168,9 @@ class WasapiPlayer { /** * Constructor. - * Specify an empty (not null) deviceName to use the default device. + * Specify an empty (not null) endpointId to use the default device. */ - WasapiPlayer(wchar_t* deviceName, WAVEFORMATEX format, + WasapiPlayer(wchar_t* endpointId, WAVEFORMATEX format, ChunkCompletedCallback callback); /** @@ -229,7 +230,7 @@ class WasapiPlayer { CComPtr clock; // The maximum number of frames that will fit in the buffer. UINT32 bufferFrames; - std::wstring deviceName; + std::wstring endpointId; WAVEFORMATEX format; ChunkCompletedCallback callback; PlayState playState = PlayState::stopped; @@ -246,9 +247,9 @@ class WasapiPlayer { bool isUsingPreferredDevice = false; }; -WasapiPlayer::WasapiPlayer(wchar_t* deviceName, WAVEFORMATEX format, +WasapiPlayer::WasapiPlayer(wchar_t* endpointId, WAVEFORMATEX format, ChunkCompletedCallback callback) -: deviceName(deviceName), format(format), callback(callback) { +: endpointId(endpointId), format(format), callback(callback) { wakeEvent = CreateEvent(nullptr, false, false, nullptr); } @@ -266,7 +267,7 @@ HRESULT WasapiPlayer::open(bool force) { } CComPtr device; isUsingPreferredDevice = false; - if (deviceName.empty()) { + if (endpointId.empty()) { hr = enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &device); } else { hr = getPreferredDevice(device); @@ -491,40 +492,39 @@ HRESULT WasapiPlayer::getPreferredDevice(CComPtr& preferredDevice) { if (FAILED(hr)) { return hr; } - CComPtr devices; - hr = enumerator->EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE, &devices); + CComPtr device; + hr = enumerator->GetDevice(endpointId.c_str(), &device); if (FAILED(hr)) { return hr; } - UINT count = 0; - devices->GetCount(&count); - for (UINT d = 0; d < count; ++d) { - CComPtr device; - hr = devices->Item(d, &device); - if (FAILED(hr)) { - return hr; - } - CComPtr props; - hr = device->OpenPropertyStore(STGM_READ, &props); - if (FAILED(hr)) { - return hr; - } - PROPVARIANT val; - hr = props->GetValue(PKEY_Device_FriendlyName, &val); - if (FAILED(hr)) { - return hr; - } - // WinMM device names are truncated to MAXPNAMELEN characters, including the - // null terminator. - constexpr size_t MAX_CHARS = MAXPNAMELEN - 1; - if (wcsncmp(val.pwszVal, deviceName.c_str(), MAX_CHARS) == 0) { - PropVariantClear(&val); - preferredDevice = std::move(device); - return S_OK; - } - PropVariantClear(&val); + + // We only want to use the device if it is plugged in and enabled. + DWORD state; + hr = device->GetState(&state); + if (FAILED(hr)) { + return hr; + } else if (state != DEVICE_STATE_ACTIVE) { + return E_NOTFOUND; } - return E_NOTFOUND; + + // We only want to use the device if it is an output device. + IMMEndpoint* endpoint; + hr = device->QueryInterface(IID_IMMEndpoint, (void**)&endpoint); + if (FAILED(hr)) { + return hr; + } + EDataFlow dataFlow; + hr = endpoint->GetDataFlow(&dataFlow); + if (FAILED(hr)) { + return hr; + } else if (dataFlow != eRender) { + return E_NOTFOUND; + } + preferredDevice = std::move(device); + endpoint->Release(); + device.Release(); + enumerator.Release(); + return S_OK; } bool WasapiPlayer::didPreferredDeviceBecomeAvailable() { @@ -532,7 +532,7 @@ bool WasapiPlayer::didPreferredDeviceBecomeAvailable() { // We're already using the preferred device. isUsingPreferredDevice || // A preferred device was not specified. - deviceName.empty() || + endpointId.empty() || // A device hasn't recently changed state. deviceStateChangeCount == notificationClient->getDeviceStateChangeCount() ) { @@ -673,7 +673,7 @@ HRESULT WasapiPlayer::disableCommunicationDucking(IMMDevice* device) { */ class SilencePlayer { public: - SilencePlayer(wchar_t* deviceName); + SilencePlayer(wchar_t* endpointId); HRESULT init(); // Play silence for the specified duration. void playFor(DWORD ms, float volume); @@ -698,8 +698,8 @@ class SilencePlayer { std::vector whiteNoiseData; }; -SilencePlayer::SilencePlayer(wchar_t* deviceName): -player(deviceName, getFormat(), nullptr), +SilencePlayer::SilencePlayer(wchar_t* endpointId): +player(endpointId, getFormat(), nullptr), whiteNoiseData( SILENCE_BYTES / ( sizeof(INT16) / sizeof(unsigned char) @@ -791,10 +791,10 @@ void SilencePlayer::terminate() { * WasapiPlayer or SilencePlayer, with the exception of wasPlay_startup. */ -WasapiPlayer* wasPlay_create(wchar_t* deviceName, WAVEFORMATEX format, +WasapiPlayer* wasPlay_create(wchar_t* endpointId, WAVEFORMATEX format, WasapiPlayer::ChunkCompletedCallback callback ) { - return new WasapiPlayer(deviceName, format, callback); + return new WasapiPlayer(endpointId, format, callback); } void wasPlay_destroy(WasapiPlayer* player) { @@ -855,9 +855,9 @@ HRESULT wasPlay_startup() { SilencePlayer* silence = nullptr; -HRESULT wasSilence_init(wchar_t* deviceName) { +HRESULT wasSilence_init(wchar_t* endpointId) { assert(!silence); - silence = new SilencePlayer(deviceName); + silence = new SilencePlayer(endpointId); return silence->init(); } diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 8f467fb7970..8a6a082dab1 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -13,7 +13,7 @@ #: provide an upgrade step (@see profileUpgradeSteps.py). An upgrade step does not need to be added when #: just adding a new element to (or removing from) the schema, only when old versions of the config #: (conforming to old schema versions) will not work correctly with the new schema. -latestSchemaVersion = 13 +latestSchemaVersion = 14 #: The configuration specification string #: @type: String @@ -41,7 +41,6 @@ includeCLDR = boolean(default=True) symbolDictionaries = string_list(default=list("cldr")) beepSpeechModePitch = integer(default=10000,min=50,max=11025) - outputDevice = string(default=default) autoLanguageSwitching = boolean(default=true) autoDialectSwitching = boolean(default=false) delayedCharacterDescriptions = boolean(default=false) @@ -55,6 +54,7 @@ # Audio settings [audio] + outputDevice = string(default=default) audioDuckingMode = integer(default=0) soundVolumeFollowsVoice = boolean(default=false) soundVolume = integer(default=100, min=0, max=100) diff --git a/source/config/profileUpgradeSteps.py b/source/config/profileUpgradeSteps.py index cf9e87c7177..d6e22123476 100644 --- a/source/config/profileUpgradeSteps.py +++ b/source/config/profileUpgradeSteps.py @@ -414,3 +414,60 @@ def upgradeConfigFrom_12_to_13(profile: ConfigObj) -> None: log.debug( f"Handled cldr value of {setting!r}. List is now: {profile['speech']['symbolDictionaries']}", ) + + +def upgradeConfigFrom_13_to_14(profile: ConfigObj): + """Set [audio][outputDevice] to the endpointID of [speech][outputDevice], and delete the latter.""" + try: + friendlyName = profile["speech"]["outputDevice"] + except KeyError: + log.debug("Output device not present in config. Taking no action.") + return + if friendlyName == "default": + log.debug("Output device is set to default. Not writing a new value to config.") + elif endpointId := _friendlyNameToEndpointId(friendlyName): + log.debug( + f"Best match for device with {friendlyName=} has {endpointId=}. Writing new value to config.", + ) + if "audio" not in profile: + profile["audio"] = {} + profile["audio"]["outputDevice"] = endpointId + else: + log.debug( + f"Could not find an audio output device with {friendlyName=}. Not writing a new value to config.", + ) + log.debug("Deleting old config value.") + del profile["speech"]["outputDevice"] + + +def _friendlyNameToEndpointId(friendlyName: str) -> str | None: + """Convert a device friendly name to an endpoint ID string. + + Since friendly names are not unique, there may be many devices on one system with the same friendly name. + As the order of devices in an IMMEndpointEnumerator is arbitrary, we cannot assume that the first device with a matching friendly name is the device the user wants. + We also can't guarantee that the device the user has selected is active, so we need to retrieve devices by state, in order from most to least preferable. + It is probably a safe bet that the device the user wants to use is either active or unplugged. + Thus, the preference order for states is: + 1. ACTIVE- The audio adapter that connects to the endpoint device is present and enabled. + In addition, if the endpoint device plugs into a jack on the adapter, then the endpoint device is plugged in. + 2. UNPLUGGED - The audio adapter that contains the jack for the endpoint device is present and enabled, but the endpoint device is not plugged into the jack. + 3. DISABLED - The user has disabled the device in the Windows multimedia control panel. + 4. NOTPRESENT - The audio adapter that connects to the endpoint device has been removed from the system, or the user has disabled the adapter device in Device Manager. + Within a state, if there is more than one device with the selected friendly name, we use the first one. + + :param friendlyName: Friendly name of the device to search for. + :return: Endpoint ID string of the best match device, or `None` if no device with a matching friendly name is available. + """ + from nvwave import _getOutputDevices + from pycaw.constants import DEVICE_STATE + + states = (DEVICE_STATE.ACTIVE, DEVICE_STATE.UNPLUGGED, DEVICE_STATE.DISABLED, DEVICE_STATE.NOTPRESENT) + for state in states: + try: + return next( + device for device in _getOutputDevices(stateMask=state) if device.friendlyName == friendlyName + ).id + except StopIteration: + # Proceed to the next device state. + continue + return None diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index af4779ba0f0..8f973b9932f 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -3041,17 +3041,15 @@ def makeSettings(self, settingsSizer: wx.BoxSizer) -> None: # Translators: This is the label for the select output device combo in NVDA audio settings. # Examples of an output device are default soundcard, usb headphones, etc. deviceListLabelText = _("Audio output &device:") - # The Windows Core Audio device enumeration does not have the concept of an ID for the default output device, so we have to insert something ourselves instead. - # Translators: Value to show when choosing to use the default audio output device. - deviceNames = (_("Default output device"), *nvwave.getOutputDeviceNames()) + self._deviceIds, deviceNames = zip(*nvwave._getOutputDevices(includeDefault=True)) self.deviceList = sHelper.addLabeledControl(deviceListLabelText, wx.Choice, choices=deviceNames) self.bindHelpEvent("SelectSynthesizerOutputDevice", self.deviceList) - selectedOutputDevice = config.conf["speech"]["outputDevice"] - if selectedOutputDevice == "default": + selectedOutputDevice = config.conf["audio"]["outputDevice"] + if selectedOutputDevice == config.conf.getConfigValidation(("audio", "outputDevice")).default: selection = 0 else: try: - selection = deviceNames.index(selectedOutputDevice) + selection = self._deviceIds.index(selectedOutputDevice) except ValueError: selection = 0 self.deviceList.SetSelection(selection) @@ -3176,13 +3174,10 @@ def _appendSoundSplitModesList(self, settingsSizerHelper: guiHelper.BoxSizerHelp self.soundSplitModesList.Select(0) def onSave(self): - # We already use "default" as the key in the config spec, so use it here as an alternative to Microsoft Sound Mapper. - selectedOutputDevice = ( - "default" if self.deviceList.GetSelection() == 0 else self.deviceList.GetStringSelection() - ) - if config.conf["speech"]["outputDevice"] != selectedOutputDevice: + selectedOutputDevice = self._deviceIds[self.deviceList.GetSelection()] + if config.conf["audio"]["outputDevice"] != selectedOutputDevice: # Synthesizer must be reload if output device changes - config.conf["speech"]["outputDevice"] = selectedOutputDevice + config.conf["audio"]["outputDevice"] = selectedOutputDevice currentSynth = getSynth() if not setSynth(currentSynth.name): _synthWarningDialog(currentSynth.name) diff --git a/source/nvwave.py b/source/nvwave.py index 0dc620374ac..3b56b3d441f 100644 --- a/source/nvwave.py +++ b/source/nvwave.py @@ -1,10 +1,10 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2007-2023 NV Access Limited, Aleksey Sadovoy, Cyrille Bougot, Peter Vágner, Babbage B.V., +# Copyright (C) 2007-2024 NV Access Limited, Aleksey Sadovoy, Cyrille Bougot, Peter Vágner, Babbage B.V., # Leonard de Ruijter, James Teh # This file is covered by the GNU General Public License. # See the file COPYING for more details. -"""Provides a simple Python interface to playing audio using the Windows multimedia waveOut functions, as well as other useful utilities.""" +"""Provides a simple Python interface to playing audio using the Windows Audio Session API (WASAPI), as well as other useful utilities.""" from collections.abc import Generator import threading @@ -89,19 +89,40 @@ class AudioPurpose(Enum): SOUNDS = auto() -def _getOutputDevices() -> Generator[tuple[str, str]]: - """Generator, yielding device ID and device Name in device ID order. - ..note: Depending on number of devices being fetched, this may take some time (~3ms) +class _AudioOutputDevice(typing.NamedTuple): + id: str + friendlyName: str + + +def _getOutputDevices( + *, + includeDefault: bool = False, + stateMask: DEVICE_STATE = DEVICE_STATE.ACTIVE, +) -> Generator[_AudioOutputDevice]: + """Generator, yielding device ID and device Name. + .. note:: Depending on number of devices being fetched, this may take some time (~3ms) + + :param includeDefault: Whether to include a value representing the system default output device in the generator, defaults to False. + .. note:: The ID of this device is **not** a valid mmdevice endpoint ID string, and is for internal use only. + The friendly name is **not** generated by the operating system, and it is highly unlikely that it will match any real output device. + :param state: What device states to include in the resultant generator, defaults to DEVICE_STATE.ACTIVE. + :return: Generator of :class:`_AudioOutputDevices` containing all enabled and present audio output devices on the system. """ + if includeDefault: + yield _AudioOutputDevice( + id=typing.cast(str, config.conf.getConfigValidation(("audio", "outputDevice")).default), + # Translators: Value to show when choosing to use the default audio output device. + friendlyName=_("Default output device"), + ) endpointCollection = AudioUtilities.GetDeviceEnumerator().EnumAudioEndpoints( EDataFlow.eRender.value, - DEVICE_STATE.ACTIVE.value, + stateMask.value, ) for i in range(endpointCollection.GetCount()): device = AudioUtilities.CreateDevice(endpointCollection.Item(i)) # This should never be None, but just to be sure if device is not None: - yield device.id, device.FriendlyName + yield _AudioOutputDevice(device.id, device.FriendlyName) else: continue @@ -194,7 +215,7 @@ def play(): channels=f.getnchannels(), samplesPerSec=f.getframerate(), bitsPerSample=f.getsampwidth() * 8, - outputDevice=config.conf["speech"]["outputDevice"], + outputDevice=config.conf["audio"]["outputDevice"], wantDucking=False, purpose=AudioPurpose.SOUNDS, ) @@ -247,7 +268,7 @@ class WasapiWavePlayer(garbageHandler.TrackedObject): #: Whether there is a pending stream idle check. _isIdleCheckPending: bool = False #: Use the default device, this is the configSpec default value. - DEFAULT_DEVICE_KEY = typing.cast(str, config.conf.getConfigValidation(("speech", "outputDevice")).default) + DEFAULT_DEVICE_KEY = typing.cast(str, config.conf.getConfigValidation(("audio", "outputDevice")).default) #: The silence output device, None if not initialized. _silenceDevice: typing.Optional[str] = None diff --git a/source/synthDriverHandler.py b/source/synthDriverHandler.py index 12553283b6d..1e4bb9b02c7 100644 --- a/source/synthDriverHandler.py +++ b/source/synthDriverHandler.py @@ -1,7 +1,7 @@ # 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) 2006-2023 NV Access Limited, Peter Vágner, Aleksey Sadovoy, +# Copyright (C) 2006-2024 NV Access Limited, Peter Vágner, Aleksey Sadovoy, # Joseph Lee, Arnold Loubriat, Leonard de Ruijter import pkgutil @@ -485,7 +485,7 @@ def setSynth(name: Optional[str], isFallback: bool = False): log.error(f"setSynth failed for {name}", exc_info=True) if _curSynth is not None: - _audioOutputDevice = config.conf["speech"]["outputDevice"] + _audioOutputDevice = config.conf["audio"]["outputDevice"] if not isFallback: config.conf["speech"]["synth"] = name log.info(f"Loaded synthDriver {_curSynth.name}") @@ -530,7 +530,7 @@ def handlePostConfigProfileSwitch(resetSpeechIfNeeded=True): @type resetSpeechIfNeeded: bool """ conf = config.conf["speech"] - if conf["synth"] != _curSynth.name or conf["outputDevice"] != _audioOutputDevice: + if conf["synth"] != _curSynth.name or config.conf["audio"]["outputDevice"] != _audioOutputDevice: if resetSpeechIfNeeded: # Reset the speech queues as we are now going to be using a new synthesizer with entirely separate state. import speech diff --git a/source/synthDrivers/_espeak.py b/source/synthDrivers/_espeak.py index 1df6ca7dc9f..442e068c7ee 100755 --- a/source/synthDrivers/_espeak.py +++ b/source/synthDrivers/_espeak.py @@ -1,6 +1,6 @@ # -*- coding: UTF-8 -*- # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2007-2020 NV Access Limited, Peter Vágner +# Copyright (C) 2007-2024 NV Access Limited, Peter Vágner # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -382,7 +382,7 @@ def initialize(indexCallback=None): channels=1, samplesPerSec=sampleRate, bitsPerSample=16, - outputDevice=config.conf["speech"]["outputDevice"], + outputDevice=config.conf["audio"]["outputDevice"], ) onIndexReached = indexCallback espeakDLL.espeak_SetSynthCallback(callback) diff --git a/source/synthDrivers/oneCore.py b/source/synthDrivers/oneCore.py index 1d5b31fd8e7..7fb64ad331b 100644 --- a/source/synthDrivers/oneCore.py +++ b/source/synthDrivers/oneCore.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2016-2021 Tyler Spivey, NV Access Limited, James Teh, Leonard de Ruijter +# Copyright (C) 2016-2024 Tyler Spivey, NV Access Limited, James Teh, Leonard de Ruijter # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -265,7 +265,7 @@ def _maybeInitPlayer(self, wav): channels=wav.getnchannels(), samplesPerSec=samplesPerSec, bitsPerSample=bytesPerSample * 8, - outputDevice=config.conf["speech"]["outputDevice"], + outputDevice=config.conf["audio"]["outputDevice"], ) def terminate(self): diff --git a/source/synthDrivers/sapi4.py b/source/synthDrivers/sapi4.py index b970dd11ba2..ea28eae3ed9 100755 --- a/source/synthDrivers/sapi4.py +++ b/source/synthDrivers/sapi4.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2006-2023 NV Access Limited, Leonard de Ruijter +# Copyright (C) 2006-2024 NV Access Limited, Leonard de Ruijter # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -233,7 +233,7 @@ def _set_voice(self, val): raise ValueError("no such mode: %s" % val) self._currentMode = mode self._ttsAudio = CoCreateInstance(CLSID_MMAudioDest, IAudioMultiMediaDevice) - self._ttsAudio.DeviceNumSet(nvwave.outputDeviceNameToID(config.conf["speech"]["outputDevice"], True)) + self._ttsAudio.DeviceNumSet(nvwave.outputDeviceNameToID(config.conf["audio"]["outputDevice"], True)) self._ttsCentral = POINTER(ITTSCentralW)() self._ttsEngines.Select(self._currentMode.gModeID, byref(self._ttsCentral), self._ttsAudio) self._ttsAttrs = self._ttsCentral.QueryInterface(ITTSAttributes) diff --git a/source/synthDrivers/sapi5.py b/source/synthDrivers/sapi5.py index c39b35406b4..c025beb2aa1 100644 --- a/source/synthDrivers/sapi5.py +++ b/source/synthDrivers/sapi5.py @@ -1,6 +1,6 @@ # -*- coding: UTF-8 -*- # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2006-2021 NV Access Limited, Peter Vágner, Aleksey Sadovoy +# Copyright (C) 2006-2024 NV Access Limited, Peter Vágner, Aleksey Sadovoy # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -204,10 +204,16 @@ def _initTts(self, voice=None): # Therefore, set the voice before setting the audio output. # Otherwise, we will get poor speech quality in some cases. self.tts.voice = voice - for audioOutput in self.tts.GetAudioOutputs(): - if audioOutput.GetDescription() == config.conf["speech"]["outputDevice"]: - self.tts.audioOutput = audioOutput - break + # SAPI5 automatically selects the system default audio device, so there's no use doing work if the user has selected to use the system default. + # Besides, our default value is not a valid endpoint ID. + if (outputDevice := config.conf["audio"]["outputDevice"]) != config.conf.getConfigValidation( + ("audio", "outputDevice"), + ).default: + for audioOutput in self.tts.GetAudioOutputs(): + # SAPI's audio output IDs are registry keys. It seems that the final path segment is the endpoint ID. + if audioOutput.Id.endswith(outputDevice): + self.tts.audioOutput = audioOutput + break self._eventsConnection = comtypes.client.GetEvents(self.tts, SapiSink(weakref.ref(self))) self.tts.EventInterests = ( SpeechVoiceEvents.StartInputStream | SpeechVoiceEvents.Bookmark | SpeechVoiceEvents.EndInputStream diff --git a/source/tones.py b/source/tones.py index 3382c301f5f..1e9286a56ae 100644 --- a/source/tones.py +++ b/source/tones.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2007-2023 NV Access Limited, Aleksey Sadovoy, Leonard de Ruijter, Babbage B.V. +# Copyright (C) 2007-2024 NV Access Limited, Aleksey Sadovoy, Leonard de Ruijter, Babbage B.V. # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -24,7 +24,7 @@ def initialize(): channels=2, samplesPerSec=int(SAMPLE_RATE), bitsPerSample=16, - outputDevice=config.conf["speech"]["outputDevice"], + outputDevice=config.conf["audio"]["outputDevice"], wantDucking=False, purpose=nvwave.AudioPurpose.SOUNDS, ) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index dbe12632700..a20023d3dc3 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -2,14 +2,17 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. # Copyright (C) 2022-2024 NV Access Limited, Cyrille Bougot, Leonard de Ruijter + +from collections.abc import Callable, Generator import enum import typing import unittest -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import io import configobj import configobj.validate +from pycaw.constants import DEVICE_STATE from config import ( AggregatedSection, @@ -24,10 +27,12 @@ BoolFlag, ) from config.profileUpgradeSteps import ( + _friendlyNameToEndpointId, _upgradeConfigFrom_8_to_9_lineIndent, _upgradeConfigFrom_8_to_9_cellBorders, _upgradeConfigFrom_8_to_9_showMessages, _upgradeConfigFrom_8_to_9_tetherTo, + upgradeConfigFrom_13_to_14, upgradeConfigFrom_9_to_10, upgradeConfigFrom_11_to_12, ) @@ -42,6 +47,7 @@ from utils.displayString import ( DisplayStringEnum, ) +from nvwave import _AudioOutputDevice class Config_FeatureFlagEnums_getAvailableEnums(unittest.TestCase): @@ -910,3 +916,130 @@ def test_updateToDifferentValue(self): self.testSection["someBool"] = False # Since we set someBool to a different value, update the profile. self.assertEqual(self.profile, {"someBool": False}) + + +_DevicesT: typing.TypeAlias = dict[DEVICE_STATE, list[_AudioOutputDevice]] + + +def getOutputDevicesFactory( + devices: _DevicesT, +) -> Callable[[DEVICE_STATE], Generator[_AudioOutputDevice]]: + """Create a callable that can be used to patch nvwave._getOutputDevices.""" + + def getOutputDevices(stateMask: DEVICE_STATE, **kw) -> Generator[_AudioOutputDevice]: + yield from devices.get(stateMask, []) + + return getOutputDevices + + +class Config_ProfileUpgradeSteps_FriendlyNameToEndpointId(unittest.TestCase): + DEFAULT_DEVICES: _DevicesT = { + DEVICE_STATE.ACTIVE: [_AudioOutputDevice("id1", "Device 1")], + DEVICE_STATE.UNPLUGGED: [_AudioOutputDevice("id2", "Device 2")], + DEVICE_STATE.DISABLED: [_AudioOutputDevice("id3", "Device 3")], + DEVICE_STATE.NOTPRESENT: [_AudioOutputDevice("id4", "Device 4")], + } + + def test_noDuplicates(self): + """Test that mapping from a friendly name to an endpoint ID works as expected when there are no duplicate friendly names.""" + devices = self.DEFAULT_DEVICES + for devicesState, devicesInState in devices.items(): + for device in devicesInState: + with self.subTest(id=device.id, FriendlyName=device.friendlyName, state=devicesState): + self.performTest(*device, devices) + + def test_orderOfPrecedence(self): + """Test that, when there are devices with duplicate names in different states, the one with the preferred state is returned.""" + FRIENDLY_NAME = "Device friendly name" + devices: _DevicesT = { + DEVICE_STATE.ACTIVE: [_AudioOutputDevice("idA", FRIENDLY_NAME)], + DEVICE_STATE.DISABLED: [_AudioOutputDevice("idD", FRIENDLY_NAME)], + DEVICE_STATE.NOTPRESENT: [_AudioOutputDevice("idN", FRIENDLY_NAME)], + DEVICE_STATE.UNPLUGGED: [_AudioOutputDevice("idU", FRIENDLY_NAME)], + } + with self.subTest("Friendly name is active"): + self.performTest(*devices[DEVICE_STATE.ACTIVE][0], devices) + devices[DEVICE_STATE.ACTIVE].pop() + with self.subTest("Friendly name is unplugged"): + self.performTest(*devices[DEVICE_STATE.UNPLUGGED][0], devices) + devices[DEVICE_STATE.UNPLUGGED].pop() + with self.subTest("Friendly name is disabled"): + self.performTest(*devices[DEVICE_STATE.DISABLED][0], devices) + devices[DEVICE_STATE.DISABLED].pop() + with self.subTest("Friendly name is notpresent"): + self.performTest(*devices[DEVICE_STATE.NOTPRESENT][0], devices) + devices[DEVICE_STATE.NOTPRESENT].pop() + + def test_nonexistant(self): + """Test that attempting a match for a friendly name that no device has returns None.""" + devices = self.DEFAULT_DEVICES + self.performTest(friendlyName="Nonexistant", expectedId=None, devices=devices) + + def test_noDevices(self): + """Test that attempting a match for a friendly name that no device has returns None.""" + devices: _DevicesT = {} + self.performTest(friendlyName="Anything", expectedId=None, devices=devices) + + def performTest(self, expectedId: str | None, friendlyName: str, devices: _DevicesT): + """Patch nvwave._getOutputDevices to return what we tell it, then test that friendlyNameToEndpointId returns the correct ID given a friendly name. + The odd order of arguments is so you can directly unpack an AudioOutputDevice. + """ + with patch( + "nvwave._getOutputDevices", + autospec=True, + side_effect=getOutputDevicesFactory(devices), + ): + self.assertEqual(_friendlyNameToEndpointId(friendlyName), expectedId) + + +class Config_upgradeProfileSteps_upgradeProfileFrom_13_to_14(unittest.TestCase): + def setUp(self): + devices: _DevicesT = { + DEVICE_STATE.ACTIVE: [_AudioOutputDevice("id", "Friendly name")], + } + self._getOutputDevicesPatcher = patch( + "nvwave._getOutputDevices", + autospec=True, + side_effect=getOutputDevicesFactory(devices), + ) + self._getOutputDevicesPatcher.start() + super().setUp() + + def tearDown(self): + self._getOutputDevicesPatcher.stop() + super().tearDown() + + def test_outputDeviceNotSet(self): + """Test that upgrading with no output device set works.""" + configString = "" + profile = _loadProfile(configString) + upgradeConfigFrom_13_to_14(profile) + with self.assertRaises(KeyError): + profile["speech"]["outputDevice"] + with self.assertRaises(KeyError): + profile["audio"]["outputDevice"] + + def test_outputDeviceFound(self): + """Test that upgrading the profile correctly creates the new key and value.""" + configString = """ + [speech] + outputDevice=Friendly name + """ + profile = _loadProfile(configString) + upgradeConfigFrom_13_to_14(profile) + self.assertEqual(profile["audio"]["outputDevice"], "id") + with self.assertRaises(KeyError): + profile["speech"]["outputDevice"] + + def test_outputDeviceNotFound(self): + """Test that upgrading the profile with an unidentifiable device doesn't create a new entry.""" + configString = """ + [speech] + outputDevice=Nonexistant device + """ + profile = _loadProfile(configString) + upgradeConfigFrom_13_to_14(profile) + with self.assertRaises(KeyError): + profile["speech"]["outputDevice"] + with self.assertRaises(KeyError): + profile["audio"]["outputDevice"] diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index fa7846e2977..6838cfedabd 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -154,6 +154,8 @@ As the NVDA update check URL is now configurable directly within NVDA, no replac * `gui.settingsDialogs.AdvancedPanelControls.wasapiComboBox` has been removed. * The `WASAPI` key has been removed from the `audio` section of the config spec. * The output from `nvwave.outputDeviceNameToID`, and input to `nvwave.outputDeviceIDToName` are now string identifiers. + * The configuration key `config.conf["speech"]["outputDevice"]` has been removed. + It has been replaced by `config.conf["audio"]["outputDevice"]`, which stores a Windows core audio endpoint device ID. (#17547) * The `outputDevice` parameter to `WasapiWavePlayer.__init__` should now only be passed string arguments. * The deprecated `closeWhenIdle` and `buffered` parameters to `WasapiWavePlayer.__init__` have been removed. * In `NVDAObjects.window.scintilla.ScintillaTextInfo`, if no text is selected, the `collapse` method is overriden to expand to line if the `end` parameter is set to `True` (#17431, @nvdaes) From 8c771f0bd8989a50e91d7c38674542b3907efdd3 Mon Sep 17 00:00:00 2001 From: Winston Chen Date: Mon, 6 Jan 2025 16:45:15 -0800 Subject: [PATCH 3/8] Remove problematic euro symbol in fi (#17578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Link to issue number: #17471 Summary of the issue: Euro complexSymbol in fi locale causing euro currency numbers to not be read in microsoft excel. Description of user facing changes Affects the reading of euro currency numbers in microsoft excel. The behavior should be in line with that of some common locales such as english and spanish where this regex is not present. Description of development approach Followed the advice of seanbudd and removed the regex, after spot-checking to verify that this is likely a finnish-only regex and behavior. --- source/locale/fi/symbols.dic | 1 - 1 file changed, 1 deletion(-) diff --git a/source/locale/fi/symbols.dic b/source/locale/fi/symbols.dic index a032b2f43a9..96cd749ace5 100644 --- a/source/locale/fi/symbols.dic +++ b/source/locale/fi/symbols.dic @@ -18,7 +18,6 @@ decimal comma (? Date: Wed, 8 Jan 2025 09:01:11 +1100 Subject: [PATCH 4/8] Fix profile upgrade from 13 to 14 (#17589) Fixes #17587 Fixes #17588 Summary of the issue: The profile upgrade steps introduced in #17547 would cause NVDA to crash, and the user's configuration to be reset, if the user had an audio output device set in their NVDA configuration. Description of user facing changes Upgrading from configuration version 13 to 14 no longer crashes NVDA and resets the user's configuration. Description of development approach The issue was caused because `config.profileUpgradeSteps.upgradeConfigFrom_13_to_14` attempts to import `nvwave`, which, through its own imports, eventually attempts to use gettext. Since gettext has not yet been initialised, this raises an exception. As we are so early in NVDA initialisation, this exception causes the rest of NVDA's initialisation to fail critically. To resolve this, `_getOutputDevices` and `_AudioOutputDevice` have been moved to the new `utils.mmdevice`. I initially thought to move them to `audio.utils`, but initialisation of `audio` runs into the same problem. Testing strategy: Checked out `a7fa0d6`, made sure the config version was `13`, ran NVDA and selected an audio output device. Checked out `fixProfileUpgrade`, ran NVDA, and ensured that it ran as expected, the profile was correctly updated, there was no loss of settings, and the correct output device was in use. --- source/config/profileUpgradeSteps.py | 2 +- source/gui/settingsDialogs.py | 4 +-- source/nvwave.py | 42 ++---------------------- source/utils/mmdevice.py | 49 ++++++++++++++++++++++++++++ tests/unit/test_config.py | 10 +++--- 5 files changed, 59 insertions(+), 48 deletions(-) create mode 100644 source/utils/mmdevice.py diff --git a/source/config/profileUpgradeSteps.py b/source/config/profileUpgradeSteps.py index d6e22123476..7eb4c31a7c5 100644 --- a/source/config/profileUpgradeSteps.py +++ b/source/config/profileUpgradeSteps.py @@ -458,7 +458,7 @@ def _friendlyNameToEndpointId(friendlyName: str) -> str | None: :param friendlyName: Friendly name of the device to search for. :return: Endpoint ID string of the best match device, or `None` if no device with a matching friendly name is available. """ - from nvwave import _getOutputDevices + from utils.mmdevice import _getOutputDevices from pycaw.constants import DEVICE_STATE states = (DEVICE_STATE.ACTIVE, DEVICE_STATE.UNPLUGGED, DEVICE_STATE.DISABLED, DEVICE_STATE.NOTPRESENT) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 8f973b9932f..793f6e04f32 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -20,6 +20,7 @@ import wx from NVDAState import WritePaths +from utils import mmdevice from vision.providerBase import VisionEnhancementProviderSettings from wx.lib.expando import ExpandoTextCtrl import wx.lib.newevent @@ -46,7 +47,6 @@ import gui.contextHelp import globalVars from logHandler import log -import nvwave import audio import audioDucking import queueHandler @@ -3041,7 +3041,7 @@ def makeSettings(self, settingsSizer: wx.BoxSizer) -> None: # Translators: This is the label for the select output device combo in NVDA audio settings. # Examples of an output device are default soundcard, usb headphones, etc. deviceListLabelText = _("Audio output &device:") - self._deviceIds, deviceNames = zip(*nvwave._getOutputDevices(includeDefault=True)) + self._deviceIds, deviceNames = zip(*mmdevice._getOutputDevices(includeDefault=True)) self.deviceList = sHelper.addLabeledControl(deviceListLabelText, wx.Choice, choices=deviceNames) self.bindHelpEvent("SelectSynthesizerOutputDevice", self.deviceList) selectedOutputDevice = config.conf["audio"]["outputDevice"] diff --git a/source/nvwave.py b/source/nvwave.py index 3b56b3d441f..59ce04d6fd8 100644 --- a/source/nvwave.py +++ b/source/nvwave.py @@ -6,7 +6,6 @@ """Provides a simple Python interface to playing audio using the Windows Audio Session API (WASAPI), as well as other useful utilities.""" -from collections.abc import Generator import threading import typing from typing import ( @@ -40,7 +39,8 @@ import core import globalVars from pycaw.utils import AudioUtilities -from pycaw.constants import EDataFlow, DEVICE_STATE + +from utils.mmdevice import _getOutputDevices __all__ = ( @@ -89,44 +89,6 @@ class AudioPurpose(Enum): SOUNDS = auto() -class _AudioOutputDevice(typing.NamedTuple): - id: str - friendlyName: str - - -def _getOutputDevices( - *, - includeDefault: bool = False, - stateMask: DEVICE_STATE = DEVICE_STATE.ACTIVE, -) -> Generator[_AudioOutputDevice]: - """Generator, yielding device ID and device Name. - .. note:: Depending on number of devices being fetched, this may take some time (~3ms) - - :param includeDefault: Whether to include a value representing the system default output device in the generator, defaults to False. - .. note:: The ID of this device is **not** a valid mmdevice endpoint ID string, and is for internal use only. - The friendly name is **not** generated by the operating system, and it is highly unlikely that it will match any real output device. - :param state: What device states to include in the resultant generator, defaults to DEVICE_STATE.ACTIVE. - :return: Generator of :class:`_AudioOutputDevices` containing all enabled and present audio output devices on the system. - """ - if includeDefault: - yield _AudioOutputDevice( - id=typing.cast(str, config.conf.getConfigValidation(("audio", "outputDevice")).default), - # Translators: Value to show when choosing to use the default audio output device. - friendlyName=_("Default output device"), - ) - endpointCollection = AudioUtilities.GetDeviceEnumerator().EnumAudioEndpoints( - EDataFlow.eRender.value, - stateMask.value, - ) - for i in range(endpointCollection.GetCount()): - device = AudioUtilities.CreateDevice(endpointCollection.Item(i)) - # This should never be None, but just to be sure - if device is not None: - yield _AudioOutputDevice(device.id, device.FriendlyName) - else: - continue - - def getOutputDeviceNames() -> list[str]: """Obtain the names of all audio output devices on the system. :return: The names of all output devices on the system. diff --git a/source/utils/mmdevice.py b/source/utils/mmdevice.py new file mode 100644 index 00000000000..25f9d3ec637 --- /dev/null +++ b/source/utils/mmdevice.py @@ -0,0 +1,49 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2025 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +from collections.abc import Generator +from typing import NamedTuple, cast + +import config +from pycaw.constants import DEVICE_STATE, EDataFlow +from pycaw.utils import AudioUtilities + + +class _AudioOutputDevice(NamedTuple): + id: str + friendlyName: str + + +def _getOutputDevices( + *, + includeDefault: bool = False, + stateMask: DEVICE_STATE = DEVICE_STATE.ACTIVE, +) -> Generator[_AudioOutputDevice]: + """Generator, yielding device ID and device Name. + .. note:: Depending on number of devices being fetched, this may take some time (~3ms) + + :param includeDefault: Whether to include a value representing the system default output device in the generator, defaults to False. + .. note:: The ID of this device is **not** a valid mmdevice endpoint ID string, and is for internal use only. + The friendly name is **not** generated by the operating system, and it is highly unlikely that it will match any real output device. + :param state: What device states to include in the resultant generator, defaults to DEVICE_STATE.ACTIVE. + :return: Generator of :class:`_AudioOutputDevices` containing all enabled and present audio output devices on the system. + """ + if includeDefault: + yield _AudioOutputDevice( + id=cast(str, config.conf.getConfigValidation(("audio", "outputDevice")).default), + # Translators: Value to show when choosing to use the default audio output device. + friendlyName=_("Default output device"), + ) + endpointCollection = AudioUtilities.GetDeviceEnumerator().EnumAudioEndpoints( + EDataFlow.eRender.value, + stateMask.value, + ) + for i in range(endpointCollection.GetCount()): + device = AudioUtilities.CreateDevice(endpointCollection.Item(i)) + # This should never be None, but just to be sure + if device is not None: + yield _AudioOutputDevice(device.id, device.FriendlyName) + else: + continue diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index a20023d3dc3..42ae74214be 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -47,7 +47,7 @@ from utils.displayString import ( DisplayStringEnum, ) -from nvwave import _AudioOutputDevice +from utils.mmdevice import _AudioOutputDevice class Config_FeatureFlagEnums_getAvailableEnums(unittest.TestCase): @@ -924,7 +924,7 @@ def test_updateToDifferentValue(self): def getOutputDevicesFactory( devices: _DevicesT, ) -> Callable[[DEVICE_STATE], Generator[_AudioOutputDevice]]: - """Create a callable that can be used to patch nvwave._getOutputDevices.""" + """Create a callable that can be used to patch utils.mmdevice._getOutputDevices.""" def getOutputDevices(stateMask: DEVICE_STATE, **kw) -> Generator[_AudioOutputDevice]: yield from devices.get(stateMask, []) @@ -981,11 +981,11 @@ def test_noDevices(self): self.performTest(friendlyName="Anything", expectedId=None, devices=devices) def performTest(self, expectedId: str | None, friendlyName: str, devices: _DevicesT): - """Patch nvwave._getOutputDevices to return what we tell it, then test that friendlyNameToEndpointId returns the correct ID given a friendly name. + """Patch utils.mmdevice._getOutputDevices to return what we tell it, then test that friendlyNameToEndpointId returns the correct ID given a friendly name. The odd order of arguments is so you can directly unpack an AudioOutputDevice. """ with patch( - "nvwave._getOutputDevices", + "utils.mmdevice._getOutputDevices", autospec=True, side_effect=getOutputDevicesFactory(devices), ): @@ -998,7 +998,7 @@ def setUp(self): DEVICE_STATE.ACTIVE: [_AudioOutputDevice("id", "Friendly name")], } self._getOutputDevicesPatcher = patch( - "nvwave._getOutputDevices", + "utils.mmdevice._getOutputDevices", autospec=True, side_effect=getOutputDevicesFactory(devices), ) From 8cc88eae4df5de34ceef52b47094d206b8602312 Mon Sep 17 00:00:00 2001 From: Jani Kinnunen Date: Wed, 8 Jan 2025 01:23:13 +0200 Subject: [PATCH 5/8] Updated symbols for fi (#17595) Now that the incorrect regular expression for the euro symbol has been removed, the corresponding euro symbol line in the symbols block is no longer needed. Additionally, the interpretation of the line feed and carriage return characters has been modified so that the Finnish Mikropuhe speech synthesizer can pause at those points when say all is used. Description of user facing changes Removed an unnecessary line related to the euro symbol from the symbols block Changed the interpretation of line feed (\n) and carriage return (\r) characters --- source/locale/fi/symbols.dic | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/source/locale/fi/symbols.dic b/source/locale/fi/symbols.dic index 96cd749ace5..d6b6f9b5a26 100644 --- a/source/locale/fi/symbols.dic +++ b/source/locale/fi/symbols.dic @@ -41,7 +41,6 @@ in-word ' heitto all norep negative number miinus none norep percent prosenttia none norep dates all always -euro euroa all norep thousand separators all always between-words - viiva all norep degrees astetta none norep @@ -51,9 +50,9 @@ degrees-fahrenheit astetta faarenhaittia none norep # Whitespace \0 tyhjä char # nollamerkki \t sisennys -\n rivinvaihto char +\n rivinvaihto char always \f sivunvaihto none -\r rivinvaihto char +\r uusi rivi char always väli char   väli char # non-breaking space @@ -64,7 +63,7 @@ degrees-fahrenheit astetta faarenhaittia none norep \# risu some $ dollari all norep £ punta all norep -€ euro all norep +€ euroa all norep ¢ sentti all norep ¥ jeni all norep ₹ rupia some norep From 1edb075669543500b4cfe3507bc334cc71ba7c6b Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter <3049216+LeonarddeR@users.noreply.github.com> Date: Wed, 8 Jan 2025 00:24:11 +0100 Subject: [PATCH 6/8] Fix bug in bdDetect device registration affecting Albatross (#17585) Fixes #17537 (comment) Summary of the issue: In #17537, I introduced aa new addUsbDevice method on DriverRegistrar, but it had parameter ordering wrong. Description of user facing changes Albatross detection should work again. Description of development approach Fixed order. I added a bunch of unit tests to avoid this in the future. --- source/bdDetect.py | 11 ++- .../brailleDisplayDrivers/albatross/driver.py | 2 +- tests/unit/test_bdDetect.py | 70 ++++++++++++++++++- 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/source/bdDetect.py b/source/bdDetect.py index 03cb71ef710..f65794c921b 100644 --- a/source/bdDetect.py +++ b/source/bdDetect.py @@ -741,7 +741,9 @@ def addUsbDevice( ) devs = self._getDriverDict() driverUsb = devs[CommunicationType.USB] - driverUsb.add(_UsbDeviceRegistryEntry(type, id, useAsFallback, matchFunc)) + driverUsb.add( + _UsbDeviceRegistryEntry(id=id, type=type, useAsFallback=useAsFallback, matchFunc=matchFunc), + ) def addUsbDevices( self, @@ -774,7 +776,12 @@ def addUsbDevices( ) devs = self._getDriverDict() driverUsb = devs[CommunicationType.USB] - driverUsb.update((_UsbDeviceRegistryEntry(id, type, useAsFallback, matchFunc) for id in ids)) + driverUsb.update( + ( + _UsbDeviceRegistryEntry(id=id, type=type, useAsFallback=useAsFallback, matchFunc=matchFunc) + for id in ids + ) + ) def addBluetoothDevices(self, matchFunc: MatchFuncT): """Associate Bluetooth HID or COM ports with the driver on this instance. diff --git a/source/brailleDisplayDrivers/albatross/driver.py b/source/brailleDisplayDrivers/albatross/driver.py index 55a8568268e..f1facc23ae4 100644 --- a/source/brailleDisplayDrivers/albatross/driver.py +++ b/source/brailleDisplayDrivers/albatross/driver.py @@ -1,7 +1,7 @@ # 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) 2023 NV Access Limited, Burman's Computer and Education Ltd. +# Copyright (C) 2023-25 NV Access Limited, Burman's Computer and Education Ltd., Leonard de Ruijter """Main code for Tivomatic Caiku Albatross braille display driver. Communication with display is done here. See class L{BrailleDisplayDriver} diff --git a/tests/unit/test_bdDetect.py b/tests/unit/test_bdDetect.py index 7a85583d376..478f7b6a15e 100644 --- a/tests/unit/test_bdDetect.py +++ b/tests/unit/test_bdDetect.py @@ -1,7 +1,7 @@ # 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) 2023 NV Access Limited, Babbage B.V., Leonard de Ruijter +# Copyright (C) 2023-25 NV Access Limited, Babbage B.V., Leonard de Ruijter """Unit tests for the bdDetect module.""" @@ -31,3 +31,71 @@ def test_scanForDevices(self): shouldStopEvaluator=lambda detector: detector is None, ) self.assertTrue(success) + + +class TestDriverRegistration(unittest.TestCase): + """A test for driver device registration.""" + + def tearDown(self): + bdDetect._driverDevices.clear() + + def test_addUsbDevice(self): + """Test adding a USB device.""" + from brailleDisplayDrivers import albatross + + registrar = bdDetect.DriverRegistrar(albatross.BrailleDisplayDriver.name) + + def matchFunc(match: bdDetect.DeviceMatch) -> bool: + return match.deviceInfo.get("busReportedDeviceDescription") == albatross.driver.BUS_DEVICE_DESC + + registrar.addUsbDevice( + bdDetect.ProtocolType.SERIAL, + albatross.driver.VID_AND_PID, + matchFunc=matchFunc, + ) + expected = bdDetect._UsbDeviceRegistryEntry( + albatross.driver.VID_AND_PID, + bdDetect.ProtocolType.SERIAL, + matchFunc=matchFunc, + ) + self.assertIn(expected, registrar._getDriverDict().get(bdDetect.CommunicationType.USB)) + + def test_addUsbDevices(self): + """Test adding multiple USB devices.""" + from brailleDisplayDrivers import albatross + + registrar = bdDetect.DriverRegistrar(albatross.BrailleDisplayDriver.name) + + def matchFunc(match: bdDetect.DeviceMatch) -> bool: + return match.deviceInfo.get("busReportedDeviceDescription") == albatross.driver.BUS_DEVICE_DESC + + fakeVidAndPid = "VID_0403&PID_6002" + registrar.addUsbDevices( + bdDetect.ProtocolType.SERIAL, + {albatross.driver.VID_AND_PID, fakeVidAndPid}, + matchFunc=matchFunc, + ) + expected = bdDetect._UsbDeviceRegistryEntry( + albatross.driver.VID_AND_PID, + bdDetect.ProtocolType.SERIAL, + matchFunc=matchFunc, + ) + self.assertIn(expected, registrar._getDriverDict().get(bdDetect.CommunicationType.USB)) + expected2 = bdDetect._UsbDeviceRegistryEntry( + fakeVidAndPid, + bdDetect.ProtocolType.SERIAL, + matchFunc=matchFunc, + ) + self.assertIn(expected2, registrar._getDriverDict().get(bdDetect.CommunicationType.USB)) + + def test_addBluetoothDevices(self): + """Test adding a fake Bluetooth match func.""" + from brailleDisplayDrivers import albatross + + registrar = bdDetect.DriverRegistrar(albatross.BrailleDisplayDriver.name) + + def matchFunc(match: bdDetect.DeviceMatch) -> bool: + return True + + registrar.addBluetoothDevices(matchFunc) + self.assertEqual(registrar._getDriverDict().get(bdDetect.CommunicationType.BLUETOOTH), matchFunc) From bd45d8e069ecaa3871f2d382dda0ccf3b43b30a9 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 8 Jan 2025 14:55:52 +1100 Subject: [PATCH 7/8] Message Dialog API take 2 (#17582) Closes #13007 Closes #12344 Closes #12353 Summary of the issue: This is the second PR to merge the message dialog API into NVDA. The previous merge was at `f437723`. See #17304 for full implementation details. The original PR was reverted by #17561, due to #17553 and #17560. Description of user facing changes This PR does not, in itself, introduce any end-user facing changes. However, it lays the groundwork for closing a number of issues. Tasks - [x] Fix the COM registration fixing tool (#17560) - [x] Fix NVDA updates (#17553) Description of development approach Restored `gui.nvdaControls.MessageDialog` inheriting from `ContextHelpMixin`, which was accidentally lost. Changed `updateCheck.UpdateAskInstallDialog.onUpdateButton` and `onPostponeButton` to simply end the modal, returning the appropriate code. Added a static method to `UpdateAskInstallDialog` which returns a callback function which, when given the return code from `UpdateAskInstallDialog`, performs the appropriate action. Added a property method to generate this callback function given the attributes of the instance on which it is called. Testing strategy: Created a portable copy of NVDA (`scons dist`), and tested running the CRFT: - Running and granting permission - works as expected - Running and denying permission - works as expected - Cancelling - works as expected Added `updateVersionType="snapshot:alpha" to `source/versionInfo.py`, and tested updating with NVDA running from source: 1. Attempt updating via NVDA menu -> Help -> Check for update... -> Download update -> Update - works as expected 2. Update by downloading then postponing update, then exit NVDA -> Install pending update a. Without incompatible updates installed - works as expected b. With incompatible updates installed - works as expected 3. Update by downloading then postponing update, restarting NVDA, then NVDA menu -> Install pending update - works as expected 4. Update by downloading then postponing update, restarting NVDA, then accepting the prompt to install the update - works as expected --- .../dev/developerGuide/developerGuide.md | 230 ++++ requirements.txt | 2 + source/documentationUtils.py | 6 +- source/gui/__init__.py | 31 +- source/gui/blockAction.py | 44 +- source/gui/exit.py | 2 +- source/gui/guiHelper.py | 53 + source/gui/message.py | 1176 ++++++++++++++++- source/gui/nvdaControls.py | 181 +-- source/updateCheck.py | 110 +- .../screenCurtain.py | 8 +- tests/system/robot/startupShutdownNVDA.robot | 4 +- tests/unit/test_messageDialog.py | 969 ++++++++++++++ user_docs/en/changes.md | 8 + 14 files changed, 2594 insertions(+), 230 deletions(-) create mode 100644 tests/unit/test_messageDialog.py diff --git a/projectDocs/dev/developerGuide/developerGuide.md b/projectDocs/dev/developerGuide/developerGuide.md index b471171e2df..be23ea20c4f 100644 --- a/projectDocs/dev/developerGuide/developerGuide.md +++ b/projectDocs/dev/developerGuide/developerGuide.md @@ -1445,3 +1445,233 @@ Please see the `EventExtensionPoints` class documentation for more information, |`Action` |`post_reviewMove` |the position of the review cursor has changed.| |`Action` |`post_mouseMove` |the mouse has moved.| |`Action` |`post_coreCycle` |the end of each core cycle has been reached.| + +## Communicating with the user + +### The message dialog API + +The message dialog API provides a flexible way of presenting interactive messages to the user. +The messages are highly customisable, with options to change icons and sounds, button labels, return values, and close behaviour, as well as to attach your own callbacks. + +All classes that make up the message dialog API are importable from `gui.message`. +While you are unlikely to need all of them, they are enumerated below: + +* `ReturnCode`: Possible return codes from modal `MessageDialog`s. +* `EscapeCode`: Escape behaviour of `MessageDialog`s. +* `DialogType`: Types of dialogs (sets the dialog's sound and icon). +* `Button`: Button configuration data structure. +* `DefaultButton`: Enumeration of pre-configured buttons. +* `DefaultButtonSet`: Enumeration of common combinations of buttons. +* `MessageDialog`: The actual dialog class. + +In many simple cases, you will be able to achieve what you need by simply creating a message dialog and calling `Show` or `ShowModal`. For example: + +```py +from gui.message import MessageDialog +from gui import mainFrame + +MessageDialog( + mainFrame, + _("Hello world!"), +).Show() +``` + +This will show a non-modal (that is, non-blocking) dialog with the text "Hello world!" and an OK button. + +If you want the dialog to be modal (that is, to block the user from performing other actions in NVDA until they have responded to it), you can call `ShowModal` instead. + +With modal dialogs, the easiest way to respond to user input is via the return code. + +```py +from gui.message import DefaultButtonSet, ReturnCode + +saveDialog = MessageDialog( + mainFrame, + _("Would you like to save your changes before exiting?"), + _("Save changes?"), + buttons=DefaultButtonSet.SAVE_NO_CANCEL +) + +match saveDialog.ShowModal(): + case ReturnCode.SAVE: + ... # Save the changes and close + case ReturnCode.NO: + ... # Discard changes and close + case ReturnCode.CANCEL: + ... # Do not close +``` + +For non-modal dialogs, the easiest way to respond to the user pressing a button is via callback methods. + +```py +def readChangelog(): + ... # Do something + +def downloadUpdate(): + ... # Do something + +def remindLater(): + ... # Do something + +updateDialog = MessageDialog( + mainFrame, + "An update is available. " + "Would you like to download it now?", + "Update", + buttons=None, +).addYesButton( + callback=downloadUpdate +).addNoButton( + label=_("&Remind me later"), + fallbackAction=True, + callback=remindLater +).addHelpButton( + label=_("What's &new"), + callback=readChangelog +) + +updateDialog.Show() +``` + +You can set many of the parameters to `addButton` later, too: + +* The default focus can be set by calling `setDefaultFocus` on your message dialog instance, and passing it the ID of the button to make the default focus. +* The fallback action can be set later by calling `setFallbackAction` or `SetEscapeId` with the ID of the button which performs the fallback action. +* The button's label can be changed by calling `setButtonLabel` with the ID of the button and the new label. + +#### Fallback actions + +The fallback action is the action performed when the dialog is closed without the user pressing one of the buttons you added to the dialog. +This can happen for several reasons: + +* The user pressed `esc` or `alt+f4` to close the dialog. +* The user used the title bar close button or system menu close item to close the dialog. +* The user closed the dialog from the Task View, Taskbar or App Switcher. +* The user is quitting NVDA. +* Some other part of NVDA or an add-on has asked the dialog to close. + +By default, the fallback action is set to `EscapeCode.CANCEL_OR_AFFIRMATIVE`. +This means that the fallback action will be the cancel button if there is one, the button whose ID is `dialog.GetAffirmativeId()` (`ReturnCode.OK`, by default), or `None` if no button with either ID exists in the dialog. +You can use `dialog.SetAffirmativeId(id)` to change the ID of the button used secondarily to Cancel, if you like. +The fallback action can also be set to `EscapeCode.NO_FALLBACK` to disable closing the dialog like this entirely. +If it is set to any other value, the value must be the id of a button to use as the default action. + +In some cases, the dialog may be forced to close. +If the dialog is shown modally, a calculated fallback action will be used if the fallback action is `EscapeCode.NO_FALLBACK` or not found. +The order of precedence for calculating the fallback when a dialog is forced to close is as follows: + +1. The developer-set fallback action. +2. The developer-set default focus. +3. The first button added to the dialog that closes the dialog. +4. The first button added to the dialog, regardless of whether it closes the dialog. +5. A dummy action that does nothing but close the dialog. + In this case, and only this case, the return code from showing the dialog modally will be `EscapeCode.NO_FALLBACK`. + +#### A note on threading + +**IMPORTANT:** Most `MessageDialog` methods are **not** thread safe. +Calling these methods from non-GUI threads can cause crashes or unpredictable behavior. + +When calling non thread safe methods on `MessageDialog` or its instances, be sure to do so on the GUI thread. +To do this with wxPython, you can use `wx.CallAfter` or `wx.CallLater`. +As these operations schedule the passed callable to occur on the GUI thread, they will return immediately, and will not return the return value of the passed callable. +If you want to wait until the callable has completed, or care about its return value, consider using `gui.guiHelper.wxCallOnMain`. + +The `wxCallOnMain` function executes the callable you pass to it, along with any positional and keyword arguments, on the GUI thread. +It blocks the calling thread until the passed callable returns or raises an exception, at which point it returns the returned value, or re-raises the raised exception. + +```py +# To call +someFunction(arg1, arg2, kw1=value1, kw2=value2) +# on the GUI thread: +wxCallOnMain(someFunction, arg1, arg2, kw=value1, kw2=value2) +``` + +In fact, you cannot create, initialise, or show (modally or non-modally) `MessageDialog`s from any thread other than the GUI thread. + +#### Buttons + +You can add buttons in a number of ways: + +* By passing a `Collection` of `Button`s to the `buttons` keyword-only parameter to `MessageDialog` when initialising. +* By calling `addButton` on a `MessageDialog` instance, either with a `Button` instance, or with simple parameters. + * When calling `addButton` with a `Button` instance, you can override all of its parameters except `id` by providing their values as keyword arguments. + * When calling `addButton` with simple parameters, the parameters it accepts are the same as those of `Button`. + * In both cases, `id` or `button` is the first argument, and is positional only. +* By calling `addButtons` with a `Collection` of `Button`s. +* By calling any of the add button helpers. + +Regardless of how you add them, you cannot add multiple buttons with the same ID to the same `MessageDialog`. + +A `Button` is an immutable data structure containing all of the information needed to add a button to a `MessageDialog`. +Its fields are as follows: + +| Field | Type | Default | Explanation | +|---|---|---|---| +| `id` | `ReturnCode` | No default | The ID used to refer to the button. | +| `label` | `str` | No default | The text label to display on the button. Prefix accelerator keys with an ampersand (&). | +| `callback` | `Callable` or `None` | `None` | The function to call when the button is clicked. This is most useful for non-modal dialogs. | +| `defaultFocus` | `bool` | `False` | Whether to explicitly set the button as the default focus. (1) | +| `fallbackAction` | `bool` | `False` | Whether the button should be the fallback action, which is called when the user presses `esc`, uses the system menu or title bar close buttons, or the dialog is asked to close programmatically. (2) | +| `closesDialog` | `bool` | `True` | Whether the button should close the dialog when pressed. (3) | +| `returnCode` | `ReturnCode` or `None` | `None` | Value to return when a modal dialog is closed. If `None`, the button's ID will be used. | + +1. Setting `defaultFocus` only overrides the default focus: + + * If no buttons have this property, the first button will be the default focus. + * If multiple buttons have this property, the last one will be the default focus. + +2. `fallbackAction` only sets whether to override the fallback action: + + * This button will still be the fallback action if the dialog's fallback action is set to `EscapeCode.CANCEL_OR_AFFIRMATIVE` (the default) and its ID is `ReturnCode.CANCEL` (or whatever the value of `GetAffirmativeId()` is (`ReturnCode.OK`, by default), if there is no button with `id=ReturnCode.CANCEL`), even if it is added with `fallbackAction=False`. + To set a dialog to have no fallback action, use `setFallbackAction(EscapeCode.NO_FALLBACK)`. + * If multiple buttons have this property, the last one will be the fallback action. + +3. Buttons with `fallbackAction=True` and `closesDialog=False` are not supported: + + * When adding a button with `fallbackAction=True` and `closesDialog=False`, `closesDialog` will be set to `True`. + * If you attempt to call `setFallbackAction` with the ID of a button that does not close the dialog, `ValueError` will be raised. + +A number of pre-configured buttons are available for you to use from the `DefaultButton` enumeration, complete with pre-translated labels. +None of these buttons will explicitly set themselves as the fallback action. +You can also add any of these buttons to an existing `MessageDialog` instance with its add button helper, which also allows you to override all but the `id` parameter. +The following default buttons are available: + +| Button | Label | ID/return code | Closes dialog | Add button helper | +|---|---|---|---|---| +| `APPLY` | &Apply | `ReturnCode.APPLY` | No | `addApplyButton` | +| `CANCEL` | Cancel | `ReturnCode.CANCEL` | Yes | `addCancelButton` | +| `CLOSE` | Close | `ReturnCode.CLOSE` | Yes | `addCloseButton` | +| `HELP` | Help | `ReturnCode.HELP` | No | `addHelpButton` | +| `NO` | &No | `ReturnCode.NO` | Yes | `addNoButton` | +| `OK` | OK | `ReturnCode.OK` | Yes | `addOkButton` | +| `SAVE` | &Save | `ReturnCode.SAVE` | Yes | `addSaveButton` | +| `YES` | &Yes | `ReturnCode.YES` | Yes | `addYesButton` | + +As you usually want more than one button on a dialog, there are also a number of pre-defined sets of buttons available as members of the `DefaultButtonSet` enumeration. +All of them comprise members of `DefaultButton`. +You can also add any of these default button sets to an existing `MessageDialog` with one of its add buttons helpers. +The following default button sets are available: + +| Button set | Contains | Add button set helper | Notes | +|---|---|---|---| +| `OK_CANCEL` | `DefaultButton.OK` and `DefaultButton.Cancel` | `addOkCancelButtons` | | +| `YES_NO` | `DefaultButton.YES` and `DefaultButton.NO` | `addYesNoButtons` | You must set a fallback action if you want the user to be able to press escape to close a dialog with only these buttons. | +| `YES_NO_CANCEL` | `DefaultButton.YES`, `DefaultButton.NO` and `DefaultButton.CANCEL` | `addYesNoCancelButtons` | | +| `SAVE_NO_CANCEL` | `DefaultButton.SAVE`, `DefaultButton.NO`, `DefaultButton.CANCEL` | `addSaveNoCancelButtons` | The label of the no button is overridden to be "Do&n't save". | + +If none of the standard `ReturnCode` values are suitable for your button, you may also use `ReturnCode.CUSTOM_1` through `ReturnCode.CUSTOM_5`, which will not conflict with any built-in identifiers. + +#### Convenience methods + +The `MessageDialog` class also provides a number of convenience methods for showing common types of modal dialogs. +Each of them requires a message string, and optionally a title string and parent window. +They all also support overriding the labels on their buttons via keyword arguments. +They are all thread safe. +The following convenience class methods are provided (keyword arguments for overriding button labels indicated in parentheses): + +| Method | Buttons | Return values | +|---|---|---| +| `alert` | OK (`okLabel`) | `None` | +| `confirm` | OK (`okLabel`) and Cancel (`cancelLabel`) | `ReturnCode.OK` or `ReturnCode.Cancel` | +| `ask` | Yes (`yesLabel`), No (`noLabel`) and Cancel (`cancelLabel`) | `ReturnCode.YES`, `ReturnCode.NO` or `ReturnCode.CANCEL` | diff --git a/requirements.txt b/requirements.txt index 7a1ab6bfcf8..f1e6c221d53 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,6 +27,8 @@ nuitka==2.5.4 # Creating XML unit test reports unittest-xml-reporting==3.2.0 +# Feed parameters to tests neatly +parameterized==0.9.0 # Building user documentation Markdown==3.7 diff --git a/source/documentationUtils.py b/source/documentationUtils.py index c84b6112c5f..6ffe7cf8826 100644 --- a/source/documentationUtils.py +++ b/source/documentationUtils.py @@ -1,6 +1,6 @@ # -*- coding: UTF-8 -*- # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2006-2023 NV Access Limited, Łukasz Golonka +# Copyright (C) 2006-2024 NV Access Limited, Łukasz Golonka # 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 @@ -13,7 +13,6 @@ from logHandler import log import ui import queueHandler -from gui.message import messageBox import wx @@ -65,6 +64,9 @@ def reportNoDocumentation(fileName: str, useMsgBox: bool = False) -> None: f"Documentation not found ({fileName}): possible cause - running from source without building user docs.", ) if useMsgBox: + # Import late to avoid circular impoort. + from gui.message import messageBox + messageBox( noDocMessage, # Translators: the title of an error message dialog diff --git a/source/gui/__init__.py b/source/gui/__init__.py index 7360c83106e..9c5c12b7561 100644 --- a/source/gui/__init__.py +++ b/source/gui/__init__.py @@ -5,8 +5,10 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. +from collections.abc import Callable import os import ctypes +import warnings import wx import wx.adv @@ -30,6 +32,8 @@ # messageBox is accessed through `gui.messageBox` as opposed to `gui.message.messageBox` throughout NVDA, # be cautious when removing messageBox, + MessageDialog, + displayDialogAsModal, ) from . import blockAction from .speechDict import ( @@ -285,7 +289,7 @@ def onExecuteUpdateCommand(self, evt): apiVersion=apiVersion, backCompatTo=backCompatToAPIVersion, ) - runScriptModalDialog(confirmUpdateDialog) + runScriptModalDialog(confirmUpdateDialog, confirmUpdateDialog.callback) else: updateCheck.executePendingUpdate() @@ -367,7 +371,7 @@ def onInputGesturesCommand(self, evt): def onAboutCommand(self, evt): # Translators: The title of the dialog to show about info for NVDA. - messageBox(versionInfo.aboutMessage, _("About NVDA"), wx.OK) + MessageDialog(None, versionInfo.aboutMessage, _("About NVDA")).Show() @blockAction.when(blockAction.Context.SECURE_MODE) def onCheckForUpdateCommand(self, evt): @@ -878,21 +882,24 @@ def showGui(): wx.CallAfter(mainFrame.showGui) -def runScriptModalDialog(dialog, callback=None): +def runScriptModalDialog(dialog: wx.Dialog, callback: Callable[[int], Any] | None = None): """Run a modal dialog from a script. - This will not block the caller, - but will instead call C{callback} (if provided) with the result from the dialog. + This will not block the caller, but will instead call callback (if provided) with the result from the dialog. The dialog will be destroyed once the callback has returned. - @param dialog: The dialog to show. - @type dialog: C{wx.Dialog} - @param callback: The optional callable to call with the result from the dialog. - @type callback: callable + + This function is deprecated. + Use :class:`message.MessageDialog` instead. + + :param dialog: The dialog to show. + :param callback: The optional callable to call with the result from the dialog. """ + warnings.warn( + "showScriptModalDialog is deprecated. Use an instance of message.MessageDialog and wx.CallAfter instead.", + DeprecationWarning, + ) def run(): - mainFrame.prePopup() - res = dialog.ShowModal() - mainFrame.postPopup() + res = displayDialogAsModal(dialog) if callback: callback(res) dialog.Destroy() diff --git a/source/gui/blockAction.py b/source/gui/blockAction.py index 205e075f31a..a7fa1e6094e 100644 --- a/source/gui/blockAction.py +++ b/source/gui/blockAction.py @@ -1,25 +1,48 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2023 NV Access Limited, Cyrille Bougot +# Copyright (C) 2023-2024 NV Access Limited, Cyrille Bougot # This file is covered by the GNU General Public License. # See the file COPYING for more details. +from collections.abc import Callable import config from config.configFlags import BrailleMode from dataclasses import dataclass from enum import Enum from functools import wraps import globalVars -from typing import Callable +from typing import Any +from speech.priorities import SpeechPriority import ui from utils.security import isLockScreenModeActive, isRunningOnSecureDesktop -from gui.message import isModalMessageBoxActive -import queueHandler +import core + +_DELAY_BEFORE_MESSAGE_MS = 1 +"""Duration in milliseconds for which to delay announcing that an action has been blocked, so that any UI changes don't interrupt it. +1ms is a magic number. It can be increased if it is found to be too short, but it should be kept to a minimum. +""" + + +def _isModalMessageBoxActive() -> bool: + """Avoid circular import of isModalMessageBoxActive""" + from gui.message import isModalMessageBoxActive + + return isModalMessageBoxActive() + + +def _modalDialogOpenCallback(): + """Focus any open blocking :class:`MessageDialog` instances.""" + # Import late to avoid circular import + from gui.message import MessageDialog + + if MessageDialog.blockingInstancesExist(): + MessageDialog.FocusBlockingInstances() @dataclass class _Context: blockActionIf: Callable[[], bool] translatedMessage: str + callback: Callable[[], Any] | None = None class Context(_Context, Enum): @@ -35,10 +58,11 @@ class Context(_Context, Enum): _("Action unavailable in NVDA Windows Store version"), ) MODAL_DIALOG_OPEN = ( - isModalMessageBoxActive, + _isModalMessageBoxActive, # Translators: Reported when an action cannot be performed because NVDA is waiting # for a response from a modal dialog _("Action unavailable while a dialog requires a response"), + _modalDialogOpenCallback, ) WINDOWS_LOCKED = ( lambda: isLockScreenModeActive() or isRunningOnSecureDesktop(), @@ -74,7 +98,15 @@ def _wrap(func): def funcWrapper(*args, **kwargs): for context in contexts: if context.blockActionIf(): - queueHandler.queueFunction(queueHandler.eventQueue, ui.message, context.translatedMessage) + if context.callback is not None: + context.callback() + # We need to delay this message so that, if a UI change is triggered by the callback, the UI change doesn't interrupt it. + core.callLater( + _DELAY_BEFORE_MESSAGE_MS, + ui.message, + context.translatedMessage, + SpeechPriority.NOW, + ) return return func(*args, **kwargs) diff --git a/source/gui/exit.py b/source/gui/exit.py index e24184f871b..d864eab8119 100644 --- a/source/gui/exit.py +++ b/source/gui/exit.py @@ -177,7 +177,7 @@ def onOk(self, evt): apiVersion=apiVersion, backCompatTo=backCompatTo, ) - displayDialogAsModal(confirmUpdateDialog) + confirmUpdateDialog.callback(displayDialogAsModal(confirmUpdateDialog)) else: updateCheck.executePendingUpdate() wx.CallAfter(self.Destroy) diff --git a/source/gui/guiHelper.py b/source/gui/guiHelper.py index cdcf9cb0689..579466e5f07 100644 --- a/source/gui/guiHelper.py +++ b/source/gui/guiHelper.py @@ -43,11 +43,16 @@ def __init__(self, parent): ... """ +from collections.abc import Callable from contextlib import contextmanager +import sys +import threading import weakref from typing import ( + Any, Generic, Optional, + ParamSpec, Type, TypeVar, Union, @@ -476,3 +481,51 @@ class SIPABCMeta(wx.siplib.wrappertype, ABCMeta): """Meta class to be used for wx subclasses with abstract methods.""" pass + + +# TODO: Rewrite to use type parameter lists when upgrading to python 3.12 or later. +_WxCallOnMain_P = ParamSpec("_WxCallOnMain_P") +_WxCallOnMain_T = TypeVar("_WxCallOnMain_T") + + +def wxCallOnMain( + function: Callable[_WxCallOnMain_P, _WxCallOnMain_T], + *args: _WxCallOnMain_P.args, + **kwargs: _WxCallOnMain_P.kwargs, +) -> _WxCallOnMain_T: + """Call a non-thread-safe wx function in a thread-safe way. + Blocks current thread. + + Using this function is preferable over calling :fun:`wx.CallAfter` directly when you care about the return time or return value of the function. + + This function blocks the thread on which it is called. + + :param function: Callable to call on the main GUI thread. + If this thread is the GUI thread, the function will be called immediately. + Otherwise, it will be scheduled to be called on the GUI thread. + In either case, the current thread will be blocked until it returns. + :raises Exception: If `function` raises an exception, it is transparently re-raised so it can be handled on the calling thread. + :return: Return value from calling `function` with the given positional and keyword arguments. + """ + result: Any = None + exception: BaseException | None = None + event = threading.Event() + + def functionWrapper(): + nonlocal result, exception + try: + result = function(*args, **kwargs) + except Exception: + exception = sys.exception() + event.set() + + if wx.IsMainThread(): + functionWrapper() + else: + wx.CallAfter(functionWrapper) + event.wait() + + if exception is not None: + raise exception + else: + return result diff --git a/source/gui/message.py b/source/gui/message.py index 6bd5d9c2f95..d9fb3b4ac67 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -1,16 +1,31 @@ # -*- coding: UTF-8 -*- # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2006-2022 NV Access Limited, Peter Vágner, Aleksey Sadovoy, Mesar Hameed, Joseph Lee, +# Copyright (C) 2006-2024 NV Access Limited, Peter Vágner, Aleksey Sadovoy, Mesar Hameed, Joseph Lee, # Thomas Stivers, Babbage B.V., Accessolutions, Julien Cochuyt # This file is covered by the GNU General Public License. # See the file COPYING for more details. import threading -from typing import Optional +import time +import warnings +import winsound +from collections import deque +from collections.abc import Callable, Collection +from enum import Enum, IntEnum, auto +from functools import partialmethod, singledispatchmethod +from typing import Any, Literal, NamedTuple, Optional, Self, TypeAlias +import core +import extensionPoints import wx +from .contextHelp import ContextHelpMixin +from logHandler import log -import extensionPoints +import gui + +from . import guiHelper +from .dpiScalingHelper import DpiScalingHelperMixinWithoutInit +from .guiHelper import SIPABCMeta, wxCallOnMain _messageBoxCounterLock = threading.Lock() _messageBoxCounter = 0 @@ -51,19 +66,17 @@ def displayDialogAsModal(dialog: wx.Dialog) -> int: Because an answer is required to continue after a modal messageBox is opened, some actions such as shutting down are prevented while NVDA is in a possibly uncertain state. """ - from gui import mainFrame - global _messageBoxCounter with _messageBoxCounterLock: _messageBoxCounter += 1 try: if not dialog.GetParent(): - mainFrame.prePopup() + gui.mainFrame.prePopup() res = dialog.ShowModal() finally: if not dialog.GetParent(): - mainFrame.postPopup() + gui.mainFrame.postPopup() with _messageBoxCounterLock: _messageBoxCounter -= 1 @@ -76,49 +89,35 @@ def messageBox( style: int = wx.OK | wx.CENTER, parent: Optional[wx.Window] = None, ) -> int: - """Display a message dialog. - Avoid using C{wx.MessageDialog} and C{wx.MessageBox} directly. - @param message: The message text. - @param caption: The caption (title) of the dialog. - @param style: Same as for wx.MessageBox. - @param parent: The parent window. - @return: Same as for wx.MessageBox. + """Display a modal message dialog. - `gui.message.messageBox` is a function which blocks the calling thread, - until a user responds to the modal dialog. + .. warning:: This function is deprecated. + Use :class:`MessageDialog` instead. + + This function blocks the calling thread until the user responds to the modal dialog. This function should be used when an answer is required before proceeding. - Consider using a custom subclass of a wxDialog if an answer is not required - or a default answer can be provided. + Consider using :class:`MessageDialog` or a custom :class:`wx.Dialog` subclass if an answer is not required, or a default answer can be provided. It's possible for multiple message boxes to be open at a time. - Before opening a new messageBox, use `isModalMessageBoxActive` - to check if another messageBox modal response is still pending. - - Because an answer is required to continue after a modal messageBox is opened, - some actions such as shutting down are prevented while NVDA is in a possibly uncertain state. - """ - from gui import mainFrame - import core - from logHandler import log - - global _messageBoxCounter - with _messageBoxCounterLock: - _messageBoxCounter += 1 + Before opening a new messageBox, use :func:`isModalMessageBoxActive` to check if another messageBox modal response is still pending. - try: - if not parent: - mainFrame.prePopup() - if not core._hasShutdownBeenTriggered: - res = wx.MessageBox(message, caption, style, parent or mainFrame) - else: - log.debugWarning("Not displaying message box as shutdown has been triggered.", stack_info=True) - res = wx.ID_CANCEL - finally: - if not parent: - mainFrame.postPopup() - with _messageBoxCounterLock: - _messageBoxCounter -= 1 + Because an answer is required to continue after a modal messageBox is opened, some actions such as shutting down are prevented while NVDA is in a possibly uncertain state. + :param message: The message text. + :param caption: The caption (title) of the dialog. + :param style: Same as for :func:`wx.MessageBox`, defaults to wx.OK | wx.CENTER. + :param parent: The parent window, defaults to None. + :return: Same as for :func:`wx.MessageBox`. + """ + warnings.warn( + "gui.message.messageBox is deprecated. Use gui.message.MessageDialog instead.", + DeprecationWarning, + ) + if not core._hasShutdownBeenTriggered: + res = wxCallOnMain(_messageBoxShim, message, caption, style, parent=parent or gui.mainFrame) + else: + log.debugWarning("Not displaying message box as shutdown has been triggered.", stack_info=True) + res = wx.CANCEL return res @@ -153,3 +152,1092 @@ def displayError(self, parentWindow: wx.Window): style=wx.OK | wx.ICON_ERROR, parent=parentWindow, ) + + +# TODO: Change to type statement when Python 3.12 or later is in use. +_Callback_T: TypeAlias = Callable[[], Any] + + +class _Missing_Type: + """Sentinel class to provide a nice repr.""" + + def __repr__(self) -> str: + return "MISSING" + + +_MISSING = _Missing_Type() +"""Sentinel for discriminating between `None` and an actually omitted argument.""" + + +class ReturnCode(IntEnum): + """Enumeration of possible returns from :class:`MessageDialog`.""" + + OK = wx.ID_OK + CANCEL = wx.ID_CANCEL + YES = wx.ID_YES + NO = wx.ID_NO + SAVE = wx.ID_SAVE + APPLY = wx.ID_APPLY + CLOSE = wx.ID_CLOSE + HELP = wx.ID_HELP + CUSTOM_1 = wx.ID_HIGHEST + 1 + CUSTOM_2 = wx.ID_HIGHEST + 2 + CUSTOM_3 = wx.ID_HIGHEST + 3 + CUSTOM_4 = wx.ID_HIGHEST + 4 + CUSTOM_5 = wx.ID_HIGHEST + 5 + + +class EscapeCode(IntEnum): + """Enumeration of the behavior of the escape key and programmatic attempts to close a :class:`MessageDialog`.""" + + NO_FALLBACK = wx.ID_NONE + """The escape key should have no effect, and programatically attempting to close the dialog should fail.""" + + CANCEL_OR_AFFIRMATIVE = wx.ID_ANY + """The Cancel button should be emulated when closing the dialog by any means other than with a button in the dialog. + If no Cancel button is present, the affirmative button should be used. + """ + + +class DialogType(Enum): + """Types of message dialogs. + These are used to determine the icon and sound to play when the dialog is shown. + """ + + STANDARD = auto() + """A simple message dialog, with no icon or sound. + This should be used in most situations. + """ + + WARNING = auto() + """A warning dialog, which makes the Windows alert sound and has an exclamation mark icon. + This should be used when you have critical information to present to the user, such as when their action may result in irreversible loss of data. + """ + + ERROR = auto() + """An error dialog, which has a cross mark icon and makes the Windows error sound. + This should be used when a critical error has been encountered. + """ + + @property + def _wxIconId(self) -> "wx.ArtID | None": # type: ignore + """The wx icon ID to use for this dialog type. + This is used to determine the icon to display in the dialog. + This will be None when the default icon should be used. + """ + match self: + case self.ERROR: + return wx.ART_ERROR + case self.WARNING: + return wx.ART_WARNING + case _: + return None + + @property + def _windowsSoundId(self) -> int | None: + """The Windows sound ID to play for this dialog type. + This is used to determine the sound to play when the dialog is shown. + This will be None when no sound should be played. + """ + match self: + case self.ERROR: + return winsound.MB_ICONHAND + case self.WARNING: + return winsound.MB_ICONASTERISK + case _: + return None + + +class Button(NamedTuple): + """A button to add to a message dialog.""" + + id: ReturnCode + """The ID to use for this button. + + This will be returned after showing the dialog modally. + It is also used to modify the button later. + """ + + label: str + """The label to display on the button.""" + + callback: _Callback_T | None = None + """The callback to call when the button is clicked.""" + + defaultFocus: bool = False + """Whether this button should explicitly be the default focused button. + + .. note:: This only overrides the default focus. + If no buttons have this property, the first button will be the default focus. + """ + + fallbackAction: bool = False + """Whether this button is the fallback action. + + The fallback action is called when the user presses escape, the title bar close button, or the system menu close item. + It is also called when programatically closing the dialog, such as when shutting down NVDA. + + .. note:: This only sets whether to override the fallback action. + `EscapeCode.DEFAULT` may still result in this button being the fallback action, even if `fallbackAction=False`. + """ + + closesDialog: bool = True + """Whether this button should close the dialog when clicked. + + .. note:: Buttons with fallbackAction=True and closesDialog=False are not supported. + See the documentation of :class:`MessageDialog` for information on how these buttons are handled. + """ + + returnCode: ReturnCode | None = None + """Override for the default return code, which is the button's ID. + + .. note:: If None, the button's ID will be used as the return code when closing a modal dialog with this button. + """ + + +class DefaultButton(Button, Enum): + """Default buttons for message dialogs.""" + + # Translators: An ok button on a message dialog. + OK = Button(id=ReturnCode.OK, label=_("OK")) + # Translators: A yes button on a message dialog. + YES = Button(id=ReturnCode.YES, label=_("&Yes")) + # Translators: A no button on a message dialog. + NO = Button(id=ReturnCode.NO, label=_("&No")) + # Translators: A cancel button on a message dialog. + CANCEL = Button(id=ReturnCode.CANCEL, label=_("Cancel")) + # Translators: A save button on a message dialog. + SAVE = Button(id=ReturnCode.SAVE, label=_("&Save")) + # Translators: An apply button on a message dialog. + APPLY = Button(id=ReturnCode.APPLY, label=_("&Apply"), closesDialog=False) + # Translators: A close button on a message dialog. + CLOSE = Button(id=ReturnCode.CLOSE, label=_("Close")) + # Translators: A help button on a message dialog. + HELP = Button(id=ReturnCode.HELP, label=_("Help"), closesDialog=False) + + +class DefaultButtonSet(tuple[DefaultButton], Enum): + """Commonly needed button combinations.""" + + OK_CANCEL = ( + DefaultButton.OK, + DefaultButton.CANCEL, + ) + YES_NO = ( + DefaultButton.YES, + DefaultButton.NO, + ) + YES_NO_CANCEL = ( + DefaultButton.YES, + DefaultButton.NO, + DefaultButton.CANCEL, + ) + SAVE_NO_CANCEL = ( + DefaultButton.SAVE, + # Translators: A don't save button on a message dialog. + DefaultButton.NO.value._replace(label=_("Do&n't save")), + DefaultButton.CANCEL, + ) + + +class _Command(NamedTuple): + """Internal representation of a command for a message dialog.""" + + callback: _Callback_T | None + """The callback function to be executed. Defaults to None.""" + + closesDialog: bool + """Indicates whether the dialog should be closed after the command is executed. Defaults to True.""" + + returnCode: ReturnCode + + +class MessageDialog(DpiScalingHelperMixinWithoutInit, ContextHelpMixin, wx.Dialog, metaclass=SIPABCMeta): + """Provides a more flexible message dialog. + + Creating dialogs with this class is extremely flexible. You can create a dialog, passing almost all parameters to the initialiser, and only call `Show` or `ShowModal` on the instance. + You can also call the initialiser with very few arguments, and modify the dialog by calling methods on the created instance. + Mixing and matching both patterns is also allowed. + + When subclassing this class, you can override `_addButtons` and `_addContents` to insert custom buttons or contents that you want your subclass to always have. + + .. warning:: Unless noted otherwise, the message dialog API is **not** thread safe. + """ + + _instances: deque["MessageDialog"] = deque() + """Double-ended queue of open instances. + When programatically closing non-blocking instances or focusing blocking instances, this should operate like a stack (I.E. LIFO behaviour). + Random access still needs to be supported for the case of non-modal dialogs being closed out of order. + """ + _FAIL_ON_NONMAIN_THREAD = True + """Class default for whether to run the :meth:`._checkMainThread` test.""" + _FAIL_ON_NO_BUTTONS = True + """Class default for whether to run the :meth:`._checkHasButtons` test.""" + + # region Constructors + def __new__(cls, *args, **kwargs) -> Self: + """Override to disallow creation on non-main threads.""" + cls._checkMainThread() + return super().__new__(cls, *args, **kwargs) + + def __init__( + self, + parent: wx.Window | None, + message: str, + title: str = wx.MessageBoxCaptionStr, + dialogType: DialogType = DialogType.STANDARD, + *, + buttons: Collection[Button] | None = (DefaultButton.OK,), + helpId: str = "", + ): + """Initialize the MessageDialog. + + :param parent: Parent window of this dialog. + If given, this window will become inoperable while the dialog is shown modally. + :param message: Message to display in the dialog. + :param title: Window title for the dialog. + :param dialogType: The type of the dialog, defaults to DialogType.STANDARD. + Affects things like the icon and sound of the dialog. + :param buttons: What buttons to place in the dialog, defaults to (DefaultButton.OK,). + Further buttons can easily be added later. + :param helpId: URL fragment of the relevant help entry in the user guide for this dialog, defaults to "" + """ + self._checkMainThread() + self.helpId = helpId # Must be set before initialising ContextHelpMixin. + super().__init__(parent, title=title) + self._isLayoutFullyRealized = False + self._commands: dict[int, _Command] = {} + """Registry of commands bound to this MessageDialog.""" + + # Stylistic matters. + self.EnableCloseButton(False) + self._setIcon(dialogType) + self._setSound(dialogType) + + # Bind event listeners. + self.Bind(wx.EVT_SHOW, self._onShowEvent, source=self) + self.Bind(wx.EVT_ACTIVATE, self._onActivateEvent, source=self) + self.Bind(wx.EVT_CLOSE, self._onCloseEvent) + self.Bind(wx.EVT_BUTTON, self._onButtonEvent) + self.Bind(wx.EVT_WINDOW_DESTROY, self._onDestroyEvent) + + # Scafold the dialog. + mainSizer = self._mainSizer = wx.BoxSizer(wx.VERTICAL) + contentsSizer = self._contentsSizer = guiHelper.BoxSizerHelper(parent=self, orientation=wx.VERTICAL) + messageControl = self._messageControl = wx.StaticText(self) + contentsSizer.addItem(messageControl) + buttonHelper = self._buttonHelper = guiHelper.ButtonHelper(wx.HORIZONTAL) + mainSizer.Add( + contentsSizer.sizer, + border=guiHelper.BORDER_FOR_DIALOGS, + flag=wx.ALL, + ) + self.SetSizer(mainSizer) + + # Finally, populate the dialog. + self.setMessage(message) + self._addContents(contentsSizer) + self._addButtons(buttonHelper) + if buttons is not None: + self.addButtons(buttons) + contentsSizer.addDialogDismissButtons(buttonHelper) + + # endregion + + # region Public object API + @singledispatchmethod + def addButton( + self, + id: ReturnCode, + /, + label: str, + *args, + callback: _Callback_T | None = None, + defaultFocus: bool = False, + fallbackAction: bool = False, + closesDialog: bool = True, + returnCode: ReturnCode | None = None, + **kwargs, + ) -> Self: + """Add a button to the dialog. + + :param id: The ID to use for the button. + :param label: Text label to show on this button. + :param callback: Function to call when the button is pressed, defaults to None. + This is most useful for dialogs that are shown as non-modal. + :param defaultFocus: whether this button should receive focus when the dialog is first opened, defaults to False. + If multiple buttons with `defaultFocus=True` are added, the last one added will receive initial focus. + :param fallbackAction: Whether or not this button should be the fallback action for the dialog, defaults to False. + The fallback action is called when the user closes the dialog with the escape key, title bar close button, system menu close item etc. + If multiple buttons with `fallbackAction=True` are added, the last one added will be the fallback action. + :param closesDialog: Whether the button should close the dialog when pressed, defaults to True. + :param returnCode: Override for the value returned from calls to :meth:`.ShowModal` when this button is pressed, defaults to None. + If None, the button's ID will be used instead. + :raises KeyError: If a button with this ID has already been added. + :return: The updated instance for chaining. + """ + if id in self._commands: + raise KeyError(f"A button with {id=} has already been added.") + button = self._buttonHelper.addButton(self, id, label, *args, **kwargs) + # Get the ID from the button instance in case it was created with id=wx.ID_ANY. + buttonId = button.GetId() + self.AddMainButtonId(buttonId) + # fallback actions that do not close the dialog do not make sense. + if fallbackAction and not closesDialog: + log.warning( + "fallback actions that do not close the dialog are not supported. Forcing closesDialog to True.", + ) + closesDialog = True + self._commands[buttonId] = _Command( + callback=callback, + closesDialog=closesDialog, + returnCode=buttonId if returnCode is None else returnCode, + ) + if defaultFocus: + self.SetDefaultItem(button) + if fallbackAction: + self.setFallbackAction(buttonId) + self.EnableCloseButton(self.hasFallback) + self._isLayoutFullyRealized = False + return self + + @addButton.register + def _( + self, + button: Button, + /, + *args, + label: str | _Missing_Type = _MISSING, + callback: _Callback_T | None | _Missing_Type = _MISSING, + defaultFocus: bool | _Missing_Type = _MISSING, + fallbackAction: bool | _Missing_Type = _MISSING, + closesDialog: bool | _Missing_Type = _MISSING, + returnCode: ReturnCode | None | _Missing_Type = _MISSING, + **kwargs, + ) -> Self: + """Add a :class:`Button` to the dialog. + + :param button: The button to add. + :param label: Override for :attr:`~.Button.label`, defaults to the passed button's `label`. + :param callback: Override for :attr:`~.Button.callback`, defaults to the passed button's `callback`. + :param defaultFocus: Override for :attr:`~.Button.defaultFocus`, defaults to the passed button's `defaultFocus`. + :param fallbackAction: Override for :attr:`~.Button.fallbackAction`, defaults to the passed button's `fallbackAction`. + :param closesDialog: Override for :attr:`~.Button.closesDialog`, defaults to the passed button's `closesDialog`. + :param returnCode: Override for :attr:`~.Button.returnCode`, defaults to the passed button's `returnCode`. + :return: The updated instance for chaining. + """ + keywords = button._asdict() + # We need to pass `id` as a positional argument as `singledispatchmethod` matches on the type of the first argument. + id = keywords.pop("id") + if label is not _MISSING: + keywords["label"] = label + if defaultFocus is not _MISSING: + keywords["defaultFocus"] = defaultFocus + if fallbackAction is not _MISSING: + keywords["fallbackAction"] = fallbackAction + if callback is not _MISSING: + keywords["callback"] = callback + if closesDialog is not _MISSING: + keywords["closesDialog"] = closesDialog + if returnCode is not _MISSING: + keywords["returnCode"] = returnCode + keywords.update(kwargs) + return self.addButton(id, *args, **keywords) + + addOkButton = partialmethod(addButton, DefaultButton.OK) + addOkButton.__doc__ = "Add an OK button to the dialog." + addCancelButton = partialmethod(addButton, DefaultButton.CANCEL) + addCancelButton.__doc__ = "Add a Cancel button to the dialog." + addYesButton = partialmethod(addButton, DefaultButton.YES) + addYesButton.__doc__ = "Add a Yes button to the dialog." + addNoButton = partialmethod(addButton, DefaultButton.NO) + addNoButton.__doc__ = "Add a No button to the dialog." + addSaveButton = partialmethod(addButton, DefaultButton.SAVE) + addSaveButton.__doc__ = "Add a Save button to the dialog." + addApplyButton = partialmethod(addButton, DefaultButton.APPLY) + addApplyButton.__doc__ = "Add an Apply button to the dialog." + addCloseButton = partialmethod(addButton, DefaultButton.CLOSE) + addCloseButton.__doc__ = "Add a Close button to the dialog." + addHelpButton = partialmethod(addButton, DefaultButton.HELP) + addHelpButton.__doc__ = "Add a Help button to the dialog." + + def addButtons(self, buttons: Collection[Button]) -> Self: + """Add multiple buttons to the dialog. + + :return: The dialog instance. + """ + buttonIds = set(button.id for button in buttons) + if len(buttonIds) != len(buttons): + raise KeyError("Button IDs must be unique.") + if not buttonIds.isdisjoint(self._commands): + raise KeyError("You may not add a new button with an existing id.") + for button in buttons: + self.addButton(button) + return self + + addOkCancelButtons = partialmethod(addButtons, DefaultButtonSet.OK_CANCEL) + addOkCancelButtons.__doc__ = "Add OK and Cancel buttons to the dialog." + addYesNoButtons = partialmethod(addButtons, DefaultButtonSet.YES_NO) + addYesNoButtons.__doc__ = "Add Yes and No buttons to the dialog." + addYesNoCancelButtons = partialmethod(addButtons, DefaultButtonSet.YES_NO_CANCEL) + addYesNoCancelButtons.__doc__ = "Add Yes, No and Cancel buttons to the dialog." + addSaveNoCancelButtons = partialmethod(addButtons, DefaultButtonSet.SAVE_NO_CANCEL) + addSaveNoCancelButtons.__doc__ = "Add Save, Don't save and Cancel buttons to the dialog." + + def setButtonLabel(self, id: ReturnCode, label: str) -> Self: + """Set the label of a button in the dialog. + + :param id: ID of the button whose label you want to change. + :param label: New label for the button. + :return: Updated instance for chaining. + """ + self._setButtonLabels((id,), (label,)) + return self + + setOkLabel = partialmethod(setButtonLabel, ReturnCode.OK) + setOkLabel.__doc__ = "Set the label of the OK button in the dialog, if there is one." + setHelpLabel = partialmethod(setButtonLabel, ReturnCode.HELP) + setHelpLabel.__doc__ = "Set the label of the help button in the dialog, if there is one." + + def setOkCancelLabels(self, okLabel: str, cancelLabel: str) -> Self: + """Set the labels of the ok and cancel buttons in the dialog, if they exist." + + :param okLabel: New label for the ok button. + :param cancelLabel: New label for the cancel button. + :return: Updated instance for chaining. + """ + self._setButtonLabels((ReturnCode.OK, ReturnCode.CANCEL), (okLabel, cancelLabel)) + return self + + def setYesNoLabels(self, yesLabel: str, noLabel: str) -> Self: + """Set the labels of the yes and no buttons in the dialog, if they exist." + + :param yesLabel: New label for the yes button. + :param noLabel: New label for the no button. + :return: Updated instance for chaining. + """ + self._setButtonLabels((ReturnCode.YES, ReturnCode.NO), (yesLabel, noLabel)) + return self + + def setYesNoCancelLabels(self, yesLabel: str, noLabel: str, cancelLabel: str) -> Self: + """Set the labels of the yes and no buttons in the dialog, if they exist." + + :param yesLabel: New label for the yes button. + :param noLabel: New label for the no button. + :param cancelLabel: New label for the cancel button. + :return: Updated instance for chaining. + """ + self._setButtonLabels( + (ReturnCode.YES, ReturnCode.NO, ReturnCode.CANCEL), + (yesLabel, noLabel, cancelLabel), + ) + return self + + def setMessage(self, message: str) -> Self: + """Set the textual message to display in the dialog. + + :param message: New message to show. + :return: Updated instance for chaining. + """ + # Use SetLabelText to avoid ampersands being interpreted as accelerators. + self._messageControl.SetLabelText(message) + self._isLayoutFullyRealized = False + return self + + def setDefaultFocus(self, id: ReturnCode) -> Self: + """Set the button to be focused when the dialog first opens. + + :param id: The id of the button to set as default. + :raises KeyError: If no button with id exists. + :return: The updated dialog. + """ + if (win := self.FindWindow(id)) is not None: + self.SetDefaultItem(win) + else: + raise KeyError(f"Unable to find button with {id=}.") + return self + + def SetEscapeId(self, id: ReturnCode | EscapeCode) -> Self: + """Set the action to take when closing the dialog by any means other than a button in the dialog. + + :param id: The ID of the action to take. + This should be the ID of the button that the user can press to explicitly perform this action. + The action should have `closesDialog=True`. + + The following special values are also supported: + * EscapeCode.NONE: If the dialog should only be closable via presses of internal buttons. + * EscapeCode.DEFAULT: If the cancel or affirmative (usually OK) button should be used. + If no Cancel or affirmative button is present, most attempts to close the dialog by means other than via buttons in the dialog wil have no effect. + + :raises KeyError: If no action with the given id has been registered. + :raises ValueError: If the action with the given id does not close the dialog. + :return: The updated dialog instance. + """ + if id not in (EscapeCode.CANCEL_OR_AFFIRMATIVE, EscapeCode.NO_FALLBACK): + if id not in self._commands: + raise KeyError(f"No command registered for {id=}.") + if not self._commands[id].closesDialog: + raise ValueError("fallback actions that do not close the dialog are not supported.") + self.EnableCloseButton(id != EscapeCode.NO_FALLBACK) + super().SetEscapeId(id) + return self + + def setFallbackAction(self, id: ReturnCode | EscapeCode) -> Self: + """See :meth:`MessageDialog.SetEscapeId`.""" + return self.SetEscapeId(id) + + def Show(self, show: bool = True) -> bool: + """Show a non-blocking dialog. + + Attach buttons with :meth:`.addButton`, :meth:`.addButtons`, or any of their more specific helpers. + + :param show: If True, show the dialog. If False, hide it. Defaults to True. + """ + if not show: + return self.Hide() + self._checkShowable() + self._realizeLayout() + log.debug(f"Showing {self!r} as non-modal.") + shown = super().Show(show) + if shown: + log.debug(f"Adding {self!r} to instances.") + self._instances.append(self) + return shown + + def ShowModal(self) -> ReturnCode: + """Show a blocking dialog. + + Attach buttons with :meth:`.addButton`, :meth:`.addButtons`, or any of their more specific helpers. + """ + self._checkShowable() + self._realizeLayout() + + # We want to call `displayDialogAsModal` from our implementation of ShowModal, so we need to switch our instance out now that it's running and replace it with that provided by :class:`wx.Dialog`. + self.__ShowModal = self.ShowModal + self.ShowModal = super().ShowModal + log.debug(f"Adding {self!r} to instances.") + self._instances.append(self) + log.debug(f"Showing {self!r} as modal") + ret = displayDialogAsModal(self) + + # Restore our implementation of ShowModal. + self.ShowModal = self.__ShowModal + return ret + + @property + def isBlocking(self) -> bool: + """Whether or not the dialog is blocking""" + return self.IsModal() or not self.hasFallback + + @property + def hasFallback(self) -> bool: + """Whether the dialog has a valid fallback action. + + Assumes that any explicit action (i.e. not EscapeCode.NONE or EscapeCode.DEFAULT) is valid. + """ + escapeId = self.GetEscapeId() + return escapeId != EscapeCode.NO_FALLBACK and ( + any( + id in (ReturnCode.CANCEL, self.GetAffirmativeId()) and command.closesDialog + for id, command in self._commands.items() + ) + if escapeId == EscapeCode.CANCEL_OR_AFFIRMATIVE + else True + ) + + # endregion + + # region Public class methods + @classmethod + def closeInstances(cls) -> None: + """Close all dialogs with a fallback action. + + This does not force-close all instances, so instances may veto being closed. + """ + for instance in cls._instances: + if not instance.isBlocking: + instance.Close() + + @classmethod + def blockingInstancesExist(cls) -> bool: + """Check if modal dialogs are open without a fallback action.""" + return any(dialog.isBlocking for dialog in cls._instances) + + @classmethod + def focusBlockingInstances(cls) -> None: + """Raise and focus open modal dialogs without a fallback action.""" + lastDialog: MessageDialog | None = None + for dialog in cls._instances: + if dialog.isBlocking: + lastDialog = dialog + dialog.Raise() + if lastDialog: + lastDialog.SetFocus() + + @classmethod + def alert( + cls, + message: str, + caption: str = wx.MessageBoxCaptionStr, + parent: wx.Window | None = None, + *, + okLabel: str | None = None, + ): + """Display a blocking dialog with an OK button. + + .. note:: This method is thread safe. + + :param message: The message to be displayed in the alert dialog. + :param caption: The caption of the alert dialog, defaults to wx.MessageBoxCaptionStr. + :param parent: The parent window of the alert dialog, defaults to None. + :param okLabel: Override for the label of the OK button, defaults to None. + """ + + def impl(): + dlg = cls(parent, message, caption, buttons=(DefaultButton.OK,)) + if okLabel is not None: + dlg.setOkLabel(okLabel) + dlg.ShowModal() + + wxCallOnMain(impl) + + @classmethod + def confirm( + cls, + message, + caption=wx.MessageBoxCaptionStr, + parent=None, + *, + okLabel=None, + cancelLabel=None, + ) -> Literal[ReturnCode.OK, ReturnCode.CANCEL]: + """Display a confirmation dialog with OK and Cancel buttons. + + .. note:: This method is thread safe. + + :param message: The message to be displayed in the dialog. + :param caption: The caption of the dialog window, defaults to wx.MessageBoxCaptionStr. + :param parent: The parent window for the dialog, defaults to None. + :param okLabel: Override for the label of the OK button, defaults to None. + :param cancelLabel: Override for the label of the Cancel button, defaults to None. + :return: ReturnCode.OK if OK is pressed, ReturnCode.CANCEL if Cancel is pressed. + """ + + def impl(): + dlg = cls(parent, message, caption, buttons=DefaultButtonSet.OK_CANCEL) + if okLabel is not None: + dlg.setOkLabel(okLabel) + if cancelLabel is not None: + dlg.setButtonLabel(ReturnCode.CANCEL, cancelLabel) + return dlg.ShowModal() + + return wxCallOnMain(impl) # type: ignore + + @classmethod + def ask( + cls, + message, + caption=wx.MessageBoxCaptionStr, + parent=None, + yesLabel=None, + noLabel=None, + cancelLabel=None, + ) -> Literal[ReturnCode.YES, ReturnCode.NO, ReturnCode.CANCEL]: + """Display a query dialog with Yes, No, and Cancel buttons. + + .. note:: This method is thread safe. + + :param message: The message to be displayed in the dialog. + :param caption: The title of the dialog window, defaults to wx.MessageBoxCaptionStr. + :param parent: The parent window for the dialog, defaults to None. + :param yesLabel: Override for the label of the Yes button, defaults to None. + :param noLabel: Override for the label of the No button, defaults to None. + :param cancelLabel: Override for the label of the Cancel button, defaults to None. + :return: ReturnCode.YES, ReturnCode.NO or ReturnCode.CANCEL, according to the user's action. + """ + + def impl(): + dlg = cls(parent, message, caption, buttons=DefaultButtonSet.YES_NO_CANCEL) + if yesLabel is not None: + dlg.setButtonLabel(ReturnCode.YES, yesLabel) + if noLabel is not None: + dlg.setButtonLabel(ReturnCode.NO, noLabel) + if cancelLabel is not None: + dlg.setButtonLabel(ReturnCode.CANCEL, cancelLabel) + return dlg.ShowModal() + + return wxCallOnMain(impl) # type: ignore + + # endregion + + # region Methods for subclasses + def _addButtons(self, buttonHelper: guiHelper.ButtonHelper) -> None: + """Adds additional buttons to the dialog, before any other buttons are added. + Subclasses may implement this method. + """ + + def _addContents(self, contentsSizer: guiHelper.BoxSizerHelper) -> None: + """Adds additional contents to the dialog, before the buttons. + Subclasses may implement this method. + """ + + # endregion + + # region Internal API + def _checkShowable(self, *, checkMainThread: bool | None = None, checkButtons: bool | None = None): + """Checks that must pass in order to show a Message Dialog. + + If any of the specified tests fails, an appropriate exception will be raised. + See test implementations for details. + + :param checkMainThread: Whether to check that we're running on the GUI thread, defaults to True. + Implemented in :meth:`._checkMainThread`. + :param checkButtons: Whether to check there is at least one command registered, defaults to True. + Implemented in :meth:`._checkHasButtons`. + """ + self._checkMainThread(checkMainThread) + self._checkHasButtons(checkButtons) + + def _checkHasButtons(self, check: bool | None = None): + """Check that the dialog has at least one button. + + :param check: Whether to run the test or fallback to the class default, defaults to None. + If `None`, the value set in :const:`._FAIL_ON_NO_BUTTONS` is used. + :raises RuntimeError: If the dialog does not have any buttons. + """ + if check is None: + check = self._FAIL_ON_NO_BUTTONS + if check and not self.GetMainButtonIds(): + raise RuntimeError("MessageDialogs cannot be shown without buttons.") + + @classmethod + def _checkMainThread(cls, check: bool | None = None): + """Check that we're running on the main (GUI) thread. + + :param check: Whether to run the test or fallback to the class default, defaults to None + If `None`, :const:`._FAIL_ON_NONMAIN_THREAD` is used. + :raises RuntimeError: If running on any thread other than the wxPython GUI thread. + """ + if check is None: + check = cls._FAIL_ON_NONMAIN_THREAD + if check and not wx.IsMainThread(): + raise RuntimeError("Message dialogs can only be used from the main thread.") + + def _realizeLayout(self) -> None: + """Perform layout adjustments prior to showing the dialog.""" + if self._isLayoutFullyRealized: + return + if gui._isDebug(): + startTime = time.time() + log.debug("Laying out message dialog") + self._messageControl.Wrap(self.scaleSize(self.GetSize().Width)) + self._mainSizer.Fit(self) + if self.Parent == gui.mainFrame: + # NVDA's main frame is not visible on screen, so centre on screen rather than on `mainFrame` to avoid the dialog appearing at the top left of the screen. + self.CentreOnScreen() + else: + self.CentreOnParent() + self._isLayoutFullyRealized = True + if gui._isDebug(): + log.debug(f"Layout completed in {time.time() - startTime:.3f} seconds") + + def _getFallbackAction(self) -> _Command | None: + """Get the fallback action of this dialog. + + :return: The id and command of the fallback action. + """ + escapeId = self.GetEscapeId() + if escapeId == EscapeCode.NO_FALLBACK: + return None + elif escapeId == EscapeCode.CANCEL_OR_AFFIRMATIVE: + affirmativeAction: _Command | None = None + affirmativeId: int = self.GetAffirmativeId() + for id, command in self._commands.items(): + if id == ReturnCode.CANCEL: + return command + elif id == affirmativeId: + affirmativeAction = command + if affirmativeAction is None: + return None + else: + return affirmativeAction + else: + return self._commands[escapeId] + + def _getFallbackActionOrFallback(self) -> _Command: + """Get a command that is guaranteed to close this dialog. + + Commands are returned in the following order of preference: + + 1. The developer-set fallback action. + 2. The developer-set default focus. + 3. The first button in the dialog explicitly set to close the dialog. + 4. The first button in the dialog, regardless of whether it closes the dialog. + 5. A new action, with id=EscapeCode.NONE and no callback. + + In all cases, if the command has `closesDialog=False`, this will be overridden to `True` in the returned copy. + + :return: Id and command of the default command. + """ + + def getAction() -> _Command: + # Try using the developer-specified fallback action. + try: + if (action := self._getFallbackAction()) is not None: + return action + except KeyError: + log.error("fallback action was not in commands. This indicates a logic error.") + + # fallback action is unavailable. Try using the default focus instead. + if (defaultFocus := self.GetDefaultItem()) is not None: + # Default focus does not have to be a command, for instance if a custom control has been added and made the default focus. + if (action := self._commands.get(defaultFocus.GetId(), None)) is not None: + return action + + # Default focus is unavailable or not a command. Try using the first registered command that closes the dialog instead. + if len(self._commands) > 0: + try: + return next(command for command in self._commands.values() if command.closesDialog) + except StopIteration: + # No commands that close the dialog have been registered. Use the first command instead. + return next(iter(self._commands.values())) + else: + log.error( + "No commands have been registered. If the dialog is shown, this indicates a logic error.", + ) + + # No commands have been registered. Create one of our own. + return _Command(callback=None, closesDialog=True, returnCode=wx.ID_NONE) + + command = getAction() + if not command.closesDialog: + log.debugWarning(f"Overriding command for {id=} to close dialog.") + command = command._replace(closesDialog=True) + return command + + def _setButtonLabels(self, ids: Collection[ReturnCode], labels: Collection[str]): + """Set a batch of button labels atomically. + + :param ids: IDs of the buttons whose labels should be changed. + :param labels: Labels for those buttons. + :raises ValueError: If the number of IDs and labels is not equal. + :raises KeyError: If any of the given IDs does not exist in the command registry. + :raises TypeError: If any of the IDs does not refer to a :class:`wx.Button`. + """ + if len(ids) != len(labels): + raise ValueError("The number of IDs and labels must be equal.") + buttons: list[wx.Button] = [] + for id in ids: + if id not in self._commands: + raise KeyError("No button with {id=} registered.") + elif isinstance((button := self.FindWindow(id)), wx.Button): + buttons.append(button) + else: + raise TypeError( + f"{id=} exists in command registry, but does not refer to a wx.Button. This indicates a logic error.", + ) + for button, label in zip(buttons, labels): + button.SetLabel(label) + + def _setIcon(self, type: DialogType) -> None: + """Set the icon to be displayed on the dialog.""" + if (iconID := type._wxIconId) is not None: + icon = wx.ArtProvider.GetIconBundle(iconID, client=wx.ART_MESSAGE_BOX) + self.SetIcons(icon) + + def _setSound(self, type: DialogType) -> None: + """Set the sound to be played when the dialog is shown.""" + self._soundID = type._windowsSoundId + + def _playSound(self) -> None: + """Play the sound set for this dialog.""" + if self._soundID is not None: + winsound.MessageBeep(self._soundID) + + def _onActivateEvent(self, evt: wx.ActivateEvent): + evt.Skip() + + def _onShowEvent(self, evt: wx.ShowEvent): + """Event handler for when the dialog is shown. + + Responsible for playing the alert sound and focusing the default button. + """ + if evt.IsShown(): + self._playSound() + if (defaultItem := self.GetDefaultItem()) is not None: + defaultItem.SetFocus() + self.Raise() + evt.Skip() + + def _onCloseEvent(self, evt: wx.CloseEvent): + """Event handler for when the dialog is asked to close. + + Responsible for calling fallback event handlers and scheduling dialog distruction. + """ + if not evt.CanVeto(): + # We must close the dialog, regardless of state. + self.Hide() + self._executeCommand(self._getFallbackActionOrFallback(), _canCallClose=False) + log.debug(f"Removing {self!r} from instances.") + self._instances.remove(self) + if self.IsModal(): + self.EndModal(self.GetReturnCode()) + self.Destroy() + return + if self.GetReturnCode() == 0: + # No button has been pressed, so this must be a close event from elsewhere. + try: + command = self._getFallbackAction() + except KeyError: + log.error("Unable to get fallback action from commands. This indicates incorrect usage.") + command = None + if command is None or not command.closesDialog: + evt.Veto() + return + self.Hide() + self._executeCommand(command, _canCallClose=False) + self.Hide() + if self.IsModal(): + self.EndModal(self.GetReturnCode()) + log.debug("Queueing {self!r} for destruction") + self.DestroyLater() + log.debug(f"Removing {self!r} from instances.") + self._instances.remove(self) + + def _onButtonEvent(self, evt: wx.CommandEvent): + """Event handler for button presses. + + Responsible for executing commands associated with buttons. + """ + id = evt.GetId() + log.debug(f"Got button event on {id=}") + try: + self._executeCommand(self._commands[id]) + except KeyError: + log.debug(f"No command registered for {id=}.") + + def _onDestroyEvent(self, evt: wx.WindowDestroyEvent): + """Ensures this instances is removed if the default close event handler is not called.""" + if self in self._instances: + log.debug(f"Removing {self!r} from instances.") + self._instances.remove(self) + + def _executeCommand( + self, + command: _Command, + *, + _canCallClose: bool = True, + ): + """Execute a command on this dialog. + + :param command: Command to execute. + :param _canCallClose: Whether or not to close the dialog if the command says to, defaults to True. + Set to False when calling from a close handler. + """ + callback, close, returnCode = command + close &= _canCallClose + if callback is not None: + if close: + self.Hide() + callback() + if close: + self.SetReturnCode(returnCode) + self.Close() + + # endregion + + +def _messageBoxShim(message: str, caption: str, style: int, parent: wx.Window | None): + """Display a message box with the given message, caption, style, and parent window. + + Shim between :fun:`gui.message.messageBox` and :class:`MessageDialog`. + Must be called from the GUI thread. + + :param message: The message to display. + :param caption: Title of the message box. + :param style: See :fun:`wx.MessageBox`. + :param parent: Parent of the dialog. If None, :data:`gui.mainFrame` will be used. + :raises Exception: Any exception raised by attempting to create a message box. + :return: See :fun:`wx.MessageBox`. + """ + dialog = MessageDialog( + parent=parent, + message=message, + title=caption, + dialogType=_messageBoxIconStylesToMessageDialogType(style), + buttons=_messageBoxButtonStylesToMessageDialogButtons(style), + ) + return _messageDialogReturnCodeToMessageBoxReturnCode(dialog.ShowModal()) + + +def _messageDialogReturnCodeToMessageBoxReturnCode(returnCode: ReturnCode) -> int: + """Map from an instance of :class:`ReturnCode` to an int as returned by :fun:`wx.MessageBox`. + + :param returnCode: Return from :class:`MessageDialog`. + :raises ValueError: If the return code is not supported by :fun:`wx.MessageBox`. + :return: Integer as would be returned by :fun:`wx.MessageBox`. + .. note:: Only YES, NO, OK, CANCEL and HELP returns are supported by :fun:`wx.MessageBox`, and thus by this function. + """ + match returnCode: + case ReturnCode.YES: + return wx.YES + case ReturnCode.NO: + return wx.NO + case ReturnCode.CANCEL: + return wx.CANCEL + case ReturnCode.OK: + return wx.OK + case ReturnCode.HELP: + return wx.HELP + case _: + raise ValueError(f"Unsupported return for wx.MessageBox: {returnCode}") + + +def _messageBoxIconStylesToMessageDialogType(flags: int) -> DialogType: + """Map from a bitmask of styles as expected by :fun:`wx.MessageBox` to a :Class:`DialogType`. + + :param flags: Style flags. + :return: Corresponding dialog type. + .. note:: This may not be a one-to-one correspondance, as not all icon styles supported by :fun:`wx.MessageBox` are associated with a :class:`DialogType`. + """ + # Order of precedence seems to be none, then error, then warning. + if flags & wx.ICON_NONE: + return DialogType.STANDARD + elif flags & wx.ICON_ERROR: + return DialogType.ERROR + elif flags & wx.ICON_WARNING: + return DialogType.WARNING + else: + return DialogType.STANDARD + + +def _messageBoxButtonStylesToMessageDialogButtons(flags: int) -> tuple[Button, ...]: + """Map from a bitmask of styles as expected by :fun:`wx.MessageBox` to a list of :class:`Button`s. + + This function will always return a tuple of at least one button, typically an OK button. + + :param flags: Style flags. + :return: Tuple of :class:`Button` instances. + .. note:: :fun:`wx.MessageBox` only supports YES, NO, OK, CANCEL and HELP buttons, so this function only supports those buttons too. + Providing other buttons will fail silently. + .. note:: Providing `wx.CANCEL_DEFAULT` without `wx.CANCEL`, or `wx.NO_DEFAULT` without `wx.NO` is invalid. + Wx will raise an assertion error about this, but wxPython will still create the dialog. + Providing these invalid combinations to this function fails silently. + """ + buttons: list[Button] = [] + if flags & (wx.YES | wx.NO): + # Wx will add yes and no buttons, even if only one of wx.YES or wx.NO is given. + buttons.extend( + (DefaultButton.YES, DefaultButton.NO._replace(defaultFocus=bool(flags & wx.NO_DEFAULT))), + ) + else: + buttons.append(DefaultButton.OK) + if flags & wx.CANCEL: + buttons.append( + DefaultButton.CANCEL._replace( + defaultFocus=(flags & wx.CANCEL_DEFAULT) & ~(flags & wx.NO & wx.NO_DEFAULT), + ), + ) + if flags & wx.HELP: + buttons.append(DefaultButton.HELP) + return tuple(buttons) diff --git a/source/gui/nvdaControls.py b/source/gui/nvdaControls.py index 66016ebc01f..838c61b52ce 100644 --- a/source/gui/nvdaControls.py +++ b/source/gui/nvdaControls.py @@ -11,6 +11,7 @@ OrderedDict, Type, ) +import warnings import wx from wx.lib import scrolledpanel @@ -21,13 +22,12 @@ FeatureFlag, FlagValueEnum as FeatureFlagEnumT, ) +import gui.message from .dpiScalingHelper import DpiScalingHelperMixin from . import ( guiHelper, - contextHelp, ) import winUser -import winsound from collections.abc import Callable @@ -270,120 +270,64 @@ def __init__(self, *args, **kwargs): DpiScalingHelperMixin.__init__(self, self.GetHandle()) -class MessageDialog(DPIScaledDialog): - """Provides a more flexible message dialog. Consider overriding _addButtons, to set your own - buttons and behaviour. +class MessageDialog(gui.message.MessageDialog): + """Provides a more flexible message dialog. + + .. warning:: This class is deprecated. + Use :class:`gui.messageDialog.MessageDialog` instead. + This class is an adapter around that class, and will be removed in 2026.1. + + Consider overriding _addButtons, to set your own buttons and behaviour. """ + # We don't want the new message dialog's guard rails, as they may be incompatible with old code + _FAIL_ON_NO_BUTTONS = False + _FAIL_ON_NONMAIN_THREAD = False + # Dialog types currently supported DIALOG_TYPE_STANDARD = 1 DIALOG_TYPE_WARNING = 2 DIALOG_TYPE_ERROR = 3 - _DIALOG_TYPE_ICON_ID_MAP = { - # DIALOG_TYPE_STANDARD is not in the map, since we wish to use the default icon provided by wx - DIALOG_TYPE_ERROR: wx.ART_ERROR, - DIALOG_TYPE_WARNING: wx.ART_WARNING, - } - - _DIALOG_TYPE_SOUND_ID_MAP = { - # DIALOG_TYPE_STANDARD is not in the map, since there should be no sound for a standard dialog. - DIALOG_TYPE_ERROR: winsound.MB_ICONHAND, - DIALOG_TYPE_WARNING: winsound.MB_ICONASTERISK, - } - - def _addButtons(self, buttonHelper: guiHelper.ButtonHelper) -> None: - """Adds ok / cancel buttons. Can be overridden to provide alternative functionality.""" - ok = buttonHelper.addButton( - self, - id=wx.ID_OK, - # Translators: An ok button on a message dialog. - label=_("OK"), - ) - ok.SetDefault() - ok.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.OK)) - - cancel = buttonHelper.addButton( - self, - id=wx.ID_CANCEL, - # Translators: A cancel button on a message dialog. - label=_("Cancel"), + @staticmethod + def _legacyDialogTypeToDialogType(dialogType: int) -> gui.message.DialogType: + match dialogType: + case MessageDialog.DIALOG_TYPE_ERROR: + return gui.message.DialogType.ERROR + case MessageDialog.DIALOG_TYPE_WARNING: + return gui.message.DialogType.WARNING + case _: + return gui.message.DialogType.STANDARD + + def __new__(cls, *args, **kwargs): + warnings.warn( + "gui.nvdaControls.MessageDialog is deprecated. Use gui.messageDialog.MessageDialog instead.", + DeprecationWarning, ) - cancel.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.CANCEL)) - - def _addContents(self, contentsSizer: guiHelper.BoxSizerHelper): - """Adds additional contents to the dialog, before the buttons. - Subclasses may implement this method. - """ - - def _setIcon(self, type): - try: - iconID = self._DIALOG_TYPE_ICON_ID_MAP[type] - except KeyError: - # type not found, use default icon. - return - icon = wx.ArtProvider.GetIcon(iconID, client=wx.ART_MESSAGE_BOX) - self.SetIcon(icon) - - def _setSound(self, type): - try: - self._soundID = self._DIALOG_TYPE_SOUND_ID_MAP[type] - except KeyError: - # type not found, no sound. - self._soundID = None - return - - def _playSound(self): - if self._soundID is not None: - winsound.MessageBeep(self._soundID) - - def __init__(self, parent, title, message, dialogType=DIALOG_TYPE_STANDARD): - DPIScaledDialog.__init__(self, parent, title=title) - - self._setIcon(dialogType) - self._setSound(dialogType) - self.Bind(wx.EVT_SHOW, self._onShowEvt, source=self) - self.Bind(wx.EVT_ACTIVATE, self._onDialogActivated, source=self) - - mainSizer = wx.BoxSizer(wx.VERTICAL) - contentsSizer = guiHelper.BoxSizerHelper(parent=self, orientation=wx.VERTICAL) + return super().__new__(cls, *args, **kwargs) - # Double ampersand in the dialog's label to avoid this character to be interpreted as an accelerator. - label = message.replace("&", "&&") - text = wx.StaticText(self, label=label) - text.Wrap(self.scaleSize(self.GetSize().Width)) - contentsSizer.addItem(text) - self._addContents(contentsSizer) - - buttonHelper = guiHelper.ButtonHelper(wx.HORIZONTAL) - self._addButtons(buttonHelper) - contentsSizer.addDialogDismissButtons(buttonHelper) - - mainSizer.Add( - contentsSizer.sizer, - border=guiHelper.BORDER_FOR_DIALOGS, - flag=wx.ALL, + def __init__( + self, + parent: wx.Window | None, + title: str, + message: str, + dialogType: int = DIALOG_TYPE_STANDARD, + ): + super().__init__( + parent, + message=message, + title=title, + dialogType=self._legacyDialogTypeToDialogType(dialogType), + buttons=None, ) - mainSizer.Fit(self) - self.SetSizer(mainSizer) - self.CentreOnScreen() - def _onDialogActivated(self, evt): - evt.Skip() - - def _onShowEvt(self, evt): - """ - :type evt: wx.ShowEvent - """ - if evt.IsShown(): - self._playSound() - evt.Skip() + def _addButtons(self, buttonHelper: guiHelper.ButtonHelper) -> None: + """Adds ok / cancel buttons. Can be overridden to provide alternative functionality.""" + self.addOkButton(returnCode=wx.OK) + self.addCancelButton(returnCode=wx.CANCEL) -class _ContinueCancelDialog( - contextHelp.ContextHelpMixin, - MessageDialog, -): +class _ContinueCancelDialog(MessageDialog): """ This implementation of a `gui.nvdaControls.MessageDialog`, provides `Continue` and `Cancel` buttons as its controls. These serve the same functions as `OK` and `Cancel` in other dialogs, but may be more desirable in some situations. @@ -414,29 +358,24 @@ def __init__( if helpId is not None: self.helpId = helpId super().__init__(parent, title, message, dialogType) + if helpId is not None: + # Help event has already been bound (in supersuperclass), so we need to re-bind it. + self.bindHelpEvent(helpId, self) def _addButtons(self, buttonHelper: guiHelper.ButtonHelper) -> None: """Override to add Continue and Cancel buttons.""" - - # Note: the order of the Continue and Cancel buttons is important, because running SetDefault() - # on the Cancel button while the Continue button is first, has no effect. Therefore the only way to - # allow a caller to make Cancel the default, is to put it first. - def _makeContinue(self, buttonHelper: guiHelper.ButtonHelper) -> wx.Button: + self.addOkButton( # Translators: The label for the Continue button in an NVDA dialog. - return buttonHelper.addButton(self, id=wx.ID_OK, label=_("&Continue")) - - def _makeCancel(self, buttonHelper: guiHelper.ButtonHelper) -> wx.Button: + label=_("&Continue"), + returnCode=wx.OK, + defaultFocus=self.continueButtonFirst, + ) + self.addCancelButton( # Translators: The label for the Cancel button in an NVDA dialog. - return buttonHelper.addButton(self, id=wx.ID_CANCEL, label=_("Cancel")) - - if self.continueButtonFirst: - continueButton = _makeContinue(self, buttonHelper) - cancelButton = _makeCancel(self, buttonHelper) - else: - cancelButton = _makeCancel(self, buttonHelper) - continueButton = _makeContinue(self, buttonHelper) - continueButton.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.OK)) - cancelButton.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.CANCEL)) + label=_("Cancel"), + returnCode=wx.CANCEL, + defaultFocus=not self.continueButtonFirst, + ) class EnhancedInputSlider(wx.Slider): diff --git a/source/updateCheck.py b/source/updateCheck.py index de8fbdf983b..36f21dd2791 100644 --- a/source/updateCheck.py +++ b/source/updateCheck.py @@ -7,6 +7,7 @@ @note: This module may raise C{RuntimeError} on import if update checking for this build is not supported. """ +from collections.abc import Callable from datetime import datetime from typing import ( Any, @@ -15,6 +16,7 @@ Tuple, ) from uuid import uuid4 + import garbageHandler import globalVars import config @@ -630,40 +632,72 @@ def onReviewAddonsButton(self, evt): ) displayDialogAsModal(incompatibleAddons) + @property + def callback(self) -> Callable[[int], None]: + """A callback method which either performs or postpones the update, based on the passed return code.""" + return self._callbackFactory( + destPath=self.destPath, + version=self.version, + apiVersion=self.apiVersion, + backCompatTo=self.backCompatTo, + ) + + @staticmethod + def _callbackFactory( + destPath: str, + version: str, + apiVersion: addonAPIVersion.AddonApiVersionT, + backCompatTo: addonAPIVersion.AddonApiVersionT, + ) -> Callable[[int], None]: + """Create a callback method suitable for passing to :meth:`gui.runScriptModalDialog`. + + See class initialisation documentation for the meaning of parameters. + + :return: A callable which performs the appropriate update action based on the return code passed to it. + """ + + def callback(res: int): + match res: + case wx.ID_OK: + _executeUpdate(destPath) + + case wx.ID_CLOSE: + finalDest = os.path.join(storeUpdatesDir, os.path.basename(destPath)) + try: + # #9825: behavior of os.rename(s) has changed (see https://bugs.python.org/issue28356). + # In Python 2, os.renames did rename files across drives, no longer allowed in Python 3 (error 17 (cannot move files across drives) is raised). + # This is prominent when trying to postpone an update for portable copy of NVDA if this runs from a USB flash drive or another internal storage device. + # Therefore use kernel32::MoveFileEx with copy allowed (0x2) flag set. + # TODO: consider moving to shutil.move, which supports moves across filesystems. + winKernel.moveFileEx(destPath, finalDest, winKernel.MOVEFILE_COPY_ALLOWED) + except: # noqa: E722 + log.debugWarning( + f"Unable to rename the file from {destPath} to {finalDest}", + exc_info=True, + ) + gui.messageBox( + # Translators: The message when a downloaded update file could not be preserved. + _("Unable to postpone update."), + # Translators: The title of the message when a downloaded update file could not be preserved. + _("Error"), + wx.OK | wx.ICON_ERROR, + ) + finalDest = destPath + state["pendingUpdateFile"] = finalDest + state["pendingUpdateVersion"] = version + state["pendingUpdateAPIVersion"] = apiVersion + state["pendingUpdateBackCompatToAPIVersion"] = backCompatTo + # Postponing an update indicates that the user is likely interested in getting a reminder. + # Therefore, clear the dontRemindVersion. + state["dontRemindVersion"] = None + saveState() + + return callback + def onUpdateButton(self, evt): - _executeUpdate(self.destPath) self.EndModal(wx.ID_OK) def onPostponeButton(self, evt): - finalDest = os.path.join(storeUpdatesDir, os.path.basename(self.destPath)) - try: - # #9825: behavior of os.rename(s) has changed (see https://bugs.python.org/issue28356). - # In Python 2, os.renames did rename files across drives, no longer allowed in Python 3 (error 17 (cannot move files across drives) is raised). - # This is prominent when trying to postpone an update for portable copy of NVDA if this runs from a USB flash drive or another internal storage device. - # Therefore use kernel32::MoveFileEx with copy allowed (0x2) flag set. - # TODO: consider moving to shutil.move, which supports moves across filesystems. - winKernel.moveFileEx(self.destPath, finalDest, winKernel.MOVEFILE_COPY_ALLOWED) - except: # noqa: E722 - log.debugWarning( - "Unable to rename the file from {} to {}".format(self.destPath, finalDest), - exc_info=True, - ) - gui.messageBox( - # Translators: The message when a downloaded update file could not be preserved. - _("Unable to postpone update."), - # Translators: The title of the message when a downloaded update file could not be preserved. - _("Error"), - wx.OK | wx.ICON_ERROR, - ) - finalDest = self.destPath - state["pendingUpdateFile"] = finalDest - state["pendingUpdateVersion"] = self.version - state["pendingUpdateAPIVersion"] = self.apiVersion - state["pendingUpdateBackCompatToAPIVersion"] = self.backCompatTo - # Postponing an update indicates that the user is likely interested in getting a reminder. - # Therefore, clear the dontRemindVersion. - state["dontRemindVersion"] = None - saveState() self.EndModal(wx.ID_CLOSE) @@ -817,14 +851,16 @@ def _error(self): def _downloadSuccess(self): self._stopped() + askInstallDialog = UpdateAskInstallDialog( + parent=gui.mainFrame, + destPath=self.destPath, + version=self.version, + apiVersion=self.apiVersion, + backCompatTo=self.backCompatToAPIVersion, + ) gui.runScriptModalDialog( - UpdateAskInstallDialog( - parent=gui.mainFrame, - destPath=self.destPath, - version=self.version, - apiVersion=self.apiVersion, - backCompatTo=self.backCompatToAPIVersion, - ), + askInstallDialog, + callback=askInstallDialog.callback, ) diff --git a/source/visionEnhancementProviders/screenCurtain.py b/source/visionEnhancementProviders/screenCurtain.py index f1f4b8174e9..769a0e4977d 100644 --- a/source/visionEnhancementProviders/screenCurtain.py +++ b/source/visionEnhancementProviders/screenCurtain.py @@ -192,19 +192,19 @@ def _exitDialog(self, result: int): settingsStorage._saveSpecificSettings(settingsStorage, settingsStorage.supportedSettings) self.EndModal(result) - def _onDialogActivated(self, evt): + def _onActivateEvent(self, evt: wx.ActivateEvent): # focus is normally set to the first child, however, we want people to easily be able to cancel this # dialog - super()._onDialogActivated(evt) + super()._onActivateEvent(evt) self.noButton.SetFocus() - def _onShowEvt(self, evt): + def _onShowEvent(self, evt: wx.ShowEvent): """When no other dialogs have been opened first, focus lands in the wrong place (on the checkbox), so we correct it after the dialog is opened. """ if evt.IsShown(): self.noButton.SetFocus() - super()._onShowEvt(evt) + super()._onShowEvent(evt) class ScreenCurtainGuiPanel( diff --git a/tests/system/robot/startupShutdownNVDA.robot b/tests/system/robot/startupShutdownNVDA.robot index 543fe240515..12b442f4cb9 100644 --- a/tests/system/robot/startupShutdownNVDA.robot +++ b/tests/system/robot/startupShutdownNVDA.robot @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2018 NV Access Limited +# Copyright (C) 2018-2024 NV Access Limited # 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 *** Settings *** @@ -47,8 +47,6 @@ Quits from keyboard with welcome dialog open Quits from keyboard with about dialog open [Documentation] Starts NVDA and ensures that it can be quit with the about dialog open [Setup] start NVDA standard-dontShowWelcomeDialog.ini - # Excluded to be fixed still (#12976) - [Tags] excluded_from_build open about dialog from menu quits from keyboard # run test diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py new file mode 100644 index 00000000000..efe5f0a68f8 --- /dev/null +++ b/tests/unit/test_messageDialog.py @@ -0,0 +1,969 @@ +# 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) 2024 NV Access Limited + +"""Unit tests for the message dialog API.""" + +from copy import deepcopy +import unittest +from unittest.mock import ANY, MagicMock, Mock, PropertyMock, patch, sentinel + +import wx +from gui.message import _Command, DefaultButtonSet, DialogType, EscapeCode, ReturnCode +from gui.message import ( + _messageBoxButtonStylesToMessageDialogButtons, +) +from parameterized import parameterized +from typing import Any, Iterable, NamedTuple +from concurrent.futures import ThreadPoolExecutor + +from gui.message import Button +from gui.message import MessageDialog + + +NO_CALLBACK = (EscapeCode.NO_FALLBACK, None) + + +def dummyCallback1(*a): + pass + + +def dummyCallback2(*a): + pass + + +def getDialogState(dialog: MessageDialog): + """Capture internal state of a :class:`gui.messageDialog.MessageDialog` for later analysis. + + Currently this only captures state relevant to adding buttons. + Further tests wishing to use this dialog should be sure to add any state potentially modified by the functions under test. + + As this is currently only used to ensure internal state does not change between calls, the order of return should be considered arbitrary. + """ + return ( + {id: dialog.FindWindow(id).GetLabel() for id in dialog.GetMainButtonIds()}, + deepcopy(dialog._commands), + item.GetId() if (item := dialog.GetDefaultItem()) is not None else None, + dialog.GetEscapeId(), + dialog._isLayoutFullyRealized, + ) + + +def mockDialogFactory(isBlocking: bool = False) -> MagicMock: + """Mock a dialog with certain properties set. + + :param isBlocking: Whether the mocked dialog is blocking. + :return: A mock with the same API as :class:`MessageDialog`. + """ + mock = MagicMock(spec_set=MessageDialog) + type(mock).isBlocking = PropertyMock(return_value=isBlocking) + return mock + + +class AddDefaultButtonHelpersArgList(NamedTuple): + func: str + expectedButtons: Iterable[int] + expectedHasFallback: bool = False + expectedFallbackId: int = wx.ID_NONE + + +class MethodCall(NamedTuple): + name: str + args: tuple[Any, ...] = tuple() + kwargs: dict[str, Any] = dict() + + +class FocusBlockingInstancesDialogs(NamedTuple): + dialog: MagicMock + expectedRaise: bool + expectedSetFocus: bool + + +class SubsequentCallArgList(NamedTuple): + label: str + meth1: MethodCall + meth2: MethodCall + + +class ExecuteCommandArgList(NamedTuple): + label: str + closesDialog: bool + canCallClose: bool + expectedCloseCalled: bool + + +class BlockingInstancesExistArgList(NamedTuple): + label: str + instances: tuple[MagicMock, ...] + expectedBlockingInstancesExist: bool + + +class IsBlockingArgList(NamedTuple): + label: str + isModal: bool + hasFallback: bool + expectedIsBlocking: bool + + +class WxTestBase(unittest.TestCase): + """Base class for test cases which need wx to be initialised.""" + + def setUp(self) -> None: + self.app = wx.App() + + +class MDTestBase(WxTestBase): + """Base class for test cases needing a MessageDialog instance to work on.""" + + def setUp(self) -> None: + super().setUp() + self.dialog = MessageDialog(None, "Test dialog", buttons=None) + + +@patch.object(wx.ArtProvider, "GetIconBundle") +class Test_MessageDialog_Icons(MDTestBase): + """Test that message dialog icons are set correctly.""" + + @parameterized.expand(((DialogType.ERROR,), (DialogType.WARNING,))) + def test_setIconWithTypeWithIcon(self, mocked_GetIconBundle: MagicMock, type: DialogType): + """Test that setting the dialog's icons has an effect when the dialog's type has icons.""" + mocked_GetIconBundle.return_value = wx.IconBundle() + self.dialog._setIcon(type) + mocked_GetIconBundle.assert_called_once() + + @parameterized.expand(((DialogType.STANDARD,),)) + def test_setIconWithTypeWithoutIcon(self, mocked_GetIconBundle: MagicMock, type: DialogType): + """Test that setting the dialog's icons doesn't have an effect when the dialog's type doesn't have icons.""" + type = DialogType.STANDARD + self.dialog._setIcon(type) + mocked_GetIconBundle.assert_not_called() + + +@patch("winsound.MessageBeep") +class Test_MessageDialog_Sounds(MDTestBase): + """Test that message dialog sounds are set and played correctly.""" + + @parameterized.expand(((DialogType.ERROR,), (DialogType.WARNING,))) + def test_playSoundWithTypeWithSound(self, mocked_MessageBeep: MagicMock, type: DialogType): + """Test that sounds are played for message dialogs whose type has an associated sound.""" + self.dialog._setSound(type) + self.dialog._playSound() + mocked_MessageBeep.assert_called_once() + + @parameterized.expand(((DialogType.STANDARD,),)) + def test_playSoundWithTypeWithoutSound(self, mocked_MessageBeep: MagicMock, type: DialogType): + """Test that no sounds are played for message dialogs whose type has an associated sound.""" + self.dialog._setSound(type) + self.dialog._playSound() + mocked_MessageBeep.assert_not_called() + + +class Test_MessageDialog_Buttons(MDTestBase): + @parameterized.expand( + ( + AddDefaultButtonHelpersArgList( + func="addOkButton", + expectedButtons=(wx.ID_OK,), + expectedHasFallback=True, + expectedFallbackId=wx.ID_OK, + ), + AddDefaultButtonHelpersArgList( + func="addCancelButton", + expectedButtons=(wx.ID_CANCEL,), + expectedHasFallback=True, + expectedFallbackId=wx.ID_CANCEL, + ), + AddDefaultButtonHelpersArgList(func="addYesButton", expectedButtons=(wx.ID_YES,)), + AddDefaultButtonHelpersArgList(func="addNoButton", expectedButtons=(wx.ID_NO,)), + AddDefaultButtonHelpersArgList(func="addSaveButton", expectedButtons=(wx.ID_SAVE,)), + AddDefaultButtonHelpersArgList(func="addApplyButton", expectedButtons=(wx.ID_APPLY,)), + AddDefaultButtonHelpersArgList(func="addCloseButton", expectedButtons=(wx.ID_CLOSE,)), + AddDefaultButtonHelpersArgList(func="addHelpButton", expectedButtons=(wx.ID_HELP,)), + AddDefaultButtonHelpersArgList( + func="addOkCancelButtons", + expectedButtons=(wx.ID_OK, wx.ID_CANCEL), + expectedHasFallback=True, + expectedFallbackId=wx.ID_CANCEL, + ), + AddDefaultButtonHelpersArgList(func="addYesNoButtons", expectedButtons=(wx.ID_YES, wx.ID_NO)), + AddDefaultButtonHelpersArgList( + func="addYesNoCancelButtons", + expectedButtons=(wx.ID_YES, wx.ID_NO, wx.ID_CANCEL), + expectedHasFallback=True, + expectedFallbackId=wx.ID_CANCEL, + ), + AddDefaultButtonHelpersArgList( + func="addSaveNoCancelButtons", + expectedButtons=(wx.ID_SAVE, wx.ID_NO, wx.ID_CANCEL), + expectedHasFallback=True, + expectedFallbackId=wx.ID_CANCEL, + ), + ), + ) + def test_addDefaultButtonHelpers( + self, + func: str, + expectedButtons: Iterable[int], + expectedHasFallback: bool, + expectedFallbackId: int, + ): + """Test the various /add*buttons?/ functions.""" + getattr(self.dialog, func)() + with self.subTest("Test all expected buttons are in main buttons"): + self.assertCountEqual(self.dialog.GetMainButtonIds(), expectedButtons) + for id in expectedButtons: + with self.subTest("Check that all buttons have the expected type", id=id): + self.assertIsInstance(self.dialog.FindWindowById(id), wx.Button) + with self.subTest("Test whether the fallback status is as expected."): + self.assertEqual(self.dialog.hasFallback, expectedHasFallback) + with self.subTest( + "Test whether getting the fallback action returns the expected action type and return code", + ): + actualFallbackAction = self.dialog._getFallbackAction() + if expectedHasFallback: + self.assertIsNotNone(actualFallbackAction) + self.assertEqual(actualFallbackAction.returnCode, expectedFallbackId) + else: + self.assertIsNone(actualFallbackAction) + + def test_addButtonWithDefaultFocus(self): + """Test adding a button with default focus.""" + self.dialog.addButton( + Button(label="Custom", id=ReturnCode.CUSTOM_1, defaultFocus=True), + ) + self.assertEqual(self.dialog.GetDefaultItem().GetId(), ReturnCode.CUSTOM_1) + + def test_addButtonWithFallbackAction(self): + """Test adding a button with fallback action.""" + self.dialog.addButton( + Button( + label="Custom", + id=ReturnCode.CUSTOM_1, + fallbackAction=True, + closesDialog=True, + ), + ) + command = self.dialog._getFallbackAction() + self.assertEqual(command.returnCode, ReturnCode.CUSTOM_1) + self.assertTrue(command.closesDialog) + + def test_addButtonWithNonClosingFallbackAction(self): + """Test adding a button with fallback action that does not close the dialog.""" + self.dialog.addButton( + Button( + label="Custom", + id=ReturnCode.CUSTOM_1, + fallbackAction=True, + closesDialog=False, + ), + ) + command = self.dialog._getFallbackAction() + self.assertEqual(command.returnCode, ReturnCode.CUSTOM_1) + self.assertTrue(command.closesDialog) + + @parameterized.expand( + ( + SubsequentCallArgList( + "buttons_same_id", + meth1=MethodCall("addOkButton", kwargs={"callback": dummyCallback1}), + meth2=MethodCall("addOkButton", kwargs={"callback": dummyCallback2}), + ), + SubsequentCallArgList( + "Button_then_ButtonSet_containing_same_id", + meth1=MethodCall("addOkButton"), + meth2=MethodCall("addOkCancelButtons"), + ), + SubsequentCallArgList( + "ButtonSet_then_Button_with_id_from_set", + meth1=MethodCall("addOkCancelButtons"), + meth2=MethodCall("addOkButton"), + ), + SubsequentCallArgList( + "ButtonSets_containing_same_id", + meth1=MethodCall("addOkCancelButtons"), + meth2=MethodCall("addYesNoCancelButtons"), + ), + ), + ) + def test_subsequentAdd(self, _, func1: MethodCall, func2: MethodCall): + """Test that adding buttons that already exist in the dialog fails.""" + getattr(self.dialog, func1.name)(*func1.args, **func1.kwargs) + oldState = getDialogState(self.dialog) + with self.subTest("Test calling second function raises."): + self.assertRaises(KeyError, getattr(self.dialog, func2.name), *func2.args, **func2.kwargs) + with self.subTest("Check state hasn't changed."): + self.assertEqual(oldState, getDialogState(self.dialog)) + + def test_setButtonLabelExistantId(self): + """Test that setting the label of a button works.""" + NEW_LABEL = "test" + self.dialog.addOkButton() + self.dialog.setButtonLabel(ReturnCode.OK, NEW_LABEL) + self.assertEqual(self.dialog.FindWindow(ReturnCode.OK).GetLabel(), NEW_LABEL) + + def test_setButtonLabelNonexistantId(self): + """Test that setting the label of a button that does not exist in the dialog fails.""" + self.dialog.addOkButton() + oldState = getDialogState(self.dialog) + self.assertRaises(KeyError, self.dialog.setButtonLabel, ReturnCode.CANCEL, "test") + self.assertEqual(oldState, getDialogState(self.dialog)) + + def test_setButtonLabelNotAButton(self): + """Test that calling setButtonLabel with an id that does not refer to a wx.Button fails as expected.""" + messageControlId = self.dialog._messageControl.GetId() + # This is not a case that should be encountered unless users tamper with internal state. + self.dialog._commands[messageControlId] = _Command( + closesDialog=True, + callback=None, + returnCode=ReturnCode.APPLY, + ) + with self.assertRaises(TypeError): + self.dialog.setButtonLabel(messageControlId, "test") + + def test_setButtonLabelsCountMismatch(self): + with self.assertRaises(ValueError): + """Test that calling _setButtonLabels with a mismatched collection of IDs and labels fails as expected.""" + self.dialog._setButtonLabels((ReturnCode.APPLY, ReturnCode.CANCEL), ("Apply", "Cancel", "Ok")) + + def test_setButtonLabelsExistantIds(self): + """Test that setting multiple button labels at once works.""" + NEW_YES_LABEL, NEW_NO_LABEL, NEW_CANCEL_LABEL = "test 1", "test 2", "test 3" + self.dialog.addYesNoCancelButtons() + self.dialog.setYesNoCancelLabels(NEW_YES_LABEL, NEW_NO_LABEL, NEW_CANCEL_LABEL) + self.assertEqual(self.dialog.FindWindow(ReturnCode.YES).GetLabel(), NEW_YES_LABEL) + self.assertEqual(self.dialog.FindWindow(ReturnCode.NO).GetLabel(), NEW_NO_LABEL) + self.assertEqual(self.dialog.FindWindow(ReturnCode.CANCEL).GetLabel(), NEW_CANCEL_LABEL) + + def test_setSomeButtonLabels(self): + """Test that setting the labels of a subset of the existant buttons in the dialog works.""" + NEW_YES_LABEL, NEW_NO_LABEL = "test 1", "test 2" + self.dialog.addYesNoCancelButtons() + OLD_CANCEL_LABEL = self.dialog.FindWindow(ReturnCode.CANCEL).GetLabel() + self.dialog.setYesNoLabels(NEW_YES_LABEL, NEW_NO_LABEL) + self.assertEqual(self.dialog.FindWindow(ReturnCode.YES).GetLabel(), NEW_YES_LABEL) + self.assertEqual(self.dialog.FindWindow(ReturnCode.NO).GetLabel(), NEW_NO_LABEL) + self.assertEqual(self.dialog.FindWindow(ReturnCode.CANCEL).GetLabel(), OLD_CANCEL_LABEL) + + @parameterized.expand( + ( + SubsequentCallArgList( + "noExistantIds", + meth1=MethodCall("addYesNoButtons"), + meth2=MethodCall("setOkCancelLabels", ("Test 1", "Test 2")), + ), + SubsequentCallArgList( + "ExistantAndNonexistantIds", + meth1=MethodCall("addYesNoCancelButtons"), + meth2=MethodCall("setOkCancelLabels", ("Test 1", "Test 2")), + ), + ), + ) + def test_setButtonLabelsBadIds(self, _, setupFunc: MethodCall, setLabelFunc: MethodCall): + """Test that attempting to set button labels with IDs that don't appear in the dialog fails and does not alter the dialog.""" + getattr(self.dialog, setupFunc.name)(*setupFunc.args, **setupFunc.kwargs) + oldState = getDialogState(self.dialog) + with self.subTest("Test that the operation raises."): + self.assertRaises( + KeyError, + getattr(self.dialog, setLabelFunc.name), + *setLabelFunc.args, + **setLabelFunc.kwargs, + ) + with self.subTest("Check state hasn't changed."): + self.assertEqual(oldState, getDialogState(self.dialog)) + + def test_addButtonFromButtonWithOverrides(self): + """Test adding a button from a :class:`Button` with overrides for its properties.""" + LABEL = "test" + CALLBACK = dummyCallback1 + DEFAULT_FOCUS = FALLBACK_ACTION = CLOSES_DIALOG = True + RETURN_CODE = 1 + self.dialog.addYesButton().addApplyButton( + label=LABEL, + callback=CALLBACK, + defaultFocus=DEFAULT_FOCUS, + fallbackAction=FALLBACK_ACTION, + closesDialog=CLOSES_DIALOG, + returnCode=RETURN_CODE, + ) + self.assertEqual(self.dialog.FindWindow(ReturnCode.APPLY).GetLabel(), LABEL) + self.assertEqual(self.dialog._commands[ReturnCode.APPLY].callback, CALLBACK) + self.assertEqual(self.dialog._commands[ReturnCode.APPLY].closesDialog, CLOSES_DIALOG) + self.assertEqual(self.dialog._commands[ReturnCode.APPLY].returnCode, RETURN_CODE) + self.assertEqual(self.dialog.GetEscapeId(), ReturnCode.APPLY) + + def test_addButtonsNonuniqueIds(self): + """Test that adding a set of buttons with non-unique IDs fails.""" + with self.assertRaises(KeyError): + self.dialog.addButtons((*DefaultButtonSet.OK_CANCEL, *DefaultButtonSet.YES_NO_CANCEL)) + + def test_setDefaultFocusGoodId(self): + """Test that setting the default focus works as expected.""" + self.dialog.addOkCancelButtons() + self.dialog.setDefaultFocus(ReturnCode.CANCEL) + self.assertEqual(self.dialog.GetDefaultItem().GetId(), ReturnCode.CANCEL) + + def test_setDefaultFocusBadId(self): + """Test that setting the default focus to an ID that doesn't exist in the dialog fails as expected.""" + self.dialog.addOkCancelButtons() + with self.assertRaises(KeyError): + self.dialog.setDefaultFocus(ReturnCode.APPLY) + + +class Test_MessageDialog_DefaultAction(MDTestBase): + def test_defaultActionDefaultEscape_OkCancel(self): + """Test that when adding OK and Cancel buttons with default escape code, that the fallback action is cancel.""" + self.dialog.addOkButton(callback=dummyCallback1).addCancelButton(callback=dummyCallback2) + command = self.dialog._getFallbackAction() + with self.subTest("Test getting the fallback action."): + self.assertEqual(command.returnCode, ReturnCode.CANCEL) + self.assertEqual(command.callback, dummyCallback2) + self.assertTrue(command.closesDialog) + with self.subTest( + "Test getting the fallback action or fallback returns the same as getting the fallback action.", + ): + self.assertEqual(command, self.dialog._getFallbackActionOrFallback()) + + def test_defaultActionDefaultEscape_CancelOk(self): + """Test that when adding cancel and ok buttons with default escape code, that the fallback action is cancel.""" + self.dialog.addCancelButton(callback=dummyCallback2).addOkButton(callback=dummyCallback1) + command = self.dialog._getFallbackAction() + with self.subTest("Test getting the fallback action."): + self.assertEqual(command.returnCode, ReturnCode.CANCEL) + self.assertEqual(command.callback, dummyCallback2) + self.assertTrue(command.closesDialog) + with self.subTest( + "Test getting the fallback action or fallback returns the same as getting the fallback action.", + ): + self.assertEqual(command, self.dialog._getFallbackActionOrFallback()) + + def test_defaultActionDefaultEscape_OkClose(self): + """Test that when adding OK and Close buttons with default escape code, that the fallback action is OK.""" + self.dialog.addOkButton(callback=dummyCallback1).addCloseButton(callback=dummyCallback2) + command = self.dialog._getFallbackAction() + with self.subTest("Test getting the fallback action."): + self.assertEqual(command.returnCode, ReturnCode.OK) + self.assertEqual(command.callback, dummyCallback1) + self.assertTrue(command.closesDialog) + with self.subTest( + "Test getting the fallback action or fallback returns the same as getting the fallback action.", + ): + self.assertEqual(command, self.dialog._getFallbackActionOrFallback()) + + def test_defaultActionDefaultEscape_CloseOk(self): + """Test that when adding Close and OK buttons with default escape code, that the fallback action is OK.""" + self.dialog.addCloseButton(callback=dummyCallback2).addOkButton(callback=dummyCallback1) + command = self.dialog._getFallbackAction() + with self.subTest("Test getting the fallback action."): + self.assertEqual(command.returnCode, ReturnCode.OK) + self.assertEqual(command.callback, dummyCallback1) + self.assertTrue(command.closesDialog) + with self.subTest( + "Test getting the fallback action or fallback returns the same as getting the fallback action.", + ): + self.assertEqual(command, self.dialog._getFallbackActionOrFallback()) + + def test_setFallbackActionExistantAction(self): + """Test that setting the fallback action results in the correct action being returned from both getFallbackAction and getFallbackActionOrFallback.""" + self.dialog.addYesNoButtons() + self.dialog.setFallbackAction(ReturnCode.YES) + command = self.dialog._getFallbackAction() + with self.subTest("Test getting the fallback action."): + self.assertEqual(command.returnCode, ReturnCode.YES) + self.assertIsNone(command.callback) + self.assertTrue(command.closesDialog) + with self.subTest( + "Test getting the fallback action or fallback returns the same as getting the fallback action.", + ): + self.assertEqual(command, self.dialog._getFallbackActionOrFallback()) + + def test_setFallbackActionNonexistantAction(self): + """Test that setting the fallback action to an action that has not been set up results in KeyError, and that a fallback action is returned from getFallbackActionOrFallback.""" + self.dialog.addYesNoButtons() + with self.subTest("Test getting the fallback action."): + with self.assertRaises(KeyError): + self.dialog.setFallbackAction(ReturnCode.APPLY) + with self.subTest("Test getting the fallback fallback action."): + self.assertIsNone(self.dialog._getFallbackAction()) + + def test_setFallbackActionNonclosingAction(self): + """Check that setting the fallback action to an action that does not close the dialog fails with a ValueError.""" + self.dialog.addOkButton().addApplyButton(closesDialog=False) + with self.subTest("Test setting the fallback action."): + with self.assertRaises(ValueError): + self.dialog.setFallbackAction(ReturnCode.APPLY) + + def test_getFallbackActionOrFallbackNoControls(self): + """Test that getFallbackActionOrFallback returns wx.ID_NONE and a close command with no callback when the dialog has no buttons.""" + command = self.dialog._getFallbackActionOrFallback() + self.assertEqual(command.returnCode, EscapeCode.NO_FALLBACK) + self.assertIsNotNone(command) + self.assertTrue(command.closesDialog) + + def test_getFallbackActionOrFallbackNoDefaultFocusClosingButton(self): + """Test that getFallbackActionOrFallback returns the first button when no fallback action or default focus is specified.""" + self.dialog.addApplyButton(closesDialog=False).addCloseButton() + self.assertIsNone(self.dialog.GetDefaultItem()) + command = self.dialog._getFallbackActionOrFallback() + self.assertEqual(command.returnCode, ReturnCode.CLOSE) + self.assertIsNotNone(command) + self.assertTrue(command.closesDialog) + + def test_getFallbackActionOrFallbackNoDefaultFocusNoClosingButton(self): + """Test that getFallbackActionOrFallback returns the first button when no fallback action or default focus is specified.""" + self.dialog.addApplyButton(closesDialog=False).addCloseButton(closesDialog=False) + self.assertIsNone(self.dialog.GetDefaultItem()) + command = self.dialog._getFallbackActionOrFallback() + self.assertEqual(command.returnCode, ReturnCode.APPLY) + self.assertIsNotNone(command) + self.assertTrue(command.closesDialog) + + def test_getFallbackActionOrFallbackNoDefaultAction(self): + """Test that getFallbackActionOrFallback returns the default focus if one is specified but there is no fallback action.""" + self.dialog.addApplyButton().addCloseButton() + self.dialog.setDefaultFocus(ReturnCode.CLOSE) + self.assertEqual(self.dialog.GetDefaultItem().GetId(), ReturnCode.CLOSE) + command = self.dialog._getFallbackActionOrFallback() + self.assertEqual(command.returnCode, ReturnCode.CLOSE) + self.assertIsNotNone(command) + self.assertTrue(command.closesDialog) + + def test_getFallbackActionOrFallbackCustomDefaultAction(self): + """Test that getFallbackActionOrFallback returns the custom defaultAction if set.""" + self.dialog.addApplyButton().addCloseButton() + self.dialog.setFallbackAction(ReturnCode.CLOSE) + command = self.dialog._getFallbackActionOrFallback() + self.assertEqual(command.returnCode, ReturnCode.CLOSE) + self.assertIsNotNone(command) + self.assertTrue(command.closesDialog) + + def test_getFallbackActionOrFallbackEscapeIdNotACommand(self): + """Test that calling _getFallbackActionOrFallback on a dialog whose EscapeId is not a command falls back to returning the default focus.""" + self.dialog.addOkCancelButtons() + super(MessageDialog, self.dialog).SetEscapeId(ReturnCode.CLOSE) + command = self.dialog._getFallbackActionOrFallback() + self.assertEqual(command.returnCode, ReturnCode.OK) + self.assertIsNotNone(command) + self.assertTrue(command.closesDialog) + + def test_getFallbackActionEscapeCode_None(self): + """Test that setting EscapeCode to None causes _getFallbackAction to return None.""" + self.dialog.addOkCancelButtons() + self.dialog.SetEscapeId(EscapeCode.NO_FALLBACK) + self.assertIsNone(self.dialog._getFallbackAction()) + + +class Test_MessageDialog_Threading(WxTestBase): + def test_newOnNonmain(self): + """Test that creating a MessageDialog on a non GUI thread fails.""" + with ThreadPoolExecutor(max_workers=1) as tpe: + with self.assertRaises(RuntimeError): + tpe.submit(MessageDialog.__new__, MessageDialog).result() + + def test_initOnNonMain(self): + """Test that initializing a MessageDialog on a non-GUI thread fails.""" + dlg = MessageDialog.__new__(MessageDialog) + with ThreadPoolExecutor(max_workers=1) as tpe: + with self.assertRaises(RuntimeError): + tpe.submit(dlg.__init__, None, "Test").result() + + def test_showOnNonMain(self): + """Test that showing a MessageDialog on a non-GUI thread fails.""" + dlg = MessageDialog(None, "Test") + with ThreadPoolExecutor(max_workers=1) as tpe: + with self.assertRaises(RuntimeError): + tpe.submit(dlg.Show).result() + + def test_showModalOnNonMain(self): + """Test that showing a MessageDialog modally on a non-GUI thread fails.""" + dlg = MessageDialog(None, "Test") + with ThreadPoolExecutor(max_workers=1) as tpe: + with self.assertRaises(RuntimeError): + tpe.submit(dlg.ShowModal).result() + + +@patch.object(wx.Dialog, "Show") +class Test_MessageDialog_Show(MDTestBase): + def test_showNoButtons(self, mocked_show: MagicMock): + """Test that showing a MessageDialog with no buttons fails.""" + with self.assertRaises(RuntimeError): + self.dialog.Show() + mocked_show.assert_not_called() + + def test_show(self, mocked_show: MagicMock): + """Test that showing a MessageDialog works as expected.""" + self.dialog.addOkButton() + self.dialog.Show() + mocked_show.assert_called_once() + + +@patch("gui.mainFrame") +@patch.object(wx.Dialog, "ShowModal") +class Test_MessageDialog_ShowModal(MDTestBase): + def test_showModalNoButtons(self, mocked_showModal: MagicMock, _): + """Test that showing a MessageDialog modally with no buttons fails.""" + with self.assertRaises(RuntimeError): + self.dialog.ShowModal() + mocked_showModal.assert_not_called() + + def test_showModal(self, mocked_showModal: MagicMock, _): + """Test that showing a MessageDialog works as expected.""" + self.dialog.addOkButton() + with patch("gui.message._messageBoxCounter") as mocked_messageBoxCounter: + mocked_messageBoxCounter.__iadd__.return_value = ( + mocked_messageBoxCounter.__isub__.return_value + ) = mocked_messageBoxCounter + self.dialog.ShowModal() + mocked_showModal.assert_called_once() + mocked_messageBoxCounter.__iadd__.assert_called_once() + mocked_messageBoxCounter.__isub__.assert_called_once() + + +class Test_MessageDialog_EventHandlers(MDTestBase): + def test_onShowEventDefaultFocus(self): + """Test that _onShowEvent correctly focuses the default focus.""" + self.dialog.addOkButton().addCancelButton(defaultFocus=True) + evt = wx.ShowEvent(self.dialog.GetId(), True) + with patch.object(wx.Window, "SetFocus") as mocked_setFocus: + self.dialog._onShowEvent(evt) + mocked_setFocus.assert_called_once() + + def test_onCloseEventNonVetoable(self): + evt = wx.CloseEvent(wx.wxEVT_CLOSE_WINDOW, self.dialog.GetId()) + """Test that a non-vetoable close event is executed.""" + evt.SetCanVeto(False) + self.dialog._instances.append(self.dialog) + with ( + patch.object(wx.Dialog, "Destroy") as mocked_destroy, + patch.object( + self.dialog, + "_executeCommand", + wraps=self.dialog._executeCommand, + ) as mocked_executeCommand, + ): + self.dialog._onCloseEvent(evt) + mocked_destroy.assert_called_once() + mocked_executeCommand.assert_called_once_with(ANY, _canCallClose=False) + self.assertNotIn(self.dialog, MessageDialog._instances) + + def test_onCloseEventNoFallbackAction(self): + """Test that a vetoable call to close is vetoed if there is no fallback action.""" + self.dialog.addYesNoButtons() + self.dialog.SetEscapeId(EscapeCode.NO_FALLBACK) + evt = wx.CloseEvent(wx.wxEVT_CLOSE_WINDOW, self.dialog.GetId()) + MessageDialog._instances.append(self.dialog) + with ( + patch.object(wx.Dialog, "DestroyLater") as mocked_destroyLater, + patch.object( + self.dialog, + "_executeCommand", + ) as mocked_executeCommand, + ): + self.dialog._onCloseEvent(evt) + mocked_destroyLater.assert_not_called() + mocked_executeCommand.assert_not_called() + self.assertTrue(evt.GetVeto()) + self.assertIn(self.dialog, MessageDialog._instances) + + def test_onCloseEventFallbackAction(self): + """Test that _onCloseEvent works properly when there is an there is a fallback action.""" + self.dialog.addOkCancelButtons() + evt = wx.CloseEvent(wx.wxEVT_CLOSE_WINDOW, self.dialog.GetId()) + MessageDialog._instances.append(self.dialog) + with ( + patch.object(wx.Dialog, "DestroyLater") as mocked_destroyLater, + patch.object( + self.dialog, + "_executeCommand", + wraps=self.dialog._executeCommand, + ) as mocked_executeCommand, + ): + self.dialog._onCloseEvent(evt) + mocked_destroyLater.assert_called_once() + mocked_executeCommand.assert_called_once_with(ANY, _canCallClose=False) + self.assertNotIn(self.dialog, MessageDialog._instances) + + @parameterized.expand( + ( + ExecuteCommandArgList( + label="closableCanCallClose", + closesDialog=True, + canCallClose=True, + expectedCloseCalled=True, + ), + ExecuteCommandArgList( + label="ClosableCannotCallClose", + closesDialog=True, + canCallClose=False, + expectedCloseCalled=False, + ), + ExecuteCommandArgList( + label="UnclosableCanCallClose", + closesDialog=False, + canCallClose=True, + expectedCloseCalled=False, + ), + ExecuteCommandArgList( + label="UnclosableCannotCallClose", + closesDialog=False, + canCallClose=False, + expectedCloseCalled=False, + ), + ), + ) + def test_executeCommand(self, _, closesDialog: bool, canCallClose: bool, expectedCloseCalled: bool): + """Test that _executeCommand performs as expected in a number of situations.""" + returnCode = sentinel.return_code + callback = Mock() + command = _Command(callback=callback, closesDialog=closesDialog, returnCode=returnCode) + with ( + patch.object(self.dialog, "Close") as mocked_close, + patch.object( + self.dialog, + "SetReturnCode", + ) as mocked_setReturnCode, + ): + self.dialog._executeCommand(command, _canCallClose=canCallClose) + callback.assert_called_once() + if expectedCloseCalled: + mocked_setReturnCode.assert_called_with(returnCode) + mocked_close.assert_called_once() + else: + mocked_setReturnCode.assert_not_called() + mocked_close.assert_not_called() + + +class Test_MessageDialog_Blocking(MDTestBase): + def tearDown(self) -> None: + MessageDialog._instances.clear() + super().tearDown() + + @parameterized.expand( + ( + BlockingInstancesExistArgList( + label="noInstances", + instances=tuple(), + expectedBlockingInstancesExist=False, + ), + BlockingInstancesExistArgList( + label="nonBlockingInstance", + instances=(mockDialogFactory(isBlocking=False),), + expectedBlockingInstancesExist=False, + ), + BlockingInstancesExistArgList( + label="blockingInstance", + instances=(mockDialogFactory(isBlocking=True),), + expectedBlockingInstancesExist=True, + ), + BlockingInstancesExistArgList( + label="onlyBlockingInstances", + instances=(mockDialogFactory(isBlocking=True), mockDialogFactory(isBlocking=True)), + expectedBlockingInstancesExist=True, + ), + BlockingInstancesExistArgList( + label="onlyNonblockingInstances", + instances=(mockDialogFactory(isBlocking=False), mockDialogFactory(isBlocking=False)), + expectedBlockingInstancesExist=False, + ), + BlockingInstancesExistArgList( + label="blockingFirstNonBlockingSecond", + instances=(mockDialogFactory(isBlocking=True), mockDialogFactory(isBlocking=False)), + expectedBlockingInstancesExist=True, + ), + BlockingInstancesExistArgList( + label="nonblockingFirstblockingSecond", + instances=(mockDialogFactory(isBlocking=False), mockDialogFactory(isBlocking=True)), + expectedBlockingInstancesExist=True, + ), + ), + ) + def test_blockingInstancesExist( + self, + _, + instances: tuple[MagicMock, ...], + expectedBlockingInstancesExist: bool, + ): + """Test that blockingInstancesExist is correct in a number of situations.""" + MessageDialog._instances.extend(instances) + self.assertEqual(MessageDialog.blockingInstancesExist(), expectedBlockingInstancesExist) + + @parameterized.expand( + ( + IsBlockingArgList( + label="modalWithFallback", + isModal=True, + hasFallback=True, + expectedIsBlocking=True, + ), + IsBlockingArgList( + label="ModalWithoutFallback", + isModal=True, + hasFallback=False, + expectedIsBlocking=True, + ), + IsBlockingArgList( + label="ModelessWithFallback", + isModal=False, + hasFallback=True, + expectedIsBlocking=False, + ), + IsBlockingArgList( + label="ModelessWithoutFallback", + isModal=False, + hasFallback=False, + expectedIsBlocking=True, + ), + ), + ) + def test_isBlocking(self, _, isModal: bool, hasFallback: bool, expectedIsBlocking: bool): + """Test that isBlocking works correctly in a number of situations.""" + with ( + patch.object(self.dialog, "IsModal", return_value=isModal), + patch.object( + type(self.dialog), + "hasFallback", + new_callable=PropertyMock, + return_value=hasFallback, + ), + ): + self.assertEqual(self.dialog.isBlocking, expectedIsBlocking) + + @parameterized.expand( + ( + ( + "oneNonblockingDialog", + ( + FocusBlockingInstancesDialogs( + mockDialogFactory(False), + expectedRaise=False, + expectedSetFocus=False, + ), + ), + ), + ( + "oneBlockingDialog", + ( + FocusBlockingInstancesDialogs( + mockDialogFactory(True), + expectedRaise=True, + expectedSetFocus=True, + ), + ), + ), + ( + "blockingThenNonblocking", + ( + FocusBlockingInstancesDialogs( + mockDialogFactory(True), + expectedRaise=True, + expectedSetFocus=True, + ), + FocusBlockingInstancesDialogs( + mockDialogFactory(False), + expectedRaise=False, + expectedSetFocus=False, + ), + ), + ), + ( + "nonblockingThenBlocking", + ( + FocusBlockingInstancesDialogs( + mockDialogFactory(False), + expectedRaise=False, + expectedSetFocus=False, + ), + FocusBlockingInstancesDialogs( + mockDialogFactory(True), + expectedRaise=True, + expectedSetFocus=True, + ), + ), + ), + ( + "blockingThenBlocking", + ( + FocusBlockingInstancesDialogs( + mockDialogFactory(True), + expectedRaise=True, + expectedSetFocus=False, + ), + FocusBlockingInstancesDialogs( + mockDialogFactory(True), + expectedRaise=True, + expectedSetFocus=True, + ), + ), + ), + ), + ) + def test_focusBlockingInstances(self, _, dialogs: tuple[FocusBlockingInstancesDialogs, ...]): + """Test that focusBlockingInstances works as expected in a number of situations.""" + MessageDialog._instances.extend(dialog.dialog for dialog in dialogs) + MessageDialog.focusBlockingInstances() + for dialog, expectedRaise, expectedSetFocus in dialogs: + if expectedRaise: + dialog.Raise.assert_called_once() + else: + dialog.Raise.assert_not_called() + if expectedSetFocus: + dialog.SetFocus.assert_called_once() + else: + dialog.SetFocus.assert_not_called() + + def test_closeNonblockingInstances(self): + """Test that closing non-blocking instances works in a number of situations.""" + bd1, bd2 = mockDialogFactory(True), mockDialogFactory(True) + nd1, nd2, nd3 = mockDialogFactory(False), mockDialogFactory(False), mockDialogFactory(False) + MessageDialog._instances.extend((nd1, bd1, nd2, bd2, nd3)) + MessageDialog.closeInstances() + bd1.Close.assert_not_called() + bd2.Close.assert_not_called() + nd1.Close.assert_called() + nd2.Close.assert_called() + nd3.Close.assert_called() + + +class Test_MessageBoxShim(unittest.TestCase): + def test_messageBoxButtonStylesToMessageDialogButtons(self): + """Test that mapping from style flags to Buttons works as expected.""" + YES, NO, OK, CANCEL, HELP = wx.YES, wx.NO, wx.OK, wx.CANCEL, wx.HELP + outputToInputsMap = { + (ReturnCode.OK,): (OK, 0), + (ReturnCode.OK, ReturnCode.CANCEL): (OK | CANCEL, CANCEL), + (ReturnCode.OK, ReturnCode.HELP): (OK | HELP, HELP), + (ReturnCode.OK, ReturnCode.CANCEL, ReturnCode.HELP): (OK | CANCEL | HELP, CANCEL | HELP), + (ReturnCode.YES, ReturnCode.NO): (YES | NO, YES, NO, YES | OK, NO | OK, YES | NO | OK), + (ReturnCode.YES, ReturnCode.NO, ReturnCode.CANCEL): ( + YES | NO | CANCEL, + YES | CANCEL, + NO | CANCEL, + YES | OK | CANCEL, + NO | OK | CANCEL, + YES | NO | OK | CANCEL, + ), + (ReturnCode.YES, ReturnCode.NO, ReturnCode.HELP): ( + YES | NO | HELP, + YES | HELP, + NO | HELP, + YES | OK | HELP, + NO | OK | HELP, + YES | NO | OK | HELP, + ), + (ReturnCode.YES, ReturnCode.NO, ReturnCode.CANCEL, ReturnCode.HELP): ( + YES | NO | CANCEL | HELP, + YES | CANCEL | HELP, + NO | CANCEL | HELP, + YES | OK | CANCEL | HELP, + NO | OK | CANCEL | HELP, + YES | NO | OK | CANCEL | HELP, + ), + } + for expectedOutput, inputs in outputToInputsMap.items(): + for input in inputs: + with self.subTest(flags=input): + self.assertCountEqual( + expectedOutput, + (button.id for button in _messageBoxButtonStylesToMessageDialogButtons(input)), + ) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 6838cfedabd..e7bbe509074 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -119,6 +119,9 @@ Add-ons will need to be re-tested and have their manifest updated. It can be used in scripts to report the result when a boolean is toggled in `config.conf` * Removed the requirement to indent function parameter lists by two tabs from NVDA's Coding Standards, to be compatible with modern automatic linting. (#17126, @XLTechie) * Added the [VS Code workspace configuration for NVDA](https://nvaccess.org/nvaccess/vscode-nvda) as a git submodule. (#17003) +* A new function, `gui.guiHelper.wxCallOnMain`, has been added, which allows safely and synchronously calling wx functions from non-GUI threads, and getting their return value. (#17304) +* A new message dialog API has been added to `gui.message`. (#13007) + * Added classes: `ReturnCode`, `EscapeCode`, `DialogType`, `Button`, `DefaultButton`, `DefaultButtonSet`, `MessageDialog`. * In the `brailleTables` module, a `getDefaultTableForCurrentLang` function has been added (#17222, @nvdaes) * Retrieving the `labeledBy` property now works for: * objects in applications implementing the `labelled-by` IAccessible2 relation. (#17436, @michaelweghorn) @@ -162,11 +165,16 @@ As the NVDA update check URL is now configurable directly within NVDA, no replac * The following symbols have been removed with no replacement: `languageHandler.getLanguageCliArgs`, `__main__.quitGroup` and `__main__.installGroup` . (#17486, @CyrilleB79) * Prefix matching on command line flags, e.g. using `--di` for `--disable-addons` is no longer supported. (#11644, @CyrilleB79) * The `useAsFallBack` keyword argument of `bdDetect.DriverRegistrar` has been renamed to `useAsFallback`. (#17521, @LeonarddeR) +* `updateCheck.UpdateAskInstallDialog` no longer automatically performs an action when the update or postpone buttons are pressed. +Instead, a `callback` property has been added, which returns a function that performs the appropriate action when called with the return value from the dialog. (#17582) +* Dialogs opened with `gui.runScriptModalDialog` are now recognised as modal by NVDA. (#17582) #### Deprecations * The `braille.filter_displaySize` extension point is deprecated. Please use `braille.filter_displayDimensions` instead. (#17011) +* The `gui.message.messageBox` and `gui.runScriptModalDialog` functions, and `gui.nvdaControls.MessageDialog` class are deprecated. +Use `gui.message.MessageDialog` instead. (#17582) * The following symbols are deprecated (#17486, @CyrilleB79): * `NoConsoleOptionParser`, `stringToBool`, `stringToLang` in `__main__`; use the same symbols in `argsParsing` instead. * `__main__.parser`; use `argsParsing.getParser()` instead. From e06f2b836a9061bc32bc7552a97af75b93594794 Mon Sep 17 00:00:00 2001 From: Rowen Date: Thu, 9 Jan 2025 08:06:03 +0800 Subject: [PATCH 8/8] Fix issue with certain SECTION elements not being recognized as editable controls in Visual Studio Code (#17573) closed #17525 Summary of the issue: In Visual Studio Code, NVDA was unable to correctly read certain non-standard editable text controls, specifically those with the SECTION role that have an editable state. These controls could be edited by users but were not recognized as EDITABLETEXT by NVDA. Description of user-facing changes: This fix ensures that NVDA correctly recognizes and reads non-standard editable text controls, such as those with the SECTION role and an editable state, in Visual Studio Code. Description of development approach: In #16248, we introduced support for text-review commands for objects in Visual Studio Code. Initially, we used obj.role == controlTypes.Role.EDITABLETEXT as the check for editable text fields. However, this caused a regression for some controls, specifically SECTION elements in VS Code that were editable but did not have the EDITABLETEXT role. These controls have an editable state but were overlooked by NVDA. This fix updates the logic to account for such cases, ensuring these elements are treated as editable controls. --- source/appModules/code.py | 10 ++++------ user_docs/en/changes.md | 1 + 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/source/appModules/code.py b/source/appModules/code.py index facaeb60a1e..94b2f9bb590 100644 --- a/source/appModules/code.py +++ b/source/appModules/code.py @@ -26,10 +26,8 @@ def chooseNVDAObjectOverlayClasses(self, obj, clsList): clsList.insert(0, VSCodeDocument) def event_NVDAObject_init(self, obj: NVDAObject): - # This is a specific fix for Visual Studio Code, - # However, the root cause of the issue is issue #15159. - # Once issue #15159 is fixed, - # The PR #16248 that introduced this code can be immediately reverted. - # (see issue #15159 for full description) - if obj.role != controlTypes.Role.EDITABLETEXT: + # TODO: This is a specific fix for Visual Studio Code. + # Once the underlying issue is resolved, this workaround can be removed. + # See issue #15159 for more details. + if obj.role != controlTypes.Role.EDITABLETEXT and controlTypes.State.EDITABLE not in obj.states: obj.TextInfo = NVDAObjectTextInfo diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index e7bbe509074..173a578d598 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -84,6 +84,7 @@ In any document, if the cursor is on the last line, it will be moved to the end * When the Standard HID Braille Display driver is explicitly selected as the braille display driver, and the braille display list is opened, NVDA correctly identifies the HID driver as the selected driver instead of showing no driver selected. (#17537, @LeonarddeR) * The Humanware Brailliant driver is now more reliable in selecting the right connection endpoint, resulting in better connection stability and less errors. (#17537, @LeonarddeR) * Custom braille tables in the developer scratchpad are now properly ignored when running with add-ons disabled. (#17565, @LeonarddeR) +* Fix issue with certain section elements not being recognized as editable controls in Visual Studio Code. (#17573, @Cary-rowen) ### Changes for Developers