Skip to content

Commit

Permalink
Create a MessageDialog API (#17304)
Browse files Browse the repository at this point in the history
Closes #13007
Closes #12344 
Closes #12353 

Summary of the issue:
Currently, there are several ways to create message boxes/dialogs in NVDA, all of them with their own drawbacks.
Additionally, subclassing `wx.Dialog` (or using it directly) brings with it the risk of breaking parts of NVDA. See #13007 for in-depth discussion.

Thus, a new API for creating highly customizable dialogs that work well with NVDA is desired.

Description of user facing changes
This PR does not, in itself, introduce any end-user facing changes. However, it lays the groundwork for closing a number of issues.

Description of development approach
Subclassed `wx.Dialog` to add the desired functionality in #13007

Re-implemented the about box to use a `MessageDialog`.

Added a callback field to `gui.blockAction._Context` to allow adding a function to execute when the block condition is met.

Features and tasks
* [x] Rename buttons
* [x] Add custom buttons
* [x] Associate context help with the given message box
* [x] Show the dialog as a modal or a non-modal
* [x] for non modal dialogs: 
  * [x] provide a default return code when dialogs are forced to close (eg via an exit, perform cancel) (WIP)
  * [x] attach a callback to handle return codes
  * [x]  refocus a dialog in the event of needing to close it
  * [x] if a default return code is provided, make the dialog close-able with alt+F4 and escape (WIP)
* [x] for modal dialogs: 
  * [x] if wx.CANCEL is provided, make the dialog close-able with alt+F4 and escape (mostly done)
  * [x] nice to have:
    * [x] if wx.CANCEL is provided, perform this action when dialogs are forced to close.
    * [x] refocus a dialog in the event of needing to close it
* [x] Ensure that the test `Quits from keyboard with about dialog open` is re-enabled and works.
* [x] Block exiting NVDA with modal `MessageDialog`s open.
* [x] Focus open `MessageDialog`s without default actions when exiting.
* [x] Tests
  * [x] Unit tests
* [x] Document the new API in the developer guide
* [x] Migrate the new API from `gui.MessageDialog` to `gui.message`.
* [x] Mark other means of creating message dialogs as deprecated.
  * [x] Re implement their internals to use the new API
    * [x] `gui.message.messageBox`
    * [x] `gui.nvdaControls.MessageDialog`
    * [x] `gui.nvdaControls._ContinueCancelDialog`

Testing strategy:
Unit and manual tests.

Unit tests are extensive, and reach around 85% coverage of the newly added code.

Message box shim tested with a variety of inputs and unittests. This is a fairly straightforward adapter.

`nvdaControls.MessageDialog` tested that screen curtain and add-on install warning dialogs still work as expected. Its private subclass, `_ContinueCancelDialog`, was tested by attempting to run the CRFT.

Known issues with pull request:

Internal dialogs are yet to be migrated. This should be done gradually before the existing means of creating message dialogs are removed.

Slightly odd behaviour when a callback function blocks, and `focusBlockingInstances` is called, where a hidden dialog is focused. This can be worked around, but possibly not without unintended consequences.

Some invalid combinations (namely a button which is set as the default action but set not to close the dialog) are silently changed. While documenting this should be sufficient, logging or raising an exception may be warranted.

Visual proportions of the re-implemented About dialog are different.

The `ctrl+c` behaviour of copying the dialog's title, contents and buttons is not supported.

Future work

* Remove all internal use of deprecated message dialog APIs.
* Remove deprecated message dialog APIs in 2026.1.

Nice to have
* Implement system tests for the new API.
* Comprehensively document a manual test plan, and develop any associated scripts.
* Add the ability to set a callback after a button has been added.
* Add a helper to add a "don't show this message again" checkbox.
  • Loading branch information
SaschaCowley authored Dec 18, 2024
1 parent bcccf14 commit 2477780
Show file tree
Hide file tree
Showing 12 changed files with 2,515 additions and 191 deletions.
230 changes: 230 additions & 0 deletions projectDocs/dev/developerGuide/developerGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1445,3 +1445,233 @@ Please see the `EventExtensionPoints` class documentation for more information,
|`Action` |`post_reviewMove` |the position of the review cursor has changed.|
|`Action` |`post_mouseMove` |the mouse has moved.|
|`Action` |`post_coreCycle` |the end of each core cycle has been reached.|

## Communicating with the user

### The message dialog API

The message dialog API provides a flexible way of presenting interactive messages to the user.
The messages are highly customisable, with options to change icons and sounds, button labels, return values, and close behaviour, as well as to attach your own callbacks.

All classes that make up the message dialog API are importable from `gui.message`.
While you are unlikely to need all of them, they are enumerated below:

* `ReturnCode`: Possible return codes from modal `MessageDialog`s.
* `EscapeCode`: Escape behaviour of `MessageDialog`s.
* `DialogType`: Types of dialogs (sets the dialog's sound and icon).
* `Button`: Button configuration data structure.
* `DefaultButton`: Enumeration of pre-configured buttons.
* `DefaultButtonSet`: Enumeration of common combinations of buttons.
* `MessageDialog`: The actual dialog class.

In many simple cases, you will be able to achieve what you need by simply creating a message dialog and calling `Show` or `ShowModal`. For example:

```py
from gui.message import MessageDialog
from gui import mainFrame

MessageDialog(
mainFrame,
_("Hello world!"),
).Show()
```

This will show a non-modal (that is, non-blocking) dialog with the text "Hello world!" and an OK button.

If you want the dialog to be modal (that is, to block the user from performing other actions in NVDA until they have responded to it), you can call `ShowModal` instead.

With modal dialogs, the easiest way to respond to user input is via the return code.

```py
from gui.message import DefaultButtonSet, ReturnCode

saveDialog = MessageDialog(
mainFrame,
_("Would you like to save your changes before exiting?"),
_("Save changes?"),
buttons=DefaultButtonSet.SAVE_NO_CANCEL
)

match saveDialog.ShowModal():
case ReturnCode.SAVE:
... # Save the changes and close
case ReturnCode.NO:
... # Discard changes and close
case ReturnCode.CANCEL:
... # Do not close
```

For non-modal dialogs, the easiest way to respond to the user pressing a button is via callback methods.

```py
def readChangelog():
... # Do something

def downloadUpdate():
... # Do something

def remindLater():
... # Do something

updateDialog = MessageDialog(
mainFrame,
"An update is available. "
"Would you like to download it now?",
"Update",
buttons=None,
).addYesButton(
callback=downloadUpdate
).addNoButton(
label=_("&Remind me later"),
fallbackAction=True,
callback=remindLater
).addHelpButton(
label=_("What's &new"),
callback=readChangelog
)

updateDialog.Show()
```

You can set many of the parameters to `addButton` later, too:

* The default focus can be set by calling `setDefaultFocus` on your message dialog instance, and passing it the ID of the button to make the default focus.
* The fallback action can be set later by calling `setFallbackAction` or `SetEscapeId` with the ID of the button which performs the fallback action.
* The button's label can be changed by calling `setButtonLabel` with the ID of the button and the new label.

#### Fallback actions

The fallback action is the action performed when the dialog is closed without the user pressing one of the buttons you added to the dialog.
This can happen for several reasons:

* The user pressed `esc` or `alt+f4` to close the dialog.
* The user used the title bar close button or system menu close item to close the dialog.
* The user closed the dialog from the Task View, Taskbar or App Switcher.
* The user is quitting NVDA.
* Some other part of NVDA or an add-on has asked the dialog to close.

By default, the fallback action is set to `EscapeCode.CANCEL_OR_AFFIRMATIVE`.
This means that the fallback action will be the cancel button if there is one, the button whose ID is `dialog.GetAffirmativeId()` (`ReturnCode.OK`, by default), or `None` if no button with either ID exists in the dialog.
You can use `dialog.SetAffirmativeId(id)` to change the ID of the button used secondarily to Cancel, if you like.
The fallback action can also be set to `EscapeCode.NO_FALLBACK` to disable closing the dialog like this entirely.
If it is set to any other value, the value must be the id of a button to use as the default action.

In some cases, the dialog may be forced to close.
If the dialog is shown modally, a calculated fallback action will be used if the fallback action is `EscapeCode.NO_FALLBACK` or not found.
The order of precedence for calculating the fallback when a dialog is forced to close is as follows:

1. The developer-set fallback action.
2. The developer-set default focus.
3. The first button added to the dialog that closes the dialog.
4. The first button added to the dialog, regardless of whether it closes the dialog.
5. A dummy action that does nothing but close the dialog.
In this case, and only this case, the return code from showing the dialog modally will be `EscapeCode.NO_FALLBACK`.

#### A note on threading

**IMPORTANT:** Most `MessageDialog` methods are **not** thread safe.
Calling these methods from non-GUI threads can cause crashes or unpredictable behavior.

When calling non thread safe methods on `MessageDialog` or its instances, be sure to do so on the GUI thread.
To do this with wxPython, you can use `wx.CallAfter` or `wx.CallLater`.
As these operations schedule the passed callable to occur on the GUI thread, they will return immediately, and will not return the return value of the passed callable.
If you want to wait until the callable has completed, or care about its return value, consider using `gui.guiHelper.wxCallOnMain`.

The `wxCallOnMain` function executes the callable you pass to it, along with any positional and keyword arguments, on the GUI thread.
It blocks the calling thread until the passed callable returns or raises an exception, at which point it returns the returned value, or re-raises the raised exception.

```py
# To call
someFunction(arg1, arg2, kw1=value1, kw2=value2)
# on the GUI thread:
wxCallOnMain(someFunction, arg1, arg2, kw=value1, kw2=value2)
```

In fact, you cannot create, initialise, or show (modally or non-modally) `MessageDialog`s from any thread other than the GUI thread.

#### Buttons

You can add buttons in a number of ways:

* By passing a `Collection` of `Button`s to the `buttons` keyword-only parameter to `MessageDialog` when initialising.
* By calling `addButton` on a `MessageDialog` instance, either with a `Button` instance, or with simple parameters.
* When calling `addButton` with a `Button` instance, you can override all of its parameters except `id` by providing their values as keyword arguments.
* When calling `addButton` with simple parameters, the parameters it accepts are the same as those of `Button`.
* In both cases, `id` or `button` is the first argument, and is positional only.
* By calling `addButtons` with a `Collection` of `Button`s.
* By calling any of the add button helpers.

Regardless of how you add them, you cannot add multiple buttons with the same ID to the same `MessageDialog`.

A `Button` is an immutable data structure containing all of the information needed to add a button to a `MessageDialog`.
Its fields are as follows:

| Field | Type | Default | Explanation |
|---|---|---|---|
| `id` | `ReturnCode` | No default | The ID used to refer to the button. |
| `label` | `str` | No default | The text label to display on the button. Prefix accelerator keys with an ampersand (&). |
| `callback` | `Callable` or `None` | `None` | The function to call when the button is clicked. This is most useful for non-modal dialogs. |
| `defaultFocus` | `bool` | `False` | Whether to explicitly set the button as the default focus. (1) |
| `fallbackAction` | `bool` | `False` | Whether the button should be the fallback action, which is called when the user presses `esc`, uses the system menu or title bar close buttons, or the dialog is asked to close programmatically. (2) |
| `closesDialog` | `bool` | `True` | Whether the button should close the dialog when pressed. (3) |
| `returnCode` | `ReturnCode` or `None` | `None` | Value to return when a modal dialog is closed. If `None`, the button's ID will be used. |

1. Setting `defaultFocus` only overrides the default focus:

* If no buttons have this property, the first button will be the default focus.
* If multiple buttons have this property, the last one will be the default focus.

2. `fallbackAction` only sets whether to override the fallback action:

* This button will still be the fallback action if the dialog's fallback action is set to `EscapeCode.CANCEL_OR_AFFIRMATIVE` (the default) and its ID is `ReturnCode.CANCEL` (or whatever the value of `GetAffirmativeId()` is (`ReturnCode.OK`, by default), if there is no button with `id=ReturnCode.CANCEL`), even if it is added with `fallbackAction=False`.
To set a dialog to have no fallback action, use `setFallbackAction(EscapeCode.NO_FALLBACK)`.
* If multiple buttons have this property, the last one will be the fallback action.

3. Buttons with `fallbackAction=True` and `closesDialog=False` are not supported:

* When adding a button with `fallbackAction=True` and `closesDialog=False`, `closesDialog` will be set to `True`.
* If you attempt to call `setFallbackAction` with the ID of a button that does not close the dialog, `ValueError` will be raised.

A number of pre-configured buttons are available for you to use from the `DefaultButton` enumeration, complete with pre-translated labels.
None of these buttons will explicitly set themselves as the fallback action.
You can also add any of these buttons to an existing `MessageDialog` instance with its add button helper, which also allows you to override all but the `id` parameter.
The following default buttons are available:

| Button | Label | ID/return code | Closes dialog | Add button helper |
|---|---|---|---|---|
| `APPLY` | &Apply | `ReturnCode.APPLY` | No | `addApplyButton` |
| `CANCEL` | Cancel | `ReturnCode.CANCEL` | Yes | `addCancelButton` |
| `CLOSE` | Close | `ReturnCode.CLOSE` | Yes | `addCloseButton` |
| `HELP` | Help | `ReturnCode.HELP` | No | `addHelpButton` |
| `NO` | &No | `ReturnCode.NO` | Yes | `addNoButton` |
| `OK` | OK | `ReturnCode.OK` | Yes | `addOkButton` |
| `SAVE` | &Save | `ReturnCode.SAVE` | Yes | `addSaveButton` |
| `YES` | &Yes | `ReturnCode.YES` | Yes | `addYesButton` |

As you usually want more than one button on a dialog, there are also a number of pre-defined sets of buttons available as members of the `DefaultButtonSet` enumeration.
All of them comprise members of `DefaultButton`.
You can also add any of these default button sets to an existing `MessageDialog` with one of its add buttons helpers.
The following default button sets are available:

| Button set | Contains | Add button set helper | Notes |
|---|---|---|---|
| `OK_CANCEL` | `DefaultButton.OK` and `DefaultButton.Cancel` | `addOkCancelButtons` | |
| `YES_NO` | `DefaultButton.YES` and `DefaultButton.NO` | `addYesNoButtons` | You must set a fallback action if you want the user to be able to press escape to close a dialog with only these buttons. |
| `YES_NO_CANCEL` | `DefaultButton.YES`, `DefaultButton.NO` and `DefaultButton.CANCEL` | `addYesNoCancelButtons` | |
| `SAVE_NO_CANCEL` | `DefaultButton.SAVE`, `DefaultButton.NO`, `DefaultButton.CANCEL` | `addSaveNoCancelButtons` | The label of the no button is overridden to be "Do&n't save". |

If none of the standard `ReturnCode` values are suitable for your button, you may also use `ReturnCode.CUSTOM_1` through `ReturnCode.CUSTOM_5`, which will not conflict with any built-in identifiers.

#### Convenience methods

The `MessageDialog` class also provides a number of convenience methods for showing common types of modal dialogs.
Each of them requires a message string, and optionally a title string and parent window.
They all also support overriding the labels on their buttons via keyword arguments.
They are all thread safe.
The following convenience class methods are provided (keyword arguments for overriding button labels indicated in parentheses):

| Method | Buttons | Return values |
|---|---|---|
| `alert` | OK (`okLabel`) | `None` |
| `confirm` | OK (`okLabel`) and Cancel (`cancelLabel`) | `ReturnCode.OK` or `ReturnCode.Cancel` |
| `ask` | Yes (`yesLabel`), No (`noLabel`) and Cancel (`cancelLabel`) | `ReturnCode.YES`, `ReturnCode.NO` or `ReturnCode.CANCEL` |
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ nuitka==2.5.4

# Creating XML unit test reports
unittest-xml-reporting==3.2.0
# Feed parameters to tests neatly
parameterized==0.9.0

# Building user documentation
Markdown==3.7
Expand Down
6 changes: 4 additions & 2 deletions source/documentationUtils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# -*- coding: UTF-8 -*-
# A part of NonVisual Desktop Access (NVDA)
# Copyright (C) 2006-2023 NV Access Limited, Łukasz Golonka
# Copyright (C) 2006-2024 NV Access Limited, Łukasz Golonka
# This file may be used under the terms of the GNU General Public License, version 2 or later.
# For more details see: https://www.gnu.org/licenses/gpl-2.0.html

Expand All @@ -13,7 +13,6 @@
from logHandler import log
import ui
import queueHandler
from gui.message import messageBox
import wx


Expand Down Expand Up @@ -65,6 +64,9 @@ def reportNoDocumentation(fileName: str, useMsgBox: bool = False) -> None:
f"Documentation not found ({fileName}): possible cause - running from source without building user docs.",
)
if useMsgBox:
# Import late to avoid circular impoort.
from gui.message import messageBox

messageBox(
noDocMessage,
# Translators: the title of an error message dialog
Expand Down
29 changes: 18 additions & 11 deletions source/gui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
# This file is covered by the GNU General Public License.
# See the file COPYING for more details.

from collections.abc import Callable
import os
import ctypes
import warnings
import wx
import wx.adv

Expand All @@ -30,6 +32,8 @@
# messageBox is accessed through `gui.messageBox` as opposed to `gui.message.messageBox` throughout NVDA,
# be cautious when removing
messageBox,
MessageDialog,
displayDialogAsModal,
)
from . import blockAction
from .speechDict import (
Expand Down Expand Up @@ -367,7 +371,7 @@ def onInputGesturesCommand(self, evt):

def onAboutCommand(self, evt):
# Translators: The title of the dialog to show about info for NVDA.
messageBox(versionInfo.aboutMessage, _("About NVDA"), wx.OK)
MessageDialog(None, versionInfo.aboutMessage, _("About NVDA")).Show()

@blockAction.when(blockAction.Context.SECURE_MODE)
def onCheckForUpdateCommand(self, evt):
Expand Down Expand Up @@ -878,21 +882,24 @@ def showGui():
wx.CallAfter(mainFrame.showGui)


def runScriptModalDialog(dialog, callback=None):
def runScriptModalDialog(dialog: wx.Dialog, callback: Callable[[int], Any] | None = None):
"""Run a modal dialog from a script.
This will not block the caller,
but will instead call C{callback} (if provided) with the result from the dialog.
This will not block the caller, but will instead call callback (if provided) with the result from the dialog.
The dialog will be destroyed once the callback has returned.
@param dialog: The dialog to show.
@type dialog: C{wx.Dialog}
@param callback: The optional callable to call with the result from the dialog.
@type callback: callable
This function is deprecated.
Use :class:`message.MessageDialog` instead.
:param dialog: The dialog to show.
:param callback: The optional callable to call with the result from the dialog.
"""
warnings.warn(
"showScriptModalDialog is deprecated. Use an instance of message.MessageDialog and wx.CallAfter instead.",
DeprecationWarning,
)

def run():
mainFrame.prePopup()
res = dialog.ShowModal()
mainFrame.postPopup()
res = displayDialogAsModal(dialog)
if callback:
callback(res)
dialog.Destroy()
Expand Down
Loading

0 comments on commit 2477780

Please sign in to comment.