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..e8431f74c3b 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 ( @@ -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/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..d38c2c746df 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -1,16 +1,30 @@ # -*- 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 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 +65,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 +88,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 +151,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, 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/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 37ab4b9e3b8..536a1d3ccd1 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -116,6 +116,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) @@ -154,6 +157,8 @@ As the NVDA update check URL is now configurable directly within NVDA, no replac * 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. (#17304) * 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.