From e80d7822160f7d2ff151140bc97ca84e5798c1fb Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 20 Dec 2024 17:25:50 +1100 Subject: [PATCH] Revert "Create a `MessageDialog` API (#17304)" (#17561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 2477780dd3e9b1f44b73fee2a180a4f20553bdf4. Reverts #17304 Issues fixed Fixes #17560 Fixes #17553 Issues reopened Reopens #13007 Reopens #12344 Reopens #12353 Reason for revert This PR broke two important NVDA functions, and there is insufficient time before the Christmas/New Years break to fix the issues, so we are temporarily reverting so that alphas work over the break: • The CRFT no longer works, as gui.message.MessageDialog's inheritance from ContextHelpMixin was accidentally removed. • Update checking no longer works, as runScriptModalDialog was modified to increment and decrement _messageBoxCounter, but UpdateAskInstallDialog attempts to restart NVDA before it has been closed, so the update fails as NVDA appears to be in an unsafe state. --- .../dev/developerGuide/developerGuide.md | 230 ---- requirements.txt | 2 - source/documentationUtils.py | 6 +- source/gui/__init__.py | 29 +- source/gui/blockAction.py | 44 +- source/gui/guiHelper.py | 53 - source/gui/message.py | 1175 +---------------- source/gui/nvdaControls.py | 181 ++- .../screenCurtain.py | 8 +- tests/system/robot/startupShutdownNVDA.robot | 4 +- tests/unit/test_messageDialog.py | 969 -------------- user_docs/en/changes.md | 5 - 12 files changed, 191 insertions(+), 2515 deletions(-) delete mode 100644 tests/unit/test_messageDialog.py diff --git a/projectDocs/dev/developerGuide/developerGuide.md b/projectDocs/dev/developerGuide/developerGuide.md index be23ea20c4f..b471171e2df 100644 --- a/projectDocs/dev/developerGuide/developerGuide.md +++ b/projectDocs/dev/developerGuide/developerGuide.md @@ -1445,233 +1445,3 @@ 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 f1e6c221d53..7a1ab6bfcf8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,8 +27,6 @@ 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 6ffe7cf8826..c84b6112c5f 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-2024 NV Access Limited, Łukasz Golonka +# Copyright (C) 2006-2023 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,6 +13,7 @@ from logHandler import log import ui import queueHandler +from gui.message import messageBox import wx @@ -64,9 +65,6 @@ 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 e8431f74c3b..7360c83106e 100644 --- a/source/gui/__init__.py +++ b/source/gui/__init__.py @@ -5,10 +5,8 @@ # 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 @@ -32,8 +30,6 @@ # 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 ( @@ -371,7 +367,7 @@ def onInputGesturesCommand(self, evt): def onAboutCommand(self, evt): # Translators: The title of the dialog to show about info for NVDA. - MessageDialog(None, versionInfo.aboutMessage, _("About NVDA")).Show() + messageBox(versionInfo.aboutMessage, _("About NVDA"), wx.OK) @blockAction.when(blockAction.Context.SECURE_MODE) def onCheckForUpdateCommand(self, evt): @@ -882,24 +878,21 @@ def showGui(): wx.CallAfter(mainFrame.showGui) -def runScriptModalDialog(dialog: wx.Dialog, callback: Callable[[int], Any] | None = None): +def runScriptModalDialog(dialog, callback=None): """Run a modal dialog from a script. - This will not block the caller, but will instead call callback (if provided) with the result from the dialog. + This will not block the caller, + but will instead call C{callback} (if provided) with the result from the dialog. The dialog will be destroyed once the callback has returned. - - 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. + @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 """ - warnings.warn( - "showScriptModalDialog is deprecated. Use an instance of message.MessageDialog and wx.CallAfter instead.", - DeprecationWarning, - ) def run(): - res = displayDialogAsModal(dialog) + mainFrame.prePopup() + res = dialog.ShowModal() + mainFrame.postPopup() if callback: callback(res) dialog.Destroy() diff --git a/source/gui/blockAction.py b/source/gui/blockAction.py index a7fa1e6094e..205e075f31a 100644 --- a/source/gui/blockAction.py +++ b/source/gui/blockAction.py @@ -1,48 +1,25 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2023-2024 NV Access Limited, Cyrille Bougot +# Copyright (C) 2023 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 Any -from speech.priorities import SpeechPriority +from typing import Callable import ui from utils.security import isLockScreenModeActive, isRunningOnSecureDesktop -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() +from gui.message import isModalMessageBoxActive +import queueHandler @dataclass class _Context: blockActionIf: Callable[[], bool] translatedMessage: str - callback: Callable[[], Any] | None = None class Context(_Context, Enum): @@ -58,11 +35,10 @@ 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(), @@ -98,15 +74,7 @@ def _wrap(func): def funcWrapper(*args, **kwargs): for context in contexts: if context.blockActionIf(): - 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, - ) + queueHandler.queueFunction(queueHandler.eventQueue, ui.message, context.translatedMessage) return return func(*args, **kwargs) diff --git a/source/gui/guiHelper.py b/source/gui/guiHelper.py index 579466e5f07..cdcf9cb0689 100644 --- a/source/gui/guiHelper.py +++ b/source/gui/guiHelper.py @@ -43,16 +43,11 @@ 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, @@ -481,51 +476,3 @@ 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 d38c2c746df..6bd5d9c2f95 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -1,30 +1,16 @@ # -*- coding: UTF-8 -*- # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2006-2024 NV Access Limited, Peter Vágner, Aleksey Sadovoy, Mesar Hameed, Joseph Lee, +# Copyright (C) 2006-2022 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 -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 +from typing import Optional -import core -import extensionPoints import wx -from logHandler import log - -import gui -from . import guiHelper -from .dpiScalingHelper import DpiScalingHelperMixinWithoutInit -from .guiHelper import SIPABCMeta, wxCallOnMain +import extensionPoints _messageBoxCounterLock = threading.Lock() _messageBoxCounter = 0 @@ -65,17 +51,19 @@ 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(): - gui.mainFrame.prePopup() + mainFrame.prePopup() res = dialog.ShowModal() finally: if not dialog.GetParent(): - gui.mainFrame.postPopup() + mainFrame.postPopup() with _messageBoxCounterLock: _messageBoxCounter -= 1 @@ -88,35 +76,49 @@ def messageBox( style: int = wx.OK | wx.CENTER, parent: Optional[wx.Window] = None, ) -> int: - """Display a modal message dialog. - - .. warning:: This function is deprecated. - Use :class:`MessageDialog` instead. + """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. - This function blocks the calling thread until the user responds to the modal dialog. + `gui.message.messageBox` is a function which blocks the calling thread, + until a user responds to the modal dialog. This function should be used when an answer is required before proceeding. - Consider using :class:`MessageDialog` or a custom :class:`wx.Dialog` subclass if an answer is not required, or a default answer can be provided. + Consider using a custom subclass of a wxDialog 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 :func:`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. + Before opening a new messageBox, use `isModalMessageBoxActive` + to check if another messageBox modal response is still pending. - :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`. + 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. """ - 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 + from gui import mainFrame + import core + from logHandler import log + + global _messageBoxCounter + with _messageBoxCounterLock: + _messageBoxCounter += 1 + + 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 + return res @@ -151,1092 +153,3 @@ 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 838c61b52ce..66016ebc01f 100644 --- a/source/gui/nvdaControls.py +++ b/source/gui/nvdaControls.py @@ -11,7 +11,6 @@ OrderedDict, Type, ) -import warnings import wx from wx.lib import scrolledpanel @@ -22,12 +21,13 @@ 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,64 +270,120 @@ def __init__(self, *args, **kwargs): DpiScalingHelperMixin.__init__(self, self.GetHandle()) -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. +class MessageDialog(DPIScaledDialog): + """Provides a more flexible message dialog. 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 - @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, - ) - return super().__new__(cls, *args, **kwargs) + _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, + } - 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, - ) + _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.""" - self.addOkButton(returnCode=wx.OK) - self.addCancelButton(returnCode=wx.CANCEL) + 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"), + ) + 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) + + # 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, + ) + 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() -class _ContinueCancelDialog(MessageDialog): + +class _ContinueCancelDialog( + contextHelp.ContextHelpMixin, + 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. @@ -358,24 +414,29 @@ 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.""" - self.addOkButton( + + # 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: # Translators: The label for the Continue button in an NVDA dialog. - label=_("&Continue"), - returnCode=wx.OK, - defaultFocus=self.continueButtonFirst, - ) - self.addCancelButton( + return buttonHelper.addButton(self, id=wx.ID_OK, label=_("&Continue")) + + def _makeCancel(self, buttonHelper: guiHelper.ButtonHelper) -> wx.Button: # Translators: The label for the Cancel button in an NVDA dialog. - label=_("Cancel"), - returnCode=wx.CANCEL, - defaultFocus=not self.continueButtonFirst, - ) + 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)) class EnhancedInputSlider(wx.Slider): diff --git a/source/visionEnhancementProviders/screenCurtain.py b/source/visionEnhancementProviders/screenCurtain.py index 769a0e4977d..f1f4b8174e9 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 _onActivateEvent(self, evt: wx.ActivateEvent): + def _onDialogActivated(self, evt): # focus is normally set to the first child, however, we want people to easily be able to cancel this # dialog - super()._onActivateEvent(evt) + super()._onDialogActivated(evt) self.noButton.SetFocus() - def _onShowEvent(self, evt: wx.ShowEvent): + def _onShowEvt(self, evt): """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()._onShowEvent(evt) + super()._onShowEvt(evt) class ScreenCurtainGuiPanel( diff --git a/tests/system/robot/startupShutdownNVDA.robot b/tests/system/robot/startupShutdownNVDA.robot index 12b442f4cb9..543fe240515 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-2024 NV Access Limited +# Copyright (C) 2018 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,6 +47,8 @@ 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 deleted file mode 100644 index efe5f0a68f8..00000000000 --- a/tests/unit/test_messageDialog.py +++ /dev/null @@ -1,969 +0,0 @@ -# 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 54a2f80fb4f..6fa365419ae 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -116,9 +116,6 @@ 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) @@ -159,8 +156,6 @@ 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.