From dfdd299a49d43879157d08ba86635fc93b628f82 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 18 Sep 2024 17:16:22 +1000 Subject: [PATCH 001/209] Working refactored message dialog based on extant message dialog and draft spec --- source/gui/messageDialog.py | 170 ++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 source/gui/messageDialog.py diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py new file mode 100644 index 00000000000..817c3794bd1 --- /dev/null +++ b/source/gui/messageDialog.py @@ -0,0 +1,170 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2024 NV Access Limited +# This file may be used under the terms of the GNU General Public License, version 2 or later. +# For more details see: https://www.gnu.org/licenses/gpl-2.0.html + +from enum import IntEnum, auto +import winsound + +import wx + +from .contextHelp import ContextHelpMixin +from .dpiScalingHelper import DpiScalingHelperMixinWithoutInit +from .guiHelper import SIPABCMeta +from gui import guiHelper + + +class MessageDialogReturnCode(IntEnum): + OK = wx.OK + YES = wx.YES + NO = wx.NO + CANCEL = wx.CANCEL + + +class MessageDialogType(IntEnum): + STANDARD = auto() + WARNING = auto() + ERROR = auto() + + @property + def _wxIconId(self) -> int | None: + 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: + match self: + case self.ERROR: + return winsound.MB_ICONHAND + case self.WARNING: + return winsound.MB_ICONASTERISK + case _: + return None + + +class MessageDialog(DpiScalingHelperMixinWithoutInit, ContextHelpMixin, wx.Dialog, metaclass=SIPABCMeta): + ''' + def __init__( + self, + parent: wx.Window, + message: str, + caption: str = wx.MessageBoxCaptionStr, + style: int = wx.OK | wx.CENTER, + **kwargs, + ) -> None: + super().__init__(parent, message, caption, style, **kwargs) + + def Show(self) -> None: + """Show a non-blocking dialog. + Attach buttons with button handlers""" + pass + + def defaultAction(self) -> None: + return None + + @staticmethod + def CloseInstances() -> None: + """Close all dialogs with a default action""" + pass + + @staticmethod + def BlockingInstancesExist() -> bool: + """Check if dialogs are open without a default return code + (eg Show without `self._defaultReturnCode`, or ShowModal without `wx.CANCEL`)""" + pass + + @staticmethod + def FocusBlockingInstances() -> None: + """Raise and focus open dialogs without a default return code + (eg Show without `self._defaultReturnCode`, or ShowModal without `wx.CANCEL`)""" + pass + ''' + + def _addButtons(self, buttonHelper): + """Adds ok / cancel buttons. Can be overridden to provide alternative functionality.""" + ok = buttonHelper.addButton( + self, + id=wx.ID_OK, + # Translators: An ok button on a message dialog. + label=_("OK"), + ) + ok.SetDefault() + ok.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.OK)) + + cancel = buttonHelper.addButton( + self, + id=wx.ID_CANCEL, + # Translators: A cancel button on a message dialog. + label=_("Cancel"), + ) + 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: MessageDialogType): + if (iconID := type._wxIconId) is not None: + icon = wx.ArtProvider.GetIcon(iconID, client=wx.ART_MESSAGE_BOX) + self.SetIcon(icon) + + def _setSound(self, type: MessageDialogType): + self._soundID = type._windowsSoundId + + def _playSound(self): + if self._soundID is not None: + winsound.MessageBeep(self._soundID) + + def __init__( + self, + parent: wx.Window, + message: str, + title: str = wx.MessageBoxCaptionStr, + dialogType: MessageDialogType = MessageDialogType.STANDARD, + ): + super().__init__(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 it being 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.CentreOnParent() + + def _onDialogActivated(self, evt): + evt.Skip() + + def _onShowEvt(self, evt): + """ + :type evt: wx.ShowEvent + """ + if evt.IsShown(): + self._playSound() + evt.Skip() From 38c99cb286726ca0d1bba337c79dd6b18326720f Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 19 Sep 2024 16:12:03 +1000 Subject: [PATCH 002/209] Improve positioning --- source/gui/messageDialog.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 817c3794bd1..06a33f857de 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -156,7 +156,13 @@ def __init__( ) mainSizer.Fit(self) self.SetSizer(mainSizer) - self.CentreOnParent() + # Import late to avoid circular import + from gui import mainFrame + if parent == 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() def _onDialogActivated(self, evt): evt.Skip() From 07ba293b8189c0f80788ee0ee0f7f46b56365be8 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 19 Sep 2024 17:11:46 +1000 Subject: [PATCH 003/209] Explicitly focus default item --- source/gui/messageDialog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 06a33f857de..7888848a310 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -173,4 +173,5 @@ def _onShowEvt(self, evt): """ if evt.IsShown(): self._playSound() + self.GetDefaultItem().SetFocus() evt.Skip() From a479a4f043bd9fa8b9d8b5fbde8de2547ab2dea1 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 19 Sep 2024 17:23:59 +1000 Subject: [PATCH 004/209] Improved types and type hints --- source/gui/messageDialog.py | 37 ++++++++++++++----------------------- 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 7888848a310..065059e1b6a 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -3,7 +3,7 @@ # 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 -from enum import IntEnum, auto +from enum import Enum, IntEnum, auto import winsound import wx @@ -21,7 +21,7 @@ class MessageDialogReturnCode(IntEnum): CANCEL = wx.CANCEL -class MessageDialogType(IntEnum): +class MessageDialogType(Enum): STANDARD = auto() WARNING = auto() ERROR = auto() @@ -48,24 +48,13 @@ def _windowsSoundId(self) -> int | None: class MessageDialog(DpiScalingHelperMixinWithoutInit, ContextHelpMixin, wx.Dialog, metaclass=SIPABCMeta): - ''' - def __init__( - self, - parent: wx.Window, - message: str, - caption: str = wx.MessageBoxCaptionStr, - style: int = wx.OK | wx.CENTER, - **kwargs, - ) -> None: - super().__init__(parent, message, caption, style, **kwargs) - - def Show(self) -> None: - """Show a non-blocking dialog. - Attach buttons with button handlers""" - pass + # def Show(self) -> None: + # """Show a non-blocking dialog. + # Attach buttons with button handlers""" + # pass - def defaultAction(self) -> None: - return None + # def defaultAction(self) -> None: + # return None @staticmethod def CloseInstances() -> None: @@ -83,7 +72,6 @@ def FocusBlockingInstances() -> None: """Raise and focus open dialogs without a default return code (eg Show without `self._defaultReturnCode`, or ShowModal without `wx.CANCEL`)""" pass - ''' def _addButtons(self, buttonHelper): """Adds ok / cancel buttons. Can be overridden to provide alternative functionality.""" @@ -93,7 +81,7 @@ def _addButtons(self, buttonHelper): # Translators: An ok button on a message dialog. label=_("OK"), ) - ok.SetDefault() + # ok.SetDefault() ok.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.OK)) cancel = buttonHelper.addButton( @@ -103,6 +91,8 @@ def _addButtons(self, buttonHelper): label=_("Cancel"), ) cancel.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.CANCEL)) + cancel.SetDefault() + # self.SetDefaultItem(cancel) def _addContents(self, contentsSizer: guiHelper.BoxSizerHelper): """Adds additional contents to the dialog, before the buttons. @@ -123,7 +113,7 @@ def _playSound(self): def __init__( self, - parent: wx.Window, + parent: wx.Window | None, message: str, title: str = wx.MessageBoxCaptionStr, dialogType: MessageDialogType = MessageDialogType.STANDARD, @@ -158,6 +148,7 @@ def __init__( self.SetSizer(mainSizer) # Import late to avoid circular import from gui import mainFrame + if parent == 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() @@ -167,7 +158,7 @@ def __init__( def _onDialogActivated(self, evt): evt.Skip() - def _onShowEvt(self, evt): + def _onShowEvt(self, evt: wx.ShowEvent): """ :type evt: wx.ShowEvent """ From 3326a98365119388bd7ef0432cb41b36ff75fcde Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 19 Sep 2024 17:26:15 +1000 Subject: [PATCH 005/209] Switched to setLabelText to avoid mnemonic issue --- source/gui/messageDialog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 065059e1b6a..e72560cc9ee 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -128,9 +128,9 @@ def __init__( mainSizer = wx.BoxSizer(wx.VERTICAL) contentsSizer = guiHelper.BoxSizerHelper(parent=self, orientation=wx.VERTICAL) - # Double ampersand in the dialog's label to avoid it being interpreted as an accelerator. - label = message.replace("&", "&&") - text = wx.StaticText(self, label=label) + # Use SetLabelText to avoid ampersands being interpreted as accelerators. + text = wx.StaticText(self) + text.SetLabelText(message) text.Wrap(self.scaleSize(self.GetSize().Width)) contentsSizer.addItem(text) self._addContents(contentsSizer) From 1a8f82f1081472bdf48e467632535331d2c4bb5d Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 24 Sep 2024 11:35:32 +1000 Subject: [PATCH 006/209] Switched to icon bundles --- source/gui/messageDialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index e72560cc9ee..303936a5f48 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -101,8 +101,8 @@ def _addContents(self, contentsSizer: guiHelper.BoxSizerHelper): def _setIcon(self, type: MessageDialogType): if (iconID := type._wxIconId) is not None: - icon = wx.ArtProvider.GetIcon(iconID, client=wx.ART_MESSAGE_BOX) - self.SetIcon(icon) + icon = wx.ArtProvider.GetIconBundle(iconID, client=wx.ART_MESSAGE_BOX) + self.SetIcons(icon) def _setSound(self, type: MessageDialogType): self._soundID = type._windowsSoundId From 99dd605129cecf7142df1aa5a95203a3d9642823 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 24 Sep 2024 11:36:26 +1000 Subject: [PATCH 007/209] Renamed private methods to be dunder --- source/gui/messageDialog.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 303936a5f48..a8891da3ccc 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -99,17 +99,17 @@ def _addContents(self, contentsSizer: guiHelper.BoxSizerHelper): Subclasses may implement this method. """ - def _setIcon(self, type: MessageDialogType): + def __setIcon(self, type: MessageDialogType): if (iconID := type._wxIconId) is not None: icon = wx.ArtProvider.GetIconBundle(iconID, client=wx.ART_MESSAGE_BOX) self.SetIcons(icon) - def _setSound(self, type: MessageDialogType): - self._soundID = type._windowsSoundId + def __setSound(self, type: MessageDialogType): + self.__soundID = type._windowsSoundId - def _playSound(self): - if self._soundID is not None: - winsound.MessageBeep(self._soundID) + def __playSound(self): + if self.__soundID is not None: + winsound.MessageBeep(self.__soundID) def __init__( self, @@ -120,8 +120,8 @@ def __init__( ): super().__init__(parent, title=title) - self._setIcon(dialogType) - self._setSound(dialogType) + self.__setIcon(dialogType) + self.__setSound(dialogType) self.Bind(wx.EVT_SHOW, self._onShowEvt, source=self) self.Bind(wx.EVT_ACTIVATE, self._onDialogActivated, source=self) @@ -163,6 +163,6 @@ def _onShowEvt(self, evt: wx.ShowEvent): :type evt: wx.ShowEvent """ if evt.IsShown(): - self._playSound() + self.__playSound() self.GetDefaultItem().SetFocus() evt.Skip() From 2a3df490751b89c80b9a9d4ccf2a54d9bb59edf5 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 24 Sep 2024 11:41:45 +1000 Subject: [PATCH 008/209] Some manual test cases --- source/gui/__init__.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/source/gui/__init__.py b/source/gui/__init__.py index d9c348a37be..2f198011400 100644 --- a/source/gui/__init__.py +++ b/source/gui/__init__.py @@ -69,6 +69,7 @@ import winUser import api import NVDAState +from gui.messageDialog import MessageDialog as NMD if NVDAState._allowDeprecatedAPI(): @@ -534,6 +535,23 @@ def onConfigProfilesCommand(self, evt): ProfilesDialog(mainFrame).Show() self.postPopup() + def onOkDialog(self, evt): + # self.prePopup() + dlg = NMD( + self, + "This is a dialog with an Ok button. Test that:\n" + "- The dialog has the expected buttons\n" + "- Pressing the Ok button has the intended effect\n" + "- Pressing Esc has the intended effect\n" + "- Pressing Alt+F4 has the intended effect\n" + "- Using the close icon/system menu close item has the intended effect\n" + "- You are still able to interact with NVDA's GUI\n" + "- Exiting NVDA does not cause errors", + "Non-modal Ok Dialog", + ) + dlg.Show() + # self.postPopup() + class SysTrayIcon(wx.adv.TaskBarIcon): def __init__(self, frame: MainFrame): @@ -640,6 +658,15 @@ def __init__(self, frame: MainFrame): ) self.Bind(wx.EVT_MENU, frame.onExitCommand, item) + dialogMenu = wx.Menu() + item = dialogMenu.Append(wx.ID_ANY, "Ok") + self.Bind(wx.EVT_MENU, frame.onOkDialog, item) + item = dialogMenu.Append(wx.ID_ANY, "Ok and Cancel") + item = dialogMenu.Append(wx.ID_ANY, "Yes and No") + item = dialogMenu.Append(wx.ID_ANY, "Yes, No and Cancel") + + self.menu.AppendSubMenu(dialogMenu, "&Dialog") + self.Bind(wx.adv.EVT_TASKBAR_LEFT_DOWN, self.onActivate) self.Bind(wx.adv.EVT_TASKBAR_RIGHT_DOWN, self.onActivate) From 947f669ac6009d396d8a30571c6d9ddff8399e62 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 24 Sep 2024 12:27:08 +1000 Subject: [PATCH 009/209] Added check for default item before focusing --- source/gui/messageDialog.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index a8891da3ccc..c61adccf93d 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -164,5 +164,6 @@ def _onShowEvt(self, evt: wx.ShowEvent): """ if evt.IsShown(): self.__playSound() - self.GetDefaultItem().SetFocus() + if (defaultItem := self.GetDefaultItem()) is not None: + defaultItem.SetFocus() evt.Skip() From 6bd8c8a075a7305c962eb96e2e52e5cd302648af Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 24 Sep 2024 12:37:19 +1000 Subject: [PATCH 010/209] Minor changes to make temp code work better --- source/gui/messageDialog.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index c61adccf93d..4cf7faf189c 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -82,7 +82,7 @@ def _addButtons(self, buttonHelper): label=_("OK"), ) # ok.SetDefault() - ok.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.OK)) + # ok.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.OK)) cancel = buttonHelper.addButton( self, @@ -90,8 +90,8 @@ def _addButtons(self, buttonHelper): # Translators: A cancel button on a message dialog. label=_("Cancel"), ) - cancel.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.CANCEL)) - cancel.SetDefault() + # cancel.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.CANCEL)) + # cancel.SetDefault() # self.SetDefaultItem(cancel) def _addContents(self, contentsSizer: guiHelper.BoxSizerHelper): @@ -124,6 +124,7 @@ def __init__( self.__setSound(dialogType) self.Bind(wx.EVT_SHOW, self._onShowEvt, source=self) self.Bind(wx.EVT_ACTIVATE, self._onDialogActivated, source=self) + self.Bind(wx.EVT_CLOSE, self._onCloseEvent) mainSizer = wx.BoxSizer(wx.VERTICAL) contentsSizer = guiHelper.BoxSizerHelper(parent=self, orientation=wx.VERTICAL) @@ -167,3 +168,6 @@ def _onShowEvt(self, evt: wx.ShowEvent): if (defaultItem := self.GetDefaultItem()) is not None: defaultItem.SetFocus() evt.Skip() + + def _onCloseEvent(self, evt: wx.CloseEvent): + self.Destroy() From 2e255cba88e629e82f769ed21d142b321654a9b9 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 25 Sep 2024 11:15:28 +1000 Subject: [PATCH 011/209] Implementation of addButton, addOkButton and addCancelButton --- source/gui/messageDialog.py | 53 ++++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 4cf7faf189c..379ac8e9c17 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -4,6 +4,7 @@ # For more details see: https://www.gnu.org/licenses/gpl-2.0.html from enum import Enum, IntEnum, auto +from typing import Any, Callable import winsound import wx @@ -75,24 +76,26 @@ def FocusBlockingInstances() -> None: def _addButtons(self, buttonHelper): """Adds ok / cancel buttons. Can be overridden to provide alternative functionality.""" - ok = buttonHelper.addButton( - self, - id=wx.ID_OK, - # Translators: An ok button on a message dialog. - label=_("OK"), - ) + # ok = 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 = 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)) # cancel.SetDefault() # self.SetDefaultItem(cancel) + self.addOkButton() + self.addCancelButton() def _addContents(self, contentsSizer: guiHelper.BoxSizerHelper): """Adds additional contents to the dialog, before the buttons. @@ -137,6 +140,7 @@ def __init__( self._addContents(contentsSizer) buttonHelper = guiHelper.ButtonHelper(wx.HORIZONTAL) + self.__buttonHelper = buttonHelper self._addButtons(buttonHelper) contentsSizer.addDialogDismissButtons(buttonHelper) @@ -171,3 +175,28 @@ def _onShowEvt(self, evt: wx.ShowEvent): def _onCloseEvent(self, evt: wx.CloseEvent): self.Destroy() + + def addButton(self, *args, callback: Callable[[wx.CommandEvent], Any] | None = None, **kwargs): + button = self.__buttonHelper.addButton(*args, **kwargs) + button.Bind(wx.EVT_BUTTON, callback) + + def addOkButton(self): + self.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)) + + def addCancelButton(self): + self.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)) + # cancel.SetDefault() + # self.SetDefaultItem(cancel) From ce826f25f2570257dff43179c8a653494683ac25 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 25 Sep 2024 11:31:26 +1000 Subject: [PATCH 012/209] Return self for chaining, and add a option to set as default --- source/gui/messageDialog.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 379ac8e9c17..b14a6a76f99 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -176,27 +176,31 @@ def _onShowEvt(self, evt: wx.ShowEvent): def _onCloseEvent(self, evt: wx.CloseEvent): self.Destroy() - def addButton(self, *args, callback: Callable[[wx.CommandEvent], Any] | None = None, **kwargs): + def addButton( + self, + *args, + callback: Callable[[wx.CommandEvent], Any] | None = None, + default: bool = False, + **kwargs, + ): button = self.__buttonHelper.addButton(*args, **kwargs) button.Bind(wx.EVT_BUTTON, callback) + if default: + button.SetDefault() + return self def addOkButton(self): - self.addButton( + return self.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)) def addCancelButton(self): - self.addButton( + return self.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)) - # cancel.SetDefault() - # self.SetDefaultItem(cancel) From 9a7dbf87175c6a9d3ae521fb9fbf868ff85c4a4d Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 25 Sep 2024 11:50:29 +1000 Subject: [PATCH 013/209] Added close functionality --- source/gui/messageDialog.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index b14a6a76f99..588330b2c14 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -94,8 +94,8 @@ def _addButtons(self, buttonHelper): # cancel.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.CANCEL)) # cancel.SetDefault() # self.SetDefaultItem(cancel) - self.addOkButton() - self.addCancelButton() + # self.addOkButton() + # self.addCancelButton() def _addContents(self, contentsSizer: guiHelper.BoxSizerHelper): """Adds additional contents to the dialog, before the buttons. @@ -184,23 +184,32 @@ def addButton( **kwargs, ): button = self.__buttonHelper.addButton(*args, **kwargs) - button.Bind(wx.EVT_BUTTON, callback) + button.Bind(wx.EVT_BUTTON, self.__closeFirst(callback)) if default: button.SetDefault() return self - def addOkButton(self): + def addOkButton(self, callback): return self.addButton( self, id=wx.ID_OK, # Translators: An ok button on a message dialog. label=_("OK"), + callback=callback, ) - def addCancelButton(self): + def addCancelButton(self, callback): return self.addButton( self, id=wx.ID_CANCEL, # Translators: A cancel button on a message dialog. label=_("Cancel"), + callback=callback, ) + + def __closeFirst(self, callback): + def function(*args, **kwargs): + self.Close() + return callback(*args, **kwargs) + + return function From 768e0520edf7d4ea9e9abb442e907133fbab40b1 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 25 Sep 2024 12:14:14 +1000 Subject: [PATCH 014/209] Fixed layout issues and updated test case --- source/gui/__init__.py | 34 ++++++++++++++++++++-------------- source/gui/messageDialog.py | 28 ++++++++++++++++++---------- 2 files changed, 38 insertions(+), 24 deletions(-) diff --git a/source/gui/__init__.py b/source/gui/__init__.py index 2f198011400..925db96db9b 100644 --- a/source/gui/__init__.py +++ b/source/gui/__init__.py @@ -535,20 +535,26 @@ def onConfigProfilesCommand(self, evt): ProfilesDialog(mainFrame).Show() self.postPopup() - def onOkDialog(self, evt): - # self.prePopup() - dlg = NMD( - self, - "This is a dialog with an Ok button. Test that:\n" - "- The dialog has the expected buttons\n" - "- Pressing the Ok button has the intended effect\n" - "- Pressing Esc has the intended effect\n" - "- Pressing Alt+F4 has the intended effect\n" - "- Using the close icon/system menu close item has the intended effect\n" - "- You are still able to interact with NVDA's GUI\n" - "- Exiting NVDA does not cause errors", - "Non-modal Ok Dialog", + def onModelessOkCancelDialog(self, evt): + self.prePopup() + dlg = ( + NMD( + self, + "This is a modeless dialog with OK and Cancel buttons. Test that:\n" + "- The dialog appears correctly both visually and to NVDA\n" + "- The dialog has the expected buttons\n" + "- Pressing the Ok or Cancel button has the intended effect\n" + "- Pressing Esc has the intended effect\n" + "- Pressing Alt+F4 has the intended effect\n" + "- Using the close icon/system menu close item has the intended effect\n" + "- You are still able to interact with NVDA's GUI\n" + "- Exiting NVDA does not cause errors", + "Non-modal OK/Cancel Dialog", + ) + .addOkButton(callback=lambda _: messageBox("You pressed OK!")) + .addCancelButton(callback=lambda _: messageBox("You pressed Cancel!")) ) + dlg.Show() # self.postPopup() @@ -660,8 +666,8 @@ def __init__(self, frame: MainFrame): dialogMenu = wx.Menu() item = dialogMenu.Append(wx.ID_ANY, "Ok") - self.Bind(wx.EVT_MENU, frame.onOkDialog, item) item = dialogMenu.Append(wx.ID_ANY, "Ok and Cancel") + self.Bind(wx.EVT_MENU, frame.onModelessOkCancelDialog, item) item = dialogMenu.Append(wx.ID_ANY, "Yes and No") item = dialogMenu.Append(wx.ID_ANY, "Yes, No and Cancel") diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 588330b2c14..41656967ab2 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -13,6 +13,7 @@ from .dpiScalingHelper import DpiScalingHelperMixinWithoutInit from .guiHelper import SIPABCMeta from gui import guiHelper +from logHandler import log class MessageDialogReturnCode(IntEnum): @@ -49,13 +50,18 @@ def _windowsSoundId(self) -> int | None: class MessageDialog(DpiScalingHelperMixinWithoutInit, ContextHelpMixin, wx.Dialog, metaclass=SIPABCMeta): - # def Show(self) -> None: - # """Show a non-blocking dialog. - # Attach buttons with button handlers""" - # pass - - # def defaultAction(self) -> None: - # return None + def Show(self) -> None: + """Show a non-blocking dialog. + Attach buttons with button handlers""" + log.info(f"{self.__isLayoutFullyRealized=}") + if not self.__isLayoutFullyRealized: + self.__contentsSizer.addDialogDismissButtons(self.__buttonHelper) + self.__mainSizer.Fit(self) + self.__isLayoutFullyRealized = True + super().Show() + + def defaultAction(self) -> None: + return None @staticmethod def CloseInstances() -> None: @@ -122,6 +128,7 @@ def __init__( dialogType: MessageDialogType = MessageDialogType.STANDARD, ): super().__init__(parent, title=title) + self.__isLayoutFullyRealized = False self.__setIcon(dialogType) self.__setSound(dialogType) @@ -131,6 +138,8 @@ def __init__( mainSizer = wx.BoxSizer(wx.VERTICAL) contentsSizer = guiHelper.BoxSizerHelper(parent=self, orientation=wx.VERTICAL) + self.__contentsSizer = contentsSizer + self.__mainSizer = mainSizer # Use SetLabelText to avoid ampersands being interpreted as accelerators. text = wx.StaticText(self) @@ -141,15 +150,14 @@ def __init__( buttonHelper = guiHelper.ButtonHelper(wx.HORIZONTAL) self.__buttonHelper = buttonHelper - self._addButtons(buttonHelper) - contentsSizer.addDialogDismissButtons(buttonHelper) + # self._addButtons(buttonHelper) mainSizer.Add( contentsSizer.sizer, border=guiHelper.BORDER_FOR_DIALOGS, flag=wx.ALL, ) - mainSizer.Fit(self) + # mainSizer.Fit(self) self.SetSizer(mainSizer) # Import late to avoid circular import from gui import mainFrame From 7e6754f0235c700d3ab842822d12f09e393de75c Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 25 Sep 2024 12:29:00 +1000 Subject: [PATCH 015/209] Added post popup call --- source/gui/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/gui/__init__.py b/source/gui/__init__.py index 925db96db9b..8af59c03cce 100644 --- a/source/gui/__init__.py +++ b/source/gui/__init__.py @@ -556,7 +556,7 @@ def onModelessOkCancelDialog(self, evt): ) dlg.Show() - # self.postPopup() + self.postPopup() class SysTrayIcon(wx.adv.TaskBarIcon): From 601a193f1598c1072a9dfd1960f201ff0ba6b0ba Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 26 Sep 2024 11:20:25 +1000 Subject: [PATCH 016/209] Added infrastructure for updates to API not yet implemented --- source/gui/messageDialog.py | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 41656967ab2..b7ca481d014 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -4,7 +4,7 @@ # For more details see: https://www.gnu.org/licenses/gpl-2.0.html from enum import Enum, IntEnum, auto -from typing import Any, Callable +from typing import Any, Callable, NamedTuple import winsound import wx @@ -17,11 +17,14 @@ class MessageDialogReturnCode(IntEnum): - OK = wx.OK - YES = wx.YES - NO = wx.NO - CANCEL = wx.CANCEL - + 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 class MessageDialogType(Enum): STANDARD = auto() @@ -49,6 +52,26 @@ def _windowsSoundId(self) -> int | None: return None +class MessageDialogButton(NamedTuple): + id: MessageDialogReturnCode + label: str + default: bool = False + closes_dialog: bool = True + + +class DefaultMessageDialogButtons(MessageDialogButton, Enum): + OK = MessageDialogButton(id=MessageDialogReturnCode.OK, label=_("OK"), default=True) + YES = MessageDialogButton(id=MessageDialogReturnCode.YES, label=_("&Yes"), default=True) + NO = MessageDialogButton(id=MessageDialogReturnCode.NO, label=_("&No")) + CANCEL = MessageDialogButton(id=MessageDialogReturnCode.CANCEL, label=_("Cancel")) + SAVE = MessageDialogButton(id=MessageDialogReturnCode.SAVE, label=_("&Save")) + APPLY = MessageDialogButton(id=MessageDialogReturnCode.APPLY, label=_("&Apply")) + CLOSE = MessageDialogButton(id=MessageDialogReturnCode.CLOSE, label=_("Close")) + HELP = MessageDialogButton(id=MessageDialogReturnCode.HELP, label=_("Help")) + + + + class MessageDialog(DpiScalingHelperMixinWithoutInit, ContextHelpMixin, wx.Dialog, metaclass=SIPABCMeta): def Show(self) -> None: """Show a non-blocking dialog. From a44063d6d585f80acfdcfa8fb30fb937482a03fb Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 26 Sep 2024 12:31:10 +1000 Subject: [PATCH 017/209] Swiutch to using partials for callback calling --- source/gui/messageDialog.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index b7ca481d014..7b96421c648 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -14,6 +14,7 @@ from .guiHelper import SIPABCMeta from gui import guiHelper from logHandler import log +from functools import partial class MessageDialogReturnCode(IntEnum): @@ -55,6 +56,7 @@ def _windowsSoundId(self) -> int | None: class MessageDialogButton(NamedTuple): id: MessageDialogReturnCode label: str + callback: Callable[[wx.CommandEvent], Any] | None = None default: bool = False closes_dialog: bool = True @@ -70,8 +72,6 @@ class DefaultMessageDialogButtons(MessageDialogButton, Enum): HELP = MessageDialogButton(id=MessageDialogReturnCode.HELP, label=_("Help")) - - class MessageDialog(DpiScalingHelperMixinWithoutInit, ContextHelpMixin, wx.Dialog, metaclass=SIPABCMeta): def Show(self) -> None: """Show a non-blocking dialog. @@ -215,7 +215,8 @@ def addButton( **kwargs, ): button = self.__buttonHelper.addButton(*args, **kwargs) - button.Bind(wx.EVT_BUTTON, self.__closeFirst(callback)) + # button.Bind(wx.EVT_BUTTON, self.__closeFirst(callback)) + button.Bind(wx.EVT_BUTTON, partial(self.__call_callback, should_close=True, callback=callback)) if default: button.SetDefault() return self @@ -238,9 +239,7 @@ def addCancelButton(self, callback): callback=callback, ) - def __closeFirst(self, callback): - def function(*args, **kwargs): + def __call_callback(self, *args, should_close, callback, **kwargs): + if should_close: self.Close() - return callback(*args, **kwargs) - - return function + return callback(*args, **kwargs) From 3e5b03c13d8240b57371e9c9ec7f617ac39243d3 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 2 Oct 2024 17:05:57 +1000 Subject: [PATCH 018/209] Added translator comments and some docstrings --- source/gui/messageDialog.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 7b96421c648..07473757aec 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -27,13 +27,22 @@ class MessageDialogReturnCode(IntEnum): CLOSE = wx.ID_CLOSE HELP = wx.ID_HELP + class MessageDialogType(Enum): + """Types of message dialogs. + These are used to determine the icon and sound to play when the dialog is shown. + """ + STANDARD = auto() WARNING = auto() ERROR = auto() @property def _wxIconId(self) -> int | None: + """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 @@ -44,6 +53,10 @@ def _wxIconId(self) -> int | 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 @@ -54,21 +67,42 @@ def _windowsSoundId(self) -> int | None: class MessageDialogButton(NamedTuple): + """A button to add to a message dialog.""" + id: MessageDialogReturnCode + """The ID to use for this button.""" + label: str + """The label to display on the button.""" + callback: Callable[[wx.CommandEvent], Any] | None = None + """The callback to call when the button is clicked.""" + default: bool = False + """Whether this button should be the default button.""" + closes_dialog: bool = True + """Whether this button should close the dialog when clicked.""" class DefaultMessageDialogButtons(MessageDialogButton, Enum): + """Default buttons for message dialogs.""" + + # Translators: An ok button on a message dialog. OK = MessageDialogButton(id=MessageDialogReturnCode.OK, label=_("OK"), default=True) + # Translators: A yes button on a message dialog. YES = MessageDialogButton(id=MessageDialogReturnCode.YES, label=_("&Yes"), default=True) + # Translators: A no button on a message dialog. NO = MessageDialogButton(id=MessageDialogReturnCode.NO, label=_("&No")) + # Translators: A cancel button on a message dialog. CANCEL = MessageDialogButton(id=MessageDialogReturnCode.CANCEL, label=_("Cancel")) + # Translators: A save button on a message dialog. SAVE = MessageDialogButton(id=MessageDialogReturnCode.SAVE, label=_("&Save")) + # Translators: An apply button on a message dialog. APPLY = MessageDialogButton(id=MessageDialogReturnCode.APPLY, label=_("&Apply")) + # Translators: A close button on a message dialog. CLOSE = MessageDialogButton(id=MessageDialogReturnCode.CLOSE, label=_("Close")) + # Translators: A help button on a message dialog. HELP = MessageDialogButton(id=MessageDialogReturnCode.HELP, label=_("Help")) From e7e1d29f376dbdddfc79c9689b6be4c606da8933 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 3 Oct 2024 16:28:00 +1000 Subject: [PATCH 019/209] Added type checking to `__call_callback` --- source/gui/messageDialog.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 07473757aec..f32d551af93 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -276,4 +276,5 @@ def addCancelButton(self, callback): def __call_callback(self, *args, should_close, callback, **kwargs): if should_close: self.Close() - return callback(*args, **kwargs) + if callback is not None: + return callback(*args, **kwargs) From 08360fd6fc72f524f1f56b2f825e068ec3deaf65 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 3 Oct 2024 16:59:24 +1000 Subject: [PATCH 020/209] Made `addButton` a `singledispatchmethod` that will add a `MessageDialogButton` if provided --- source/gui/messageDialog.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index f32d551af93..8868e7366a3 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -14,7 +14,7 @@ from .guiHelper import SIPABCMeta from gui import guiHelper from logHandler import log -from functools import partial +from functools import partial, singledispatchmethod class MessageDialogReturnCode(IntEnum): @@ -241,6 +241,7 @@ def _onShowEvt(self, evt: wx.ShowEvent): def _onCloseEvent(self, evt: wx.CloseEvent): self.Destroy() + @singledispatchmethod def addButton( self, *args, @@ -255,6 +256,28 @@ def addButton( button.SetDefault() return self + @addButton.register + def _( + self, + button: MessageDialogButton, + *args, + callback: Callable[[wx.CommandEvent], Any] | None = None, + default: bool | None = None, + **kwargs, + ): + keywords = dict( + id=button.id, + label=button.label, + callback=button.callback, + default=button.default, + ) + if default is not None: + keywords["default"] = default + if callback is not None: + keywords["callback"] = callback + keywords.update(kwargs) + return self.addButton(self, *args, **keywords) + def addOkButton(self, callback): return self.addButton( self, From 917d8e46a9bd1e0b2afe621e6530f6daeefd9bea Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 3 Oct 2024 17:10:55 +1000 Subject: [PATCH 021/209] Refactored `addOkButton` and `addCancelButton` to be `partialmethod`s rather than normal functions. --- source/gui/messageDialog.py | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 8868e7366a3..5a87babac52 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -14,7 +14,7 @@ from .guiHelper import SIPABCMeta from gui import guiHelper from logHandler import log -from functools import partial, singledispatchmethod +from functools import partial, partialmethod, singledispatchmethod class MessageDialogReturnCode(IntEnum): @@ -278,23 +278,8 @@ def _( keywords.update(kwargs) return self.addButton(self, *args, **keywords) - def addOkButton(self, callback): - return self.addButton( - self, - id=wx.ID_OK, - # Translators: An ok button on a message dialog. - label=_("OK"), - callback=callback, - ) - - def addCancelButton(self, callback): - return self.addButton( - self, - id=wx.ID_CANCEL, - # Translators: A cancel button on a message dialog. - label=_("Cancel"), - callback=callback, - ) + addOkButton = partialmethod(addButton, DefaultMessageDialogButtons.OK) + addCancelButton = partialmethod(addButton, DefaultMessageDialogButtons.CANCEL) def __call_callback(self, *args, should_close, callback, **kwargs): if should_close: From 273d771defb3a7b7bfcaf04ad1a99bc1745aa5cf Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 4 Oct 2024 16:20:35 +1000 Subject: [PATCH 022/209] Added multi button add method --- source/gui/messageDialog.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 5a87babac52..55a46402ec3 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -4,7 +4,7 @@ # For more details see: https://www.gnu.org/licenses/gpl-2.0.html from enum import Enum, IntEnum, auto -from typing import Any, Callable, NamedTuple +from typing import Any, Callable, Iterable, NamedTuple import winsound import wx @@ -229,9 +229,6 @@ def _onDialogActivated(self, evt): evt.Skip() def _onShowEvt(self, evt: wx.ShowEvent): - """ - :type evt: wx.ShowEvent - """ if evt.IsShown(): self.__playSound() if (defaultItem := self.GetDefaultItem()) is not None: @@ -281,6 +278,11 @@ def _( addOkButton = partialmethod(addButton, DefaultMessageDialogButtons.OK) addCancelButton = partialmethod(addButton, DefaultMessageDialogButtons.CANCEL) + def addButtons(self, *buttons: Iterable[MessageDialogButton]): + for button in buttons: + self.addButton(button) + return self + def __call_callback(self, *args, should_close, callback, **kwargs): if should_close: self.Close() From 8f1bcba0cef6fb9a4090c16a320276e999738989 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 4 Oct 2024 17:42:40 +1000 Subject: [PATCH 023/209] Documentation improvements --- source/gui/messageDialog.py | 52 ++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 55a46402ec3..21ffc9850bb 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -13,7 +13,6 @@ from .dpiScalingHelper import DpiScalingHelperMixinWithoutInit from .guiHelper import SIPABCMeta from gui import guiHelper -from logHandler import log from functools import partial, partialmethod, singledispatchmethod @@ -110,7 +109,6 @@ class MessageDialog(DpiScalingHelperMixinWithoutInit, ContextHelpMixin, wx.Dialo def Show(self) -> None: """Show a non-blocking dialog. Attach buttons with button handlers""" - log.info(f"{self.__isLayoutFullyRealized=}") if not self.__isLayoutFullyRealized: self.__contentsSizer.addDialogDismissButtons(self.__buttonHelper) self.__mainSizer.Fit(self) @@ -138,27 +136,9 @@ def FocusBlockingInstances() -> None: pass def _addButtons(self, buttonHelper): - """Adds ok / cancel buttons. Can be overridden to provide alternative functionality.""" - # ok = buttonHelper.addButton( - # self, - # id=wx.ID_OK, - # Translators: An ok button on a message dialog. - # label=_("OK"), - # ) - # ok.SetDefault() - # ok.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.OK)) - - # cancel = buttonHelper.addButton( - # self, - # id=wx.ID_CANCEL, - # Translators: A cancel button on a message dialog. - # label=_("Cancel"), - # ) - # cancel.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.CANCEL)) - # cancel.SetDefault() - # self.SetDefaultItem(cancel) - # self.addOkButton() - # self.addCancelButton() + """Adds additional buttons to the dialog, before any other buttons are added. + Subclasses may implement this method. + """ def _addContents(self, contentsSizer: guiHelper.BoxSizerHelper): """Adds additional contents to the dialog, before the buttons. @@ -207,7 +187,7 @@ def __init__( buttonHelper = guiHelper.ButtonHelper(wx.HORIZONTAL) self.__buttonHelper = buttonHelper - # self._addButtons(buttonHelper) + self._addButtons(buttonHelper) mainSizer.Add( contentsSizer.sizer, @@ -246,6 +226,15 @@ def addButton( default: bool = False, **kwargs, ): + """Add a button to the dialog. + + Any additional arguments are passed to `ButtonHelper.addButton`. + + :param callback: Function to call when the button is pressed, defaults to None. + :param default: Whether the button should be the default (first focused) button in the dialog, defaults to False. + If multiple buttons with `default=True` are added, the last one added will be the default button. + :return: The dialog instance. + """ button = self.__buttonHelper.addButton(*args, **kwargs) # button.Bind(wx.EVT_BUTTON, self.__closeFirst(callback)) button.Bind(wx.EVT_BUTTON, partial(self.__call_callback, should_close=True, callback=callback)) @@ -262,6 +251,14 @@ def _( default: bool | None = None, **kwargs, ): + """Add a c{MessageDialogButton} to the dialog. + + :param button: The button to add. + :param callback: Override for the callback specified in `button`, defaults to None. + :param default: Override for the default specified in `button`, defaults to None. + If multiple buttons with `default=True` are added, the last one added will be the default button. + :return: The dialog instance. + """ keywords = dict( id=button.id, label=button.label, @@ -276,9 +273,16 @@ def _( return self.addButton(self, *args, **keywords) addOkButton = partialmethod(addButton, DefaultMessageDialogButtons.OK) + addOkButton.__doc__ = "Add an OK button to the dialog." addCancelButton = partialmethod(addButton, DefaultMessageDialogButtons.CANCEL) + addCancelButton.__doc__ = "Add a Cancel button to the dialog." def addButtons(self, *buttons: Iterable[MessageDialogButton]): + """Add multiple buttons to the dialog. + + :return: The dialog instance. + """ + for button in buttons: self.addButton(button) return self From 5e9d4c5eb704595d8961b91a5ab3251e7f884329 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 7 Oct 2024 12:21:52 +1100 Subject: [PATCH 024/209] Added deque to track open MessageDialog instances --- source/gui/messageDialog.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 21ffc9850bb..ba35f5db623 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -4,7 +4,7 @@ # For more details see: https://www.gnu.org/licenses/gpl-2.0.html from enum import Enum, IntEnum, auto -from typing import Any, Callable, Iterable, NamedTuple +from typing import Any, Callable, Deque, Iterable, NamedTuple import winsound import wx @@ -14,7 +14,7 @@ from .guiHelper import SIPABCMeta from gui import guiHelper from functools import partial, partialmethod, singledispatchmethod - +from collections import deque class MessageDialogReturnCode(IntEnum): OK = wx.ID_OK @@ -106,6 +106,7 @@ class DefaultMessageDialogButtons(MessageDialogButton, Enum): class MessageDialog(DpiScalingHelperMixinWithoutInit, ContextHelpMixin, wx.Dialog, metaclass=SIPABCMeta): + _instances: Deque["MessageDialog"] = deque() def Show(self) -> None: """Show a non-blocking dialog. Attach buttons with button handlers""" @@ -114,6 +115,7 @@ def Show(self) -> None: self.__mainSizer.Fit(self) self.__isLayoutFullyRealized = True super().Show() + self._instances.append(self) def defaultAction(self) -> None: return None @@ -217,6 +219,7 @@ def _onShowEvt(self, evt: wx.ShowEvent): def _onCloseEvent(self, evt: wx.CloseEvent): self.Destroy() + self._instances.remove(self) @singledispatchmethod def addButton( From f8cde120b0385e1dc5e6fc1144bea64cbbd74c9c Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 7 Oct 2024 12:41:34 +1100 Subject: [PATCH 025/209] Refactored to use a registry oif callbacks --- source/gui/messageDialog.py | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index ba35f5db623..30492110f07 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -4,7 +4,7 @@ # For more details see: https://www.gnu.org/licenses/gpl-2.0.html from enum import Enum, IntEnum, auto -from typing import Any, Callable, Deque, Iterable, NamedTuple +from typing import Any, Callable, Deque, Iterable, NamedTuple, TypeAlias import winsound import wx @@ -13,9 +13,14 @@ from .dpiScalingHelper import DpiScalingHelperMixinWithoutInit from .guiHelper import SIPABCMeta from gui import guiHelper -from functools import partial, partialmethod, singledispatchmethod +from functools import partialmethod, singledispatchmethod from collections import deque + +# TODO: Change to type statement when Python 3.12 or later is in use. +MessageDialogCallback: TypeAlias = Callable[[wx.CommandEvent], Any] + + class MessageDialogReturnCode(IntEnum): OK = wx.ID_OK CANCEL = wx.ID_CANCEL @@ -74,7 +79,7 @@ class MessageDialogButton(NamedTuple): label: str """The label to display on the button.""" - callback: Callable[[wx.CommandEvent], Any] | None = None + callback: MessageDialogCallback | None = None """The callback to call when the button is clicked.""" default: bool = False @@ -105,13 +110,19 @@ class DefaultMessageDialogButtons(MessageDialogButton, Enum): HELP = MessageDialogButton(id=MessageDialogReturnCode.HELP, label=_("Help")) +class _MessageDialogCommand(NamedTuple): + callback: MessageDialogCallback | None = None + closes_dialog: bool = True + + class MessageDialog(DpiScalingHelperMixinWithoutInit, ContextHelpMixin, wx.Dialog, metaclass=SIPABCMeta): _instances: Deque["MessageDialog"] = deque() + _commands: dict[int, _MessageDialogCommand] = {} + def Show(self) -> None: """Show a non-blocking dialog. Attach buttons with button handlers""" if not self.__isLayoutFullyRealized: - self.__contentsSizer.addDialogDismissButtons(self.__buttonHelper) self.__mainSizer.Fit(self) self.__isLayoutFullyRealized = True super().Show() @@ -174,6 +185,7 @@ def __init__( self.Bind(wx.EVT_SHOW, self._onShowEvt, source=self) self.Bind(wx.EVT_ACTIVATE, self._onDialogActivated, source=self) self.Bind(wx.EVT_CLOSE, self._onCloseEvent) + self.Bind(wx.EVT_BUTTON, self._onButton) mainSizer = wx.BoxSizer(wx.VERTICAL) contentsSizer = guiHelper.BoxSizerHelper(parent=self, orientation=wx.VERTICAL) @@ -188,6 +200,7 @@ def __init__( self._addContents(contentsSizer) buttonHelper = guiHelper.ButtonHelper(wx.HORIZONTAL) + contentsSizer.addDialogDismissButtons(buttonHelper) self.__buttonHelper = buttonHelper self._addButtons(buttonHelper) @@ -221,12 +234,22 @@ def _onCloseEvent(self, evt: wx.CloseEvent): self.Destroy() self._instances.remove(self) + def _onButton(self, evt: wx.CommandEvent): + command = self._commands.get(evt.GetId()) + if command is None: + return + if command.callback is not None: + command.callback(evt) + if command.closes_dialog: + self.Close() + @singledispatchmethod def addButton( self, *args, callback: Callable[[wx.CommandEvent], Any] | None = None, default: bool = False, + closes_dialog: bool = True, **kwargs, ): """Add a button to the dialog. @@ -240,7 +263,9 @@ def addButton( """ button = self.__buttonHelper.addButton(*args, **kwargs) # button.Bind(wx.EVT_BUTTON, self.__closeFirst(callback)) - button.Bind(wx.EVT_BUTTON, partial(self.__call_callback, should_close=True, callback=callback)) + # button.Bind(wx.EVT_BUTTON, partial(self.__call_callback, should_close=True, callback=callback)) + # button.Bind(wx.EVT_BUTTON, self._onButton) + self._commands[button.GetId()] = _MessageDialogCommand(callback=callback, closes_dialog=closes_dialog) if default: button.SetDefault() return self From ac2be855304427ec9b3015e53995ee0422880ce3 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 7 Oct 2024 12:46:08 +1100 Subject: [PATCH 026/209] Added adders for oother default button types --- source/gui/messageDialog.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 30492110f07..6dbfd7062e7 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -304,6 +304,18 @@ def _( addOkButton.__doc__ = "Add an OK button to the dialog." addCancelButton = partialmethod(addButton, DefaultMessageDialogButtons.CANCEL) addCancelButton.__doc__ = "Add a Cancel button to the dialog." + addYesButton = partialmethod(addButton, DefaultMessageDialogButtons.YES) + addYesButton.__doc__ = "Add a Yes button to the dialog." + addNoButton = partialmethod(addButton, DefaultMessageDialogButtons.NO) + addNoButton.__doc__ = "Add a No button to the dialog." + addSaveButton = partialmethod(addButton, DefaultMessageDialogButtons.SAVE) + addSaveButton.__doc__ = "Add a Save button to the dialog." + addApplyButton = partialmethod(addButton, DefaultMessageDialogButtons.APPLY) + addApplyButton.__doc__ = "Add an Apply button to the dialog." + addCloseButton = partialmethod(addButton, DefaultMessageDialogButtons.CLOSE) + addCloseButton.__doc__ = "Add a Close button to the dialog." + addHelpButton = partialmethod(addButton, DefaultMessageDialogButtons.HELP) + addHelpButton.__doc__ = "Add a Help button to the dialog." def addButtons(self, *buttons: Iterable[MessageDialogButton]): """Add multiple buttons to the dialog. From bcc49f91c4f68ecae6fba41a1c225c673ac9a9e1 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 7 Oct 2024 12:53:46 +1100 Subject: [PATCH 027/209] Added custom button IDs --- source/gui/messageDialog.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 6dbfd7062e7..9fd00229ff3 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -30,6 +30,11 @@ class MessageDialogReturnCode(IntEnum): 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 MessageDialogType(Enum): From 1a95e060d2bb1e6c9a35d1f08d001982f203e9de Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 7 Oct 2024 13:09:32 +1100 Subject: [PATCH 028/209] Various code clean-ups --- source/gui/messageDialog.py | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 9fd00229ff3..43fc9fa619c 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -263,13 +263,12 @@ def addButton( :param callback: Function to call when the button is pressed, defaults to None. :param default: Whether the button should be the default (first focused) button in the dialog, defaults to False. + :param closes_dialog: Whether the button should close the dialog when pressed, defaults to True. If multiple buttons with `default=True` are added, the last one added will be the default button. :return: The dialog instance. """ button = self.__buttonHelper.addButton(*args, **kwargs) - # button.Bind(wx.EVT_BUTTON, self.__closeFirst(callback)) - # button.Bind(wx.EVT_BUTTON, partial(self.__call_callback, should_close=True, callback=callback)) - # button.Bind(wx.EVT_BUTTON, self._onButton) + # Get the ID from the button instance in case it was created with id=wx.ID_ANY. self._commands[button.GetId()] = _MessageDialogCommand(callback=callback, closes_dialog=closes_dialog) if default: button.SetDefault() @@ -282,6 +281,7 @@ def _( *args, callback: Callable[[wx.CommandEvent], Any] | None = None, default: bool | None = None, + closes_dialog: bool | None = None, **kwargs, ): """Add a c{MessageDialogButton} to the dialog. @@ -290,18 +290,16 @@ def _( :param callback: Override for the callback specified in `button`, defaults to None. :param default: Override for the default specified in `button`, defaults to None. If multiple buttons with `default=True` are added, the last one added will be the default button. + :param closes_dialog: Override for `button`'s `closes_dialog` property, defaults to None. :return: The dialog instance. """ - keywords = dict( - id=button.id, - label=button.label, - callback=button.callback, - default=button.default, - ) + keywords = button._asdict() if default is not None: keywords["default"] = default if callback is not None: keywords["callback"] = callback + if closes_dialog is not None: + keywords["closes_dialog"] = closes_dialog keywords.update(kwargs) return self.addButton(self, *args, **keywords) @@ -331,9 +329,3 @@ def addButtons(self, *buttons: Iterable[MessageDialogButton]): for button in buttons: self.addButton(button) return self - - def __call_callback(self, *args, should_close, callback, **kwargs): - if should_close: - self.Close() - if callback is not None: - return callback(*args, **kwargs) From 5ec943ebb726c584a056729d83ec7c17768f1b3d Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 7 Oct 2024 17:23:01 +1100 Subject: [PATCH 029/209] Partially working modal dialogs --- source/gui/__init__.py | 2 +- source/gui/messageDialog.py | 55 ++++++++++++++++++++++++++++++++----- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/source/gui/__init__.py b/source/gui/__init__.py index 7a3579a5d66..430c3cb459a 100644 --- a/source/gui/__init__.py +++ b/source/gui/__init__.py @@ -596,7 +596,7 @@ def onModelessOkCancelDialog(self, evt): .addCancelButton(callback=lambda _: messageBox("You pressed Cancel!")) ) - dlg.Show() + dlg.ShowModal() self.postPopup() diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 43fc9fa619c..0087932b2d7 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -15,6 +15,7 @@ from gui import guiHelper from functools import partialmethod, singledispatchmethod from collections import deque +from logHandler import log # TODO: Change to type statement when Python 3.12 or later is in use. @@ -122,7 +123,6 @@ class _MessageDialogCommand(NamedTuple): class MessageDialog(DpiScalingHelperMixinWithoutInit, ContextHelpMixin, wx.Dialog, metaclass=SIPABCMeta): _instances: Deque["MessageDialog"] = deque() - _commands: dict[int, _MessageDialogCommand] = {} def Show(self) -> None: """Show a non-blocking dialog. @@ -133,7 +133,29 @@ def Show(self) -> None: super().Show() self._instances.append(self) - def defaultAction(self) -> None: + def ShowModal(self): + """Show a blocking dialog. + Attach buttons with button handlers""" + if not self.__isLayoutFullyRealized: + self.__mainSizer.Fit(self) + self.__isLayoutFullyRealized = True + self.__ShowModal = self.ShowModal + self.ShowModal = super().ShowModal + from .message import displayDialogAsModal + + self._instances.append(self) + displayDialogAsModal(self) + self.ShowModal = self.__ShowModal + + @property + def _defaultAction(self) -> MessageDialogCallback: + if (defaultReturnCode := self._defaultReturnCode) is not None: + try: + return self._commands[defaultReturnCode] + except KeyError: + raise RuntimeError( + f"Default return code {defaultReturnCode} is not associated with a callback", + ) return None @staticmethod @@ -145,13 +167,21 @@ def CloseInstances() -> None: def BlockingInstancesExist() -> bool: """Check if dialogs are open without a default return code (eg Show without `self._defaultReturnCode`, or ShowModal without `wx.CANCEL`)""" - pass + return any(dialog.isBlocking() for dialog in MessageDialog._instances) @staticmethod def FocusBlockingInstances() -> None: """Raise and focus open dialogs without a default return code (eg Show without `self._defaultReturnCode`, or ShowModal without `wx.CANCEL`)""" - pass + for dialog in MessageDialog._instances: + if dialog.isBlocking(): + dialog.Raise() + dialog.SetFocus() + break + + def isBlocking(self) -> bool: + """Check if the dialog is blocking""" + return self.IsModal() and self._defaultReturnCode is None def _addButtons(self, buttonHelper): """Adds additional buttons to the dialog, before any other buttons are added. @@ -184,6 +214,8 @@ def __init__( ): super().__init__(parent, title=title) self.__isLayoutFullyRealized = False + self._commands: dict[int, _MessageDialogCommand] = {} + self._defaultReturnCode: MessageDialogReturnCode | None = None self.__setIcon(dialogType) self.__setSound(dialogType) @@ -236,17 +268,26 @@ def _onShowEvt(self, evt: wx.ShowEvent): evt.Skip() def _onCloseEvent(self, evt: wx.CloseEvent): - self.Destroy() + log.debug(f"{evt.GetId()=}, {evt.GetEventObject().Label=}") + # self.GetEscapeId() + # self._onButton(wx.CommandEvent(wx.wxEVT_BUTTON, self.GetEscapeId())) + self.DestroyLater() self._instances.remove(self) def _onButton(self, evt: wx.CommandEvent): command = self._commands.get(evt.GetId()) if command is None: return + if command.closes_dialog: + closeEvent = wx.PyEvent(0, wx.EVT_CLOSE.typeId) + closeEvent.SetEventObject(evt.GetEventObject()) + self.GetEventHandler().QueueEvent(closeEvent) + # wx.PostEvent(self.GetEventHandler(), closeEvent) + # self.ProcessPendingEvents() + # self.ProcessEvent(wx.CloseEvent(id=evt.GetId())) + # self.Close() if command.callback is not None: command.callback(evt) - if command.closes_dialog: - self.Close() @singledispatchmethod def addButton( From dbca144048899c2da596b7b4570420d5096e9e97 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 8 Oct 2024 17:00:45 +1100 Subject: [PATCH 030/209] Better handling of modal dialogs (still not woroking properly as blockingInstancesExist tries to access destroyed instances) --- source/gui/messageDialog.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 0087932b2d7..c1377279f3f 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -271,23 +271,26 @@ def _onCloseEvent(self, evt: wx.CloseEvent): log.debug(f"{evt.GetId()=}, {evt.GetEventObject().Label=}") # self.GetEscapeId() # self._onButton(wx.CommandEvent(wx.wxEVT_BUTTON, self.GetEscapeId())) + # self.EndModal(0) + # wx.CallAfter(self.Destroy) self.DestroyLater() - self._instances.remove(self) + # self._instances.remove(self) def _onButton(self, evt: wx.CommandEvent): - command = self._commands.get(evt.GetId()) + id = evt.GetId() + command = self._commands.get(id) if command is None: return - if command.closes_dialog: + callback, close = command + if callback is not None: + if close: + self.Hide() + callback(evt) + if close: closeEvent = wx.PyEvent(0, wx.EVT_CLOSE.typeId) closeEvent.SetEventObject(evt.GetEventObject()) + self.SetReturnCode(id) self.GetEventHandler().QueueEvent(closeEvent) - # wx.PostEvent(self.GetEventHandler(), closeEvent) - # self.ProcessPendingEvents() - # self.ProcessEvent(wx.CloseEvent(id=evt.GetId())) - # self.Close() - if command.callback is not None: - command.callback(evt) @singledispatchmethod def addButton( From c0d0f35ede7855322a239b47fc35d348a86a638e Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 9 Oct 2024 10:40:47 +1100 Subject: [PATCH 031/209] Fixed problem with blocking instances exist --- source/gui/messageDialog.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index c1377279f3f..76c328ec402 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -128,22 +128,31 @@ def Show(self) -> None: """Show a non-blocking dialog. Attach buttons with button handlers""" if not self.__isLayoutFullyRealized: + log.debug("Laying out") self.__mainSizer.Fit(self) self.__isLayoutFullyRealized = True + log.debug("Layout completed") + log.debug("Showing") super().Show() + log.debug("Adding to instances") self._instances.append(self) def ShowModal(self): """Show a blocking dialog. Attach buttons with button handlers""" if not self.__isLayoutFullyRealized: + log.debug("Laying out") self.__mainSizer.Fit(self) self.__isLayoutFullyRealized = True + log.debug("Layout completed") + self.__ShowModal = self.ShowModal self.ShowModal = super().ShowModal from .message import displayDialogAsModal + log.debug("Adding to instances") self._instances.append(self) + log.debug("Showing modal") displayDialogAsModal(self) self.ShowModal = self.__ShowModal @@ -268,13 +277,15 @@ def _onShowEvt(self, evt: wx.ShowEvent): evt.Skip() def _onCloseEvent(self, evt: wx.CloseEvent): - log.debug(f"{evt.GetId()=}, {evt.GetEventObject().Label=}") + # log.debug(f"{evt.GetId()=}, {evt.GetEventObject().Label=}") # self.GetEscapeId() # self._onButton(wx.CommandEvent(wx.wxEVT_BUTTON, self.GetEscapeId())) # self.EndModal(0) # wx.CallAfter(self.Destroy) + log.debug("Queueing destroy") self.DestroyLater() - # self._instances.remove(self) + log.debug("Removing from instances") + self._instances.remove(self) def _onButton(self, evt: wx.CommandEvent): id = evt.GetId() From 2140a454ed68b617350566c51bf9e87b3e028871 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 9 Oct 2024 16:10:02 +1100 Subject: [PATCH 032/209] WIP better performance tracing and layout encapsulation --- source/gui/messageDialog.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 76c328ec402..8178e539ea7 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -4,11 +4,14 @@ # For more details see: https://www.gnu.org/licenses/gpl-2.0.html from enum import Enum, IntEnum, auto +import time from typing import Any, Callable, Deque, Iterable, NamedTuple, TypeAlias import winsound import wx +import gui + from .contextHelp import ContextHelpMixin from .dpiScalingHelper import DpiScalingHelperMixinWithoutInit from .guiHelper import SIPABCMeta @@ -127,25 +130,27 @@ class MessageDialog(DpiScalingHelperMixinWithoutInit, ContextHelpMixin, wx.Dialo def Show(self) -> None: """Show a non-blocking dialog. Attach buttons with button handlers""" - if not self.__isLayoutFullyRealized: - log.debug("Laying out") - self.__mainSizer.Fit(self) - self.__isLayoutFullyRealized = True - log.debug("Layout completed") + self._realize_layout() log.debug("Showing") super().Show() log.debug("Adding to instances") self._instances.append(self) + def _realize_layout(self): + if self.__isLayoutFullyRealized: + return + if gui._isDebug(): + startTime = time.time() + log.debug("Laying out message dialog") + self.__mainSizer.Fit(self) + self.__isLayoutFullyRealized = True + if gui._isDebug(): + log.debug(f"Layout completed in {time.time() - startTime:.3f} seconds") + def ShowModal(self): """Show a blocking dialog. Attach buttons with button handlers""" - if not self.__isLayoutFullyRealized: - log.debug("Laying out") - self.__mainSizer.Fit(self) - self.__isLayoutFullyRealized = True - log.debug("Layout completed") - + self._realize_layout() self.__ShowModal = self.ShowModal self.ShowModal = super().ShowModal from .message import displayDialogAsModal @@ -327,6 +332,7 @@ def addButton( self._commands[button.GetId()] = _MessageDialogCommand(callback=callback, closes_dialog=closes_dialog) if default: button.SetDefault() + self.__isLayoutFullyRealized = False return self @addButton.register From 0beba1748c83412ac4b501e59e93ca5e29bbe721 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 10 Oct 2024 11:53:07 +1100 Subject: [PATCH 033/209] Reorganised MessageDialog code --- source/gui/messageDialog.py | 271 +++++++++++++++++++----------------- 1 file changed, 143 insertions(+), 128 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 8178e539ea7..8e7f99d0aa1 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -127,98 +127,7 @@ class _MessageDialogCommand(NamedTuple): class MessageDialog(DpiScalingHelperMixinWithoutInit, ContextHelpMixin, wx.Dialog, metaclass=SIPABCMeta): _instances: Deque["MessageDialog"] = deque() - def Show(self) -> None: - """Show a non-blocking dialog. - Attach buttons with button handlers""" - self._realize_layout() - log.debug("Showing") - super().Show() - log.debug("Adding to instances") - self._instances.append(self) - - def _realize_layout(self): - if self.__isLayoutFullyRealized: - return - if gui._isDebug(): - startTime = time.time() - log.debug("Laying out message dialog") - self.__mainSizer.Fit(self) - self.__isLayoutFullyRealized = True - if gui._isDebug(): - log.debug(f"Layout completed in {time.time() - startTime:.3f} seconds") - - def ShowModal(self): - """Show a blocking dialog. - Attach buttons with button handlers""" - self._realize_layout() - self.__ShowModal = self.ShowModal - self.ShowModal = super().ShowModal - from .message import displayDialogAsModal - - log.debug("Adding to instances") - self._instances.append(self) - log.debug("Showing modal") - displayDialogAsModal(self) - self.ShowModal = self.__ShowModal - - @property - def _defaultAction(self) -> MessageDialogCallback: - if (defaultReturnCode := self._defaultReturnCode) is not None: - try: - return self._commands[defaultReturnCode] - except KeyError: - raise RuntimeError( - f"Default return code {defaultReturnCode} is not associated with a callback", - ) - return None - - @staticmethod - def CloseInstances() -> None: - """Close all dialogs with a default action""" - pass - - @staticmethod - def BlockingInstancesExist() -> bool: - """Check if dialogs are open without a default return code - (eg Show without `self._defaultReturnCode`, or ShowModal without `wx.CANCEL`)""" - return any(dialog.isBlocking() for dialog in MessageDialog._instances) - - @staticmethod - def FocusBlockingInstances() -> None: - """Raise and focus open dialogs without a default return code - (eg Show without `self._defaultReturnCode`, or ShowModal without `wx.CANCEL`)""" - for dialog in MessageDialog._instances: - if dialog.isBlocking(): - dialog.Raise() - dialog.SetFocus() - break - - def isBlocking(self) -> bool: - """Check if the dialog is blocking""" - return self.IsModal() and self._defaultReturnCode is None - - def _addButtons(self, buttonHelper): - """Adds additional buttons to the dialog, before any other buttons are added. - Subclasses may implement this method. - """ - - def _addContents(self, contentsSizer: guiHelper.BoxSizerHelper): - """Adds additional contents to the dialog, before the buttons. - Subclasses may implement this method. - """ - - def __setIcon(self, type: MessageDialogType): - if (iconID := type._wxIconId) is not None: - icon = wx.ArtProvider.GetIconBundle(iconID, client=wx.ART_MESSAGE_BOX) - self.SetIcons(icon) - - def __setSound(self, type: MessageDialogType): - self.__soundID = type._windowsSoundId - - def __playSound(self): - if self.__soundID is not None: - winsound.MessageBeep(self.__soundID) - + # region Constructors def __init__( self, parent: wx.Window | None, @@ -271,43 +180,9 @@ def __init__( else: self.CentreOnParent() - def _onDialogActivated(self, evt): - evt.Skip() - - def _onShowEvt(self, evt: wx.ShowEvent): - if evt.IsShown(): - self.__playSound() - if (defaultItem := self.GetDefaultItem()) is not None: - defaultItem.SetFocus() - evt.Skip() - - def _onCloseEvent(self, evt: wx.CloseEvent): - # log.debug(f"{evt.GetId()=}, {evt.GetEventObject().Label=}") - # self.GetEscapeId() - # self._onButton(wx.CommandEvent(wx.wxEVT_BUTTON, self.GetEscapeId())) - # self.EndModal(0) - # wx.CallAfter(self.Destroy) - log.debug("Queueing destroy") - self.DestroyLater() - log.debug("Removing from instances") - self._instances.remove(self) - - def _onButton(self, evt: wx.CommandEvent): - id = evt.GetId() - command = self._commands.get(id) - if command is None: - return - callback, close = command - if callback is not None: - if close: - self.Hide() - callback(evt) - if close: - closeEvent = wx.PyEvent(0, wx.EVT_CLOSE.typeId) - closeEvent.SetEventObject(evt.GetEventObject()) - self.SetReturnCode(id) - self.GetEventHandler().QueueEvent(closeEvent) + # endregion + # region Public object API @singledispatchmethod def addButton( self, @@ -390,3 +265,143 @@ def addButtons(self, *buttons: Iterable[MessageDialogButton]): for button in buttons: self.addButton(button) return self + + def Show(self) -> None: + """Show a non-blocking dialog. + Attach buttons with button handlers""" + self._realize_layout() + log.debug("Showing") + super().Show() + log.debug("Adding to instances") + self._instances.append(self) + + def ShowModal(self): + """Show a blocking dialog. + Attach buttons with button handlers""" + self._realize_layout() + self.__ShowModal = self.ShowModal + self.ShowModal = super().ShowModal + from .message import displayDialogAsModal + + log.debug("Adding to instances") + self._instances.append(self) + log.debug("Showing modal") + displayDialogAsModal(self) + self.ShowModal = self.__ShowModal + + def isBlocking(self) -> bool: + """Check if the dialog is blocking""" + return self.IsModal() and self._defaultReturnCode is None + + # endregion + + # region Public class methods + @staticmethod + def CloseInstances() -> None: + """Close all dialogs with a default action""" + pass + + @staticmethod + def BlockingInstancesExist() -> bool: + """Check if dialogs are open without a default return code + (eg Show without `self._defaultReturnCode`, or ShowModal without `wx.CANCEL`)""" + return any(dialog.isBlocking() for dialog in MessageDialog._instances) + + @staticmethod + def FocusBlockingInstances() -> None: + """Raise and focus open dialogs without a default return code + (eg Show without `self._defaultReturnCode`, or ShowModal without `wx.CANCEL`)""" + for dialog in MessageDialog._instances: + if dialog.isBlocking(): + dialog.Raise() + dialog.SetFocus() + break + + # endregion + + # region Methods for subclasses + def _addButtons(self, buttonHelper): + """Adds additional buttons to the dialog, before any other buttons are added. + Subclasses may implement this method. + """ + + def _addContents(self, contentsSizer: guiHelper.BoxSizerHelper): + """Adds additional contents to the dialog, before the buttons. + Subclasses may implement this method. + """ + + # endregion + + # region Internal API + def _realize_layout(self): + if self.__isLayoutFullyRealized: + return + if gui._isDebug(): + startTime = time.time() + log.debug("Laying out message dialog") + self.__mainSizer.Fit(self) + self.__isLayoutFullyRealized = True + if gui._isDebug(): + log.debug(f"Layout completed in {time.time() - startTime:.3f} seconds") + + @property + def _defaultAction(self) -> MessageDialogCallback: + if (defaultReturnCode := self._defaultReturnCode) is not None: + try: + return self._commands[defaultReturnCode] + except KeyError: + raise RuntimeError( + f"Default return code {defaultReturnCode} is not associated with a callback", + ) + return None + + def __setIcon(self, type: MessageDialogType): + if (iconID := type._wxIconId) is not None: + icon = wx.ArtProvider.GetIconBundle(iconID, client=wx.ART_MESSAGE_BOX) + self.SetIcons(icon) + + def __setSound(self, type: MessageDialogType): + self.__soundID = type._windowsSoundId + + def __playSound(self): + if self.__soundID is not None: + winsound.MessageBeep(self.__soundID) + + def _onDialogActivated(self, evt): + evt.Skip() + + def _onShowEvt(self, evt: wx.ShowEvent): + if evt.IsShown(): + self.__playSound() + if (defaultItem := self.GetDefaultItem()) is not None: + defaultItem.SetFocus() + evt.Skip() + + def _onCloseEvent(self, evt: wx.CloseEvent): + # log.debug(f"{evt.GetId()=}, {evt.GetEventObject().Label=}") + # self.GetEscapeId() + # self._onButton(wx.CommandEvent(wx.wxEVT_BUTTON, self.GetEscapeId())) + # self.EndModal(0) + # wx.CallAfter(self.Destroy) + log.debug("Queueing destroy") + self.DestroyLater() + log.debug("Removing from instances") + self._instances.remove(self) + + def _onButton(self, evt: wx.CommandEvent): + id = evt.GetId() + command = self._commands.get(id) + if command is None: + return + callback, close = command + if callback is not None: + if close: + self.Hide() + callback(evt) + if close: + closeEvent = wx.PyEvent(0, wx.EVT_CLOSE.typeId) + closeEvent.SetEventObject(evt.GetEventObject()) + self.SetReturnCode(id) + self.GetEventHandler().QueueEvent(closeEvent) + + # endregion From 648ad0a42468e43fc75338212b1df629c82d7bb2 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 10 Oct 2024 12:19:21 +1100 Subject: [PATCH 034/209] Fixed return codes not working --- source/gui/messageDialog.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 8e7f99d0aa1..12b6e324dc1 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -286,8 +286,9 @@ def ShowModal(self): log.debug("Adding to instances") self._instances.append(self) log.debug("Showing modal") - displayDialogAsModal(self) + ret = displayDialogAsModal(self) self.ShowModal = self.__ShowModal + return ret def isBlocking(self) -> bool: """Check if the dialog is blocking""" @@ -383,6 +384,8 @@ def _onCloseEvent(self, evt: wx.CloseEvent): # self._onButton(wx.CommandEvent(wx.wxEVT_BUTTON, self.GetEscapeId())) # self.EndModal(0) # wx.CallAfter(self.Destroy) + if self.IsModal(): + self.EndModal(self.GetReturnCode()) log.debug("Queueing destroy") self.DestroyLater() log.debug("Removing from instances") From 2f82b4b312f79dd691fe573a7b9e8915d6a528e7 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 10 Oct 2024 12:21:02 +1100 Subject: [PATCH 035/209] Explicitly hide dialog when closing --- source/gui/messageDialog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 12b6e324dc1..bf7b4d0fddb 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -384,6 +384,7 @@ def _onCloseEvent(self, evt: wx.CloseEvent): # self._onButton(wx.CommandEvent(wx.wxEVT_BUTTON, self.GetEscapeId())) # self.EndModal(0) # wx.CallAfter(self.Destroy) + self.Hide() if self.IsModal(): self.EndModal(self.GetReturnCode()) log.debug("Queueing destroy") From 305351480fed847130d2b71a694e00f18d29d3a6 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 10 Oct 2024 13:46:54 +1100 Subject: [PATCH 036/209] Added escape code enum to support ID_NONE and ID_ANY --- source/gui/messageDialog.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index bf7b4d0fddb..a1d753d310e 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -41,6 +41,11 @@ class MessageDialogReturnCode(IntEnum): CUSTOM_5 = wx.ID_HIGHEST + 5 +class MessageDialogEscapeCode(IntEnum): + NONE = wx.ID_NONE + DEFAULT = wx.ID_ANY + + class MessageDialogType(Enum): """Types of message dialogs. These are used to determine the icon and sound to play when the dialog is shown. From b0b244928e82c62bc52ec3fcccec75e323225ddd Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 10 Oct 2024 14:00:43 +1100 Subject: [PATCH 037/209] Changed MessageDialogButton.default to MessageDialogButton.default_focus, and added a MessageDialogButton.default_action field. --- source/gui/messageDialog.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index a1d753d310e..1a4490fb8f2 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -43,7 +43,9 @@ class MessageDialogReturnCode(IntEnum): class MessageDialogEscapeCode(IntEnum): NONE = wx.ID_NONE + """The escape key should have no effect, and programatically attempting to close the dialog should fail.""" DEFAULT = 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 MessageDialogType(Enum): @@ -96,9 +98,12 @@ class MessageDialogButton(NamedTuple): callback: MessageDialogCallback | None = None """The callback to call when the button is clicked.""" - default: bool = False + default_focus: bool = False """Whether this button should be the default button.""" + default_action: bool = False + """Whether this button is the default action. That is, whether pressing escape, the system close button, or programatically closing the dialog, should simulate pressing this button.""" + closes_dialog: bool = True """Whether this button should close the dialog when clicked.""" @@ -107,9 +112,9 @@ class DefaultMessageDialogButtons(MessageDialogButton, Enum): """Default buttons for message dialogs.""" # Translators: An ok button on a message dialog. - OK = MessageDialogButton(id=MessageDialogReturnCode.OK, label=_("OK"), default=True) + OK = MessageDialogButton(id=MessageDialogReturnCode.OK, label=_("OK"), default_focus=True) # Translators: A yes button on a message dialog. - YES = MessageDialogButton(id=MessageDialogReturnCode.YES, label=_("&Yes"), default=True) + YES = MessageDialogButton(id=MessageDialogReturnCode.YES, label=_("&Yes"), default_focus=True) # Translators: A no button on a message dialog. NO = MessageDialogButton(id=MessageDialogReturnCode.NO, label=_("&No")) # Translators: A cancel button on a message dialog. @@ -193,7 +198,7 @@ def addButton( self, *args, callback: Callable[[wx.CommandEvent], Any] | None = None, - default: bool = False, + default_button: bool = False, closes_dialog: bool = True, **kwargs, ): @@ -202,7 +207,7 @@ def addButton( Any additional arguments are passed to `ButtonHelper.addButton`. :param callback: Function to call when the button is pressed, defaults to None. - :param default: Whether the button should be the default (first focused) button in the dialog, defaults to False. + :param default_button: Whether the button should be the default (first focused) button in the dialog, defaults to False. :param closes_dialog: Whether the button should close the dialog when pressed, defaults to True. If multiple buttons with `default=True` are added, the last one added will be the default button. :return: The dialog instance. @@ -210,7 +215,7 @@ def addButton( button = self.__buttonHelper.addButton(*args, **kwargs) # Get the ID from the button instance in case it was created with id=wx.ID_ANY. self._commands[button.GetId()] = _MessageDialogCommand(callback=callback, closes_dialog=closes_dialog) - if default: + if default_button: button.SetDefault() self.__isLayoutFullyRealized = False return self @@ -221,7 +226,7 @@ def _( button: MessageDialogButton, *args, callback: Callable[[wx.CommandEvent], Any] | None = None, - default: bool | None = None, + default_button: bool | None = None, closes_dialog: bool | None = None, **kwargs, ): @@ -229,14 +234,14 @@ def _( :param button: The button to add. :param callback: Override for the callback specified in `button`, defaults to None. - :param default: Override for the default specified in `button`, defaults to None. + :param default_button: Override for the default specified in `button`, defaults to None. If multiple buttons with `default=True` are added, the last one added will be the default button. :param closes_dialog: Override for `button`'s `closes_dialog` property, defaults to None. :return: The dialog instance. """ keywords = button._asdict() - if default is not None: - keywords["default"] = default + if default_button is not None: + keywords["default_button"] = default_button if callback is not None: keywords["callback"] = callback if closes_dialog is not None: From 898c24e4bd865bb6c0a54debd89738ed3054a4f2 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 10 Oct 2024 15:35:29 +1100 Subject: [PATCH 038/209] Corrected variable names --- source/gui/messageDialog.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 1a4490fb8f2..fc57e0e6e9f 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -198,7 +198,8 @@ def addButton( self, *args, callback: Callable[[wx.CommandEvent], Any] | None = None, - default_button: bool = False, + default_focus: bool = False, + default_action: bool = False, closes_dialog: bool = True, **kwargs, ): @@ -207,16 +208,20 @@ def addButton( Any additional arguments are passed to `ButtonHelper.addButton`. :param callback: Function to call when the button is pressed, defaults to None. - :param default_button: Whether the button should be the default (first focused) button in the dialog, defaults to False. + :param default_focus: Whether the button should be the default (first focused) button in the dialog, defaults to False. :param closes_dialog: Whether the button should close the dialog when pressed, defaults to True. If multiple buttons with `default=True` are added, the last one added will be the default button. :return: The dialog instance. """ button = self.__buttonHelper.addButton(*args, **kwargs) # Get the ID from the button instance in case it was created with id=wx.ID_ANY. - self._commands[button.GetId()] = _MessageDialogCommand(callback=callback, closes_dialog=closes_dialog) - if default_button: + buttonId = button.GetId() + self.AddMainButtonId(buttonId) + self._commands[buttonId] = _MessageDialogCommand(callback=callback, closes_dialog=closes_dialog) + if default_focus: button.SetDefault() + if default_action: + self.SetEscapeId(buttonId) self.__isLayoutFullyRealized = False return self @@ -226,7 +231,7 @@ def _( button: MessageDialogButton, *args, callback: Callable[[wx.CommandEvent], Any] | None = None, - default_button: bool | None = None, + default_focus: bool | None = None, closes_dialog: bool | None = None, **kwargs, ): @@ -234,14 +239,14 @@ def _( :param button: The button to add. :param callback: Override for the callback specified in `button`, defaults to None. - :param default_button: Override for the default specified in `button`, defaults to None. + :param default_focus: Override for the default specified in `button`, defaults to None. If multiple buttons with `default=True` are added, the last one added will be the default button. :param closes_dialog: Override for `button`'s `closes_dialog` property, defaults to None. :return: The dialog instance. """ keywords = button._asdict() - if default_button is not None: - keywords["default_button"] = default_button + if default_focus is not None: + keywords["default_focus"] = default_focus if callback is not None: keywords["callback"] = callback if closes_dialog is not None: From 92bc0ffd7e2e8a472c62cff6e3a8628e30be9135 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 10 Oct 2024 16:05:02 +1100 Subject: [PATCH 039/209] Removed staticmethods to classmethods --- source/gui/messageDialog.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index fc57e0e6e9f..41fa5e39427 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -312,22 +312,24 @@ def isBlocking(self) -> bool: # endregion # region Public class methods - @staticmethod - def CloseInstances() -> None: + @classmethod + def CloseInstances(cls) -> None: """Close all dialogs with a default action""" - pass + for instance in cls._instances: + if not instance.isBlocking: + instance.Close() - @staticmethod - def BlockingInstancesExist() -> bool: + @classmethod + def BlockingInstancesExist(cls) -> bool: """Check if dialogs are open without a default return code (eg Show without `self._defaultReturnCode`, or ShowModal without `wx.CANCEL`)""" - return any(dialog.isBlocking() for dialog in MessageDialog._instances) + return any(dialog.isBlocking() for dialog in cls._instances) - @staticmethod - def FocusBlockingInstances() -> None: + @classmethod + def FocusBlockingInstances(cls) -> None: """Raise and focus open dialogs without a default return code (eg Show without `self._defaultReturnCode`, or ShowModal without `wx.CANCEL`)""" - for dialog in MessageDialog._instances: + for dialog in cls._instances: if dialog.isBlocking(): dialog.Raise() dialog.SetFocus() @@ -394,11 +396,6 @@ def _onShowEvt(self, evt: wx.ShowEvent): evt.Skip() def _onCloseEvent(self, evt: wx.CloseEvent): - # log.debug(f"{evt.GetId()=}, {evt.GetEventObject().Label=}") - # self.GetEscapeId() - # self._onButton(wx.CommandEvent(wx.wxEVT_BUTTON, self.GetEscapeId())) - # self.EndModal(0) - # wx.CallAfter(self.Destroy) self.Hide() if self.IsModal(): self.EndModal(self.GetReturnCode()) From 0a66a76005c05f43dfd7771a83c8179fa73d7adf Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 18 Oct 2024 13:17:52 +1100 Subject: [PATCH 040/209] Made isBlocking a property --- source/gui/messageDialog.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 41fa5e39427..ebccb4b76e2 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -305,8 +305,9 @@ def ShowModal(self): self.ShowModal = self.__ShowModal return ret + @property def isBlocking(self) -> bool: - """Check if the dialog is blocking""" + """Whether or not the dialog is blocking""" return self.IsModal() and self._defaultReturnCode is None # endregion @@ -323,14 +324,14 @@ def CloseInstances(cls) -> None: def BlockingInstancesExist(cls) -> bool: """Check if dialogs are open without a default return code (eg Show without `self._defaultReturnCode`, or ShowModal without `wx.CANCEL`)""" - return any(dialog.isBlocking() for dialog in cls._instances) + return any(dialog.isBlocking for dialog in cls._instances) @classmethod def FocusBlockingInstances(cls) -> None: """Raise and focus open dialogs without a default return code (eg Show without `self._defaultReturnCode`, or ShowModal without `wx.CANCEL`)""" for dialog in cls._instances: - if dialog.isBlocking(): + if dialog.isBlocking: dialog.Raise() dialog.SetFocus() break From 1ee10c390c69d0cec5fafb8d18b1656fc7db2775 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 18 Oct 2024 13:36:01 +1100 Subject: [PATCH 041/209] Improved type annotations --- source/gui/messageDialog.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index ebccb4b76e2..d6bd63335c2 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -5,7 +5,7 @@ from enum import Enum, IntEnum, auto import time -from typing import Any, Callable, Deque, Iterable, NamedTuple, TypeAlias +from typing import Any, Callable, Deque, Iterable, NamedTuple, TypeAlias, Self import winsound import wx @@ -202,7 +202,7 @@ def addButton( default_action: bool = False, closes_dialog: bool = True, **kwargs, - ): + ) -> Self: """Add a button to the dialog. Any additional arguments are passed to `ButtonHelper.addButton`. @@ -234,7 +234,7 @@ def _( default_focus: bool | None = None, closes_dialog: bool | None = None, **kwargs, - ): + ) -> Self: """Add a c{MessageDialogButton} to the dialog. :param button: The button to add. @@ -271,7 +271,7 @@ def _( addHelpButton = partialmethod(addButton, DefaultMessageDialogButtons.HELP) addHelpButton.__doc__ = "Add a Help button to the dialog." - def addButtons(self, *buttons: Iterable[MessageDialogButton]): + def addButtons(self, *buttons: Iterable[MessageDialogButton]) -> Self: """Add multiple buttons to the dialog. :return: The dialog instance. @@ -339,12 +339,12 @@ def FocusBlockingInstances(cls) -> None: # endregion # region Methods for subclasses - def _addButtons(self, buttonHelper): + 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): + def _addContents(self, contentsSizer: guiHelper.BoxSizerHelper) -> None: """Adds additional contents to the dialog, before the buttons. Subclasses may implement this method. """ @@ -352,7 +352,7 @@ def _addContents(self, contentsSizer: guiHelper.BoxSizerHelper): # endregion # region Internal API - def _realize_layout(self): + def _realize_layout(self) -> None: if self.__isLayoutFullyRealized: return if gui._isDebug(): @@ -364,7 +364,7 @@ def _realize_layout(self): log.debug(f"Layout completed in {time.time() - startTime:.3f} seconds") @property - def _defaultAction(self) -> MessageDialogCallback: + def _defaultAction(self) -> MessageDialogCallback | None: if (defaultReturnCode := self._defaultReturnCode) is not None: try: return self._commands[defaultReturnCode] @@ -374,19 +374,19 @@ def _defaultAction(self) -> MessageDialogCallback: ) return None - def __setIcon(self, type: MessageDialogType): + def __setIcon(self, type: MessageDialogType) -> None: if (iconID := type._wxIconId) is not None: icon = wx.ArtProvider.GetIconBundle(iconID, client=wx.ART_MESSAGE_BOX) self.SetIcons(icon) - def __setSound(self, type: MessageDialogType): + def __setSound(self, type: MessageDialogType) -> None: self.__soundID = type._windowsSoundId - def __playSound(self): + def __playSound(self) -> None: if self.__soundID is not None: winsound.MessageBeep(self.__soundID) - def _onDialogActivated(self, evt): + def _onDialogActivated(self, evt: wx.ActivateEvent): evt.Skip() def _onShowEvt(self, evt: wx.ShowEvent): From c302f728fbb78e066a048a1af0ff7a6b56a9a94e Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 18 Oct 2024 14:09:38 +1100 Subject: [PATCH 042/209] Documentation improvements --- source/gui/messageDialog.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index d6bd63335c2..6c9c521dfb3 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -26,6 +26,8 @@ class MessageDialogReturnCode(IntEnum): + """Enumeration of possible returns from c{MessageDialog}.""" + OK = wx.ID_OK CANCEL = wx.ID_CANCEL YES = wx.ID_YES @@ -42,6 +44,8 @@ class MessageDialogReturnCode(IntEnum): class MessageDialogEscapeCode(IntEnum): + """Enumeration of the behavior of the escape key and programmatic attempts to close a c{MessageDialog}.""" + NONE = wx.ID_NONE """The escape key should have no effect, and programatically attempting to close the dialog should fail.""" DEFAULT = wx.ID_ANY @@ -130,8 +134,12 @@ class DefaultMessageDialogButtons(MessageDialogButton, Enum): class _MessageDialogCommand(NamedTuple): + """Internal representation of a command for a message dialog.""" + callback: MessageDialogCallback | None = None + """The callback function to be executed. Defaults to None.""" closes_dialog: bool = True + """Indicates whether the dialog should be closed after the command is executed. Defaults to True.""" class MessageDialog(DpiScalingHelperMixinWithoutInit, ContextHelpMixin, wx.Dialog, metaclass=SIPABCMeta): From 0010195dfec64c4f5155789794409c90e7401415 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 18 Oct 2024 14:25:21 +1100 Subject: [PATCH 043/209] Made Show and ShowModal work in line with wx.Dialog expectations --- source/gui/messageDialog.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 6c9c521dfb3..5928f89936f 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -289,16 +289,18 @@ def addButtons(self, *buttons: Iterable[MessageDialogButton]) -> Self: self.addButton(button) return self - def Show(self) -> None: + def Show(self) -> bool: """Show a non-blocking dialog. Attach buttons with button handlers""" self._realize_layout() log.debug("Showing") - super().Show() - log.debug("Adding to instances") - self._instances.append(self) + ret = super().Show() + if ret: + log.debug("Adding to instances") + self._instances.append(self) + return ret - def ShowModal(self): + def ShowModal(self) -> MessageDialogReturnCode: """Show a blocking dialog. Attach buttons with button handlers""" self._realize_layout() @@ -311,7 +313,7 @@ def ShowModal(self): log.debug("Showing modal") ret = displayDialogAsModal(self) self.ShowModal = self.__ShowModal - return ret + return MessageDialogReturnCode(ret) @property def isBlocking(self) -> bool: From 23172ad0d5c30a1d45d556277d905450be1ff669 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 18 Oct 2024 15:07:29 +1100 Subject: [PATCH 044/209] Added help id argument --- source/gui/messageDialog.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 5928f89936f..8a058530c77 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -152,7 +152,10 @@ def __init__( message: str, title: str = wx.MessageBoxCaptionStr, dialogType: MessageDialogType = MessageDialogType.STANDARD, + *, + helpId: str = "", ): + self.helpId = helpId super().__init__(parent, title=title) self.__isLayoutFullyRealized = False self._commands: dict[int, _MessageDialogCommand] = {} From bbe7c33222407c076c952430ca1cbe1bf83c424a Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 18 Oct 2024 15:17:51 +1100 Subject: [PATCH 045/209] Added exception for trying to show a MessageDialog without buttons --- source/gui/messageDialog.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 8a058530c77..04bcef4a539 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -295,6 +295,8 @@ def addButtons(self, *buttons: Iterable[MessageDialogButton]) -> Self: def Show(self) -> bool: """Show a non-blocking dialog. Attach buttons with button handlers""" + if not self.GetMainButtonIds(): + raise RuntimeError("MessageDialogs cannot be shown without buttons.") self._realize_layout() log.debug("Showing") ret = super().Show() @@ -306,6 +308,8 @@ def Show(self) -> bool: def ShowModal(self) -> MessageDialogReturnCode: """Show a blocking dialog. Attach buttons with button handlers""" + if not self.GetMainButtonIds(): + raise RuntimeError("MessageDialogs cannot be shown without buttons.") self._realize_layout() self.__ShowModal = self.ShowModal self.ShowModal = super().ShowModal From 81ba8cbe8f6bce4e2fdc565bd4e11ba9edc789cc Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 18 Oct 2024 15:48:51 +1100 Subject: [PATCH 046/209] Renamed DefaultMessageDialogButtons to DefaultMessageDialogButton --- source/gui/messageDialog.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 04bcef4a539..eedaea52604 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -112,7 +112,7 @@ class MessageDialogButton(NamedTuple): """Whether this button should close the dialog when clicked.""" -class DefaultMessageDialogButtons(MessageDialogButton, Enum): +class DefaultMessageDialogButton(MessageDialogButton, Enum): """Default buttons for message dialogs.""" # Translators: An ok button on a message dialog. @@ -265,21 +265,21 @@ def _( keywords.update(kwargs) return self.addButton(self, *args, **keywords) - addOkButton = partialmethod(addButton, DefaultMessageDialogButtons.OK) + addOkButton = partialmethod(addButton, DefaultMessageDialogButton.OK) addOkButton.__doc__ = "Add an OK button to the dialog." - addCancelButton = partialmethod(addButton, DefaultMessageDialogButtons.CANCEL) + addCancelButton = partialmethod(addButton, DefaultMessageDialogButton.CANCEL) addCancelButton.__doc__ = "Add a Cancel button to the dialog." - addYesButton = partialmethod(addButton, DefaultMessageDialogButtons.YES) + addYesButton = partialmethod(addButton, DefaultMessageDialogButton.YES) addYesButton.__doc__ = "Add a Yes button to the dialog." - addNoButton = partialmethod(addButton, DefaultMessageDialogButtons.NO) + addNoButton = partialmethod(addButton, DefaultMessageDialogButton.NO) addNoButton.__doc__ = "Add a No button to the dialog." - addSaveButton = partialmethod(addButton, DefaultMessageDialogButtons.SAVE) + addSaveButton = partialmethod(addButton, DefaultMessageDialogButton.SAVE) addSaveButton.__doc__ = "Add a Save button to the dialog." - addApplyButton = partialmethod(addButton, DefaultMessageDialogButtons.APPLY) + addApplyButton = partialmethod(addButton, DefaultMessageDialogButton.APPLY) addApplyButton.__doc__ = "Add an Apply button to the dialog." - addCloseButton = partialmethod(addButton, DefaultMessageDialogButtons.CLOSE) + addCloseButton = partialmethod(addButton, DefaultMessageDialogButton.CLOSE) addCloseButton.__doc__ = "Add a Close button to the dialog." - addHelpButton = partialmethod(addButton, DefaultMessageDialogButtons.HELP) + addHelpButton = partialmethod(addButton, DefaultMessageDialogButton.HELP) addHelpButton.__doc__ = "Add a Help button to the dialog." def addButtons(self, *buttons: Iterable[MessageDialogButton]) -> Self: From a79d98d21ac27331c88bdae5f74b58bbcf828c88 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 18 Oct 2024 16:06:58 +1100 Subject: [PATCH 047/209] Added an enumeration of common button combinations, and a buttons parameter to MessageDialog's initializer --- source/gui/messageDialog.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index eedaea52604..0217038ed6e 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -133,6 +133,28 @@ class DefaultMessageDialogButton(MessageDialogButton, Enum): HELP = MessageDialogButton(id=MessageDialogReturnCode.HELP, label=_("Help")) +class DefaultMessageDialogButtons(tuple[DefaultMessageDialogButton], Enum): + OK_CANCEL = ( + DefaultMessageDialogButton.OK, + DefaultMessageDialogButton.CANCEL, + ) + YES_NO = ( + DefaultMessageDialogButton.YES, + DefaultMessageDialogButton.NO, + ) + YES_NO_CANCEL = ( + DefaultMessageDialogButton.YES, + DefaultMessageDialogButton.NO, + DefaultMessageDialogButton.CANCEL, + ) + SAVE_NO_CANCEL = ( + DefaultMessageDialogButton.SAVE, + # Translators: A don't save button on a message dialog. + DefaultMessageDialogButton.NO._replace(label=_("Do&n't save")), + DefaultMessageDialogButton.CANCEL, + ) + + class _MessageDialogCommand(NamedTuple): """Internal representation of a command for a message dialog.""" @@ -153,6 +175,7 @@ def __init__( title: str = wx.MessageBoxCaptionStr, dialogType: MessageDialogType = MessageDialogType.STANDARD, *, + buttons: Iterable[MessageDialogButton] | None = (DefaultMessageDialogButton.OK,), helpId: str = "", ): self.helpId = helpId @@ -184,6 +207,8 @@ def __init__( contentsSizer.addDialogDismissButtons(buttonHelper) self.__buttonHelper = buttonHelper self._addButtons(buttonHelper) + if buttons is not None: + self.addButtons(*buttons) mainSizer.Add( contentsSizer.sizer, From 58edca297324ebfa1241abeb550ba6890c79f622 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 18 Oct 2024 17:09:56 +1100 Subject: [PATCH 048/209] Used MessageDialog for about dialog --- source/gui/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/source/gui/__init__.py b/source/gui/__init__.py index 430c3cb459a..2d8b4c118da 100644 --- a/source/gui/__init__.py +++ b/source/gui/__init__.py @@ -31,6 +31,7 @@ # be cautious when removing messageBox, ) +from .messageDialog import MessageDialog from . import blockAction from .speechDict import ( DefaultDictionaryDialog, @@ -368,7 +369,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): From c9c4eff3b24eb731d3bee5da25642ccb4554bac8 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 28 Oct 2024 13:28:39 +1100 Subject: [PATCH 049/209] Added deprecation warning to gui.message.messageBox --- source/gui/message.py | 6 ++++++ user_docs/en/changes.md | 2 ++ 2 files changed, 8 insertions(+) diff --git a/source/gui/message.py b/source/gui/message.py index 6bd5d9c2f95..76df44218f9 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -9,6 +9,7 @@ from typing import Optional import wx +import warnings import extensionPoints @@ -97,6 +98,11 @@ def 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( + DeprecationWarning( + "gui.message.messageBox is deprecated. Use gui.message.MessageDialog instead.", + ), + ) from gui import mainFrame import core from logHandler import log diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 3f1ee9ec5a1..51221b85726 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -90,6 +90,8 @@ As the NVDA update check URL is now configurable directly within NVDA, no replac * The `braille.filter_displaySize` extension point is deprecated. Please use `braille.filter_displayDimensions` instead. (#17011) +* The `gui.message.messageBox` function is deprecated. +Use `gui.message.MessageDialog` instead. ## 2024.4 From e740ac7fa7ad320c2296e7743f4ab7758d16516d Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 28 Oct 2024 13:40:50 +1100 Subject: [PATCH 050/209] Re-enabled system test "Quits from keyboard with about dialog open" --- tests/system/robot/startupShutdownNVDA.robot | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/system/robot/startupShutdownNVDA.robot b/tests/system/robot/startupShutdownNVDA.robot index 543fe240515..12b442f4cb9 100644 --- a/tests/system/robot/startupShutdownNVDA.robot +++ b/tests/system/robot/startupShutdownNVDA.robot @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2018 NV Access Limited +# Copyright (C) 2018-2024 NV Access Limited # This file may be used under the terms of the GNU General Public License, version 2 or later. # For more details see: https://www.gnu.org/licenses/gpl-2.0.html *** Settings *** @@ -47,8 +47,6 @@ Quits from keyboard with welcome dialog open Quits from keyboard with about dialog open [Documentation] Starts NVDA and ensures that it can be quit with the about dialog open [Setup] start NVDA standard-dontShowWelcomeDialog.ini - # Excluded to be fixed still (#12976) - [Tags] excluded_from_build open about dialog from menu quits from keyboard # run test From 335f58f53b9028a673eacdce94470a57c2f7cf6f Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 28 Oct 2024 14:39:01 +1100 Subject: [PATCH 051/209] Updated block action to bring blocking dialogs to foreground --- source/gui/blockAction.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/source/gui/blockAction.py b/source/gui/blockAction.py index 205e075f31a..deaf3997f7e 100644 --- a/source/gui/blockAction.py +++ b/source/gui/blockAction.py @@ -10,10 +10,11 @@ from functools import wraps import globalVars from typing import Callable +from speech.priorities import SpeechPriority import ui from utils.security import isLockScreenModeActive, isRunningOnSecureDesktop from gui.message import isModalMessageBoxActive -import queueHandler +import core @dataclass @@ -74,7 +75,15 @@ def _wrap(func): def funcWrapper(*args, **kwargs): for context in contexts: if context.blockActionIf(): - queueHandler.queueFunction(queueHandler.eventQueue, ui.message, context.translatedMessage) + if context == Context.MODAL_DIALOG_OPEN: + # Import late to avoid circular import + from gui.messageDialog import MessageDialog + + if MessageDialog.BlockingInstancesExist(): + MessageDialog.FocusBlockingInstances() + # We need to delay this message so that, if a dialog is to be focused, the appearance of the dialog doesn'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. + core.callLater(1, ui.message, context.translatedMessage, SpeechPriority.NOW) return return func(*args, **kwargs) From db03d56adfb3325060aab1fd1b2df8749fbc331c Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 28 Oct 2024 16:07:00 +1100 Subject: [PATCH 052/209] Default action forces close --- source/gui/messageDialog.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 0217038ed6e..c9e5fcbff96 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -253,7 +253,11 @@ def addButton( # Get the ID from the button instance in case it was created with id=wx.ID_ANY. buttonId = button.GetId() self.AddMainButtonId(buttonId) - self._commands[buttonId] = _MessageDialogCommand(callback=callback, closes_dialog=closes_dialog) + # Default actions that do not close the dialog do not make sense. + self._commands[buttonId] = _MessageDialogCommand( + callback=callback, + closes_dialog=closes_dialog or default_action, + ) if default_focus: button.SetDefault() if default_action: From 6766cf50a156c1d90c5590afb2e067a9b0296841 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 29 Oct 2024 12:25:31 +1100 Subject: [PATCH 053/209] Added a docstring --- source/gui/messageDialog.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index c9e5fcbff96..5175e0bf81d 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -166,6 +166,10 @@ class _MessageDialogCommand(NamedTuple): class MessageDialog(DpiScalingHelperMixinWithoutInit, ContextHelpMixin, wx.Dialog, metaclass=SIPABCMeta): _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. + """ # region Constructors def __init__( From d0ff5cf54c3eb8b9ca3142cbb07b7dc861d2d60e Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 29 Oct 2024 16:40:34 +1100 Subject: [PATCH 054/209] Added a draft class-level docstring --- source/gui/messageDialog.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 5175e0bf81d..e9550378cf8 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -165,6 +165,15 @@ class _MessageDialogCommand(NamedTuple): class MessageDialog(DpiScalingHelperMixinWithoutInit, ContextHelpMixin, wx.Dialog, metaclass=SIPABCMeta): + """Provides a more flexible message dialog. + + Creating dialogs with this class is extremely flexible. You can create a dialog, passing almost all parameters to the initialiser, and only call `Show` or `ShowModal` on the instance. + You can also call the initialiser with very few arguments, and modify the dialog by calling methods on the created instance. + Mixing and matching both patterns is also allowed. + + When subclassing this class, you can override `_addButtons` and `_addContents` to insert custom buttons or contents that you want your subclass to always have. + """ + _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). From cb247abf24cba309f8b1ebde7fe0a590ab762dc4 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 29 Oct 2024 17:43:12 +1100 Subject: [PATCH 055/209] Respect CloseEvent.CanVeto --- source/gui/messageDialog.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index e9550378cf8..9e969ba1787 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -362,7 +362,7 @@ def ShowModal(self) -> MessageDialogReturnCode: log.debug("Showing modal") ret = displayDialogAsModal(self) self.ShowModal = self.__ShowModal - return MessageDialogReturnCode(ret) + return ret @property def isBlocking(self) -> bool: @@ -456,6 +456,16 @@ def _onShowEvt(self, evt: wx.ShowEvent): evt.Skip() def _onCloseEvent(self, evt: wx.CloseEvent): + log.debug(f"Got {'vetoable' if evt.CanVeto() else 'non-vetoable'} close event.") + if not evt.CanVeto(): + # We must close the dialog, regardless of state. + self._instances.remove(self) + self.EndModal() + self.Destroy() + return + if self.GetReturnCode() == 0: + # No button has been pressed, so this must be a close event from elsewhere. + pass self.Hide() if self.IsModal(): self.EndModal(self.GetReturnCode()) @@ -466,6 +476,7 @@ def _onCloseEvent(self, evt: wx.CloseEvent): def _onButton(self, evt: wx.CommandEvent): id = evt.GetId() + log.debug(f"Got button event on {id=}") command = self._commands.get(id) if command is None: return @@ -475,9 +486,10 @@ def _onButton(self, evt: wx.CommandEvent): self.Hide() callback(evt) if close: - closeEvent = wx.PyEvent(0, wx.EVT_CLOSE.typeId) - closeEvent.SetEventObject(evt.GetEventObject()) + # closeEvent = wx.PyEvent(0, wx.EVT_CLOSE.typeId) + # closeEvent.SetEventObject(evt.GetEventObject()) self.SetReturnCode(id) - self.GetEventHandler().QueueEvent(closeEvent) + # self.GetEventHandler().QueueEvent(closeEvent) + self.Close() # endregion From 0f4a4f51c37092d52cfbbb40b61e305feb838d7c Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 30 Oct 2024 17:19:37 +1100 Subject: [PATCH 056/209] Initial message dialog unit tests --- tests/unit/test_messageDialog.py | 51 ++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/unit/test_messageDialog.py diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py new file mode 100644 index 00000000000..6e7be77681a --- /dev/null +++ b/tests/unit/test_messageDialog.py @@ -0,0 +1,51 @@ +# 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.""" + +import unittest +from unittest.mock import MagicMock, patch + +import wx +from gui.messageDialog import MessageDialog, MessageDialogType + + +@patch.object(wx.ArtProvider, "GetIconBundle") +class Test_MessageDialog_Icons(unittest.TestCase): + """Tests for the message dialog API.""" + + def setUp(self) -> None: + self.app = wx.App() + self.dialog = MessageDialog(None, "Test dialog") + + def test_setIcon_with_type_with_icon(self, mocked_GetIconBundle: MagicMock): + mocked_GetIconBundle.return_value = wx.IconBundle() + type = MessageDialogType.ERROR + self.dialog._MessageDialog__setIcon(type) + mocked_GetIconBundle.assert_called_once() + + def test_setIcon_with_type_without_icon(self, mocked_GetIconBundle: MagicMock): + type = MessageDialogType.STANDARD + self.dialog._MessageDialog__setIcon(type) + mocked_GetIconBundle.assert_not_called() + + +@patch("winsound.MessageBeep") +class Test_MessageDialog_Sounds(unittest.TestCase): + def setUp(self): + self.app = wx.App() + self.dialog = MessageDialog(None, "Test dialog") + + def test_playSound_with_type_with_Sound(self, mocked_MessageBeep: MagicMock): + type = MessageDialogType.ERROR + self.dialog._MessageDialog__setSound(type) + self.dialog._MessageDialog__playSound() + mocked_MessageBeep.assert_called_once() + + def test_playSound_with_type_without_Sound(self, mocked_MessageBeep: MagicMock): + type = MessageDialogType.STANDARD + self.dialog._MessageDialog__setSound(type) + self.dialog._MessageDialog__playSound() + mocked_MessageBeep.assert_not_called() From 1d02fffcec5b01329f10f157eda7e8c705a3a9f8 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 30 Oct 2024 17:59:40 +1100 Subject: [PATCH 057/209] Refactored to use a common base class --- tests/unit/test_messageDialog.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 6e7be77681a..9a7f0e9a813 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -12,14 +12,18 @@ from gui.messageDialog import MessageDialog, MessageDialogType -@patch.object(wx.ArtProvider, "GetIconBundle") -class Test_MessageDialog_Icons(unittest.TestCase): - """Tests for the message dialog API.""" +class MDTestBase(unittest.TestCase): + """Base class for test cases testing MessageDialog. Handles wx initialisation.""" def setUp(self) -> None: self.app = wx.App() self.dialog = MessageDialog(None, "Test dialog") + +@patch.object(wx.ArtProvider, "GetIconBundle") +class Test_MessageDialog_Icons(MDTestBase): + """Test that message dialog icons are set correctly.""" + def test_setIcon_with_type_with_icon(self, mocked_GetIconBundle: MagicMock): mocked_GetIconBundle.return_value = wx.IconBundle() type = MessageDialogType.ERROR @@ -33,10 +37,8 @@ def test_setIcon_with_type_without_icon(self, mocked_GetIconBundle: MagicMock): @patch("winsound.MessageBeep") -class Test_MessageDialog_Sounds(unittest.TestCase): - def setUp(self): - self.app = wx.App() - self.dialog = MessageDialog(None, "Test dialog") +class Test_MessageDialog_Sounds(MDTestBase): + """Test that message dialog sounds are set and played correctly.""" def test_playSound_with_type_with_Sound(self, mocked_MessageBeep: MagicMock): type = MessageDialogType.ERROR From 7987c300f79c5fe633214474be79bc360761c8a3 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 31 Oct 2024 10:26:21 +1100 Subject: [PATCH 058/209] Removed default button assignment (reimplement later) --- source/gui/messageDialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 9e969ba1787..9e2057ddbdf 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -188,7 +188,7 @@ def __init__( title: str = wx.MessageBoxCaptionStr, dialogType: MessageDialogType = MessageDialogType.STANDARD, *, - buttons: Iterable[MessageDialogButton] | None = (DefaultMessageDialogButton.OK,), + buttons: Iterable[MessageDialogButton] | None = None, helpId: str = "", ): self.helpId = helpId From eca4e670f23665a0aab5cacb58114e7489c6a148 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 31 Oct 2024 10:28:58 +1100 Subject: [PATCH 059/209] Tests for adding standard buttons --- tests/unit/test_messageDialog.py | 50 ++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 9a7f0e9a813..d334740b392 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -51,3 +51,53 @@ def test_playSound_with_type_without_Sound(self, mocked_MessageBeep: MagicMock): self.dialog._MessageDialog__setSound(type) self.dialog._MessageDialog__playSound() mocked_MessageBeep.assert_not_called() + + +class Test_MessageDialog_Buttons(MDTestBase): + def test_addOkButton(self): + """Test adding an OK button to the dialog.""" + self.dialog.addOkButton() + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_OK), wx.Button) + self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_OK]) + + def test_addCancelButton(self): + """Test adding a Cancel button to the dialog.""" + self.dialog.addCancelButton() + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_CANCEL), wx.Button) + self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_CANCEL]) + + def test_addYesButton(self): + """Test adding a Yes button to the dialog.""" + self.dialog.addYesButton() + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_YES), wx.Button) + self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_YES]) + + def test_addNoButton(self): + """Test adding a No button to the dialog.""" + self.dialog.addNoButton() + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_NO), wx.Button) + self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_NO]) + + def test_addSaveButton(self): + """Test adding a Save button to the dialog.""" + self.dialog.addSaveButton() + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_SAVE), wx.Button) + self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_SAVE]) + + def test_addApplyButton(self): + """Test adding an Apply button to the dialog.""" + self.dialog.addApplyButton() + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_APPLY), wx.Button) + self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_APPLY]) + + def test_addCloseButton(self): + """Test adding a Close button to the dialog.""" + self.dialog.addCloseButton() + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_CLOSE), wx.Button) + self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_CLOSE]) + + def test_addHelpButton(self): + """Test adding a Help button to the dialog.""" + self.dialog.addHelpButton() + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_HELP), wx.Button) + self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_HELP]) From 9fdd60391afc7b784245ffffbd7bcc8fd8ab82e4 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 31 Oct 2024 12:42:43 +1100 Subject: [PATCH 060/209] Added support for adding DefaultMessageDialogButtons with MessageDialog.addButtons without unpacking --- source/gui/messageDialog.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 9e2057ddbdf..8611313407a 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -5,7 +5,7 @@ from enum import Enum, IntEnum, auto import time -from typing import Any, Callable, Deque, Iterable, NamedTuple, TypeAlias, Self +from typing import Any, NamedTuple, TypeAlias, Self import winsound import wx @@ -18,6 +18,7 @@ from gui import guiHelper from functools import partialmethod, singledispatchmethod from collections import deque +from collections.abc import Iterable, Iterator, Callable from logHandler import log @@ -174,7 +175,7 @@ class MessageDialog(DpiScalingHelperMixinWithoutInit, ContextHelpMixin, wx.Dialo When subclassing this class, you can override `_addButtons` and `_addContents` to insert custom buttons or contents that you want your subclass to always have. """ - _instances: Deque["MessageDialog"] = deque() + _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. @@ -324,13 +325,12 @@ def _( addHelpButton = partialmethod(addButton, DefaultMessageDialogButton.HELP) addHelpButton.__doc__ = "Add a Help button to the dialog." - def addButtons(self, *buttons: Iterable[MessageDialogButton]) -> Self: + def addButtons(self, *buttons: DefaultMessageDialogButtons | MessageDialogButton) -> Self: """Add multiple buttons to the dialog. :return: The dialog instance. """ - - for button in buttons: + for button in _flattenButtons(buttons): self.addButton(button) return self @@ -493,3 +493,18 @@ def _onButton(self, evt: wx.CommandEvent): self.Close() # endregion + + +def _flattenButtons( + buttons: Iterable[DefaultMessageDialogButtons | MessageDialogButton], +) -> Iterator[MessageDialogButton]: + """Flatten an iterable of c{MessageDialogButton} or c{DefaultMessageDialogButtons} instances into an iterator of c{MessageDialogButton} instances. + + :param buttons: The iterator of buttons and button sets to flatten. + :yield: Each button contained in the input iterator or its children. + """ + for item in buttons: + if isinstance(item, DefaultMessageDialogButtons): + yield from item + else: + yield item From b5a928cc2b85e9bfbdb7b822bf6891bb642735ae Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 31 Oct 2024 12:53:19 +1100 Subject: [PATCH 061/209] Added methods to easily add default buttons and unit tests for same --- source/gui/messageDialog.py | 9 +++++++++ tests/unit/test_messageDialog.py | 26 ++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 8611313407a..65b2f8743a4 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -334,6 +334,15 @@ def addButtons(self, *buttons: DefaultMessageDialogButtons | MessageDialogButton self.addButton(button) return self + addOkCancelButtons = partialmethod(addButtons, DefaultMessageDialogButtons.OK_CANCEL) + addOkCancelButtons.__doc__ = "Add OK and Cancel buttons to the dialog." + addYesNoButtons = partialmethod(addButtons, DefaultMessageDialogButtons.YES_NO) + addYesNoButtons.__doc__ = "Add Yes and No buttons to the dialog." + addYesNoCancelButtons = partialmethod(addButtons, DefaultMessageDialogButtons.YES_NO_CANCEL) + addYesNoCancelButtons.__doc__ = "Add Yes, No and Cancel buttons to the dialog." + addSaveNoCancelButtons = partialmethod(addButtons, DefaultMessageDialogButtons.SAVE_NO_CANCEL) + addSaveNoCancelButtons.__doc__ = "Add Save, Don't save and Cancel buttons to the dialog." + def Show(self) -> bool: """Show a non-blocking dialog. Attach buttons with button handlers""" diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index d334740b392..55c89b7022f 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -101,3 +101,29 @@ def test_addHelpButton(self): self.dialog.addHelpButton() self.assertIsInstance(self.dialog.FindWindowById(wx.ID_HELP), wx.Button) self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_HELP]) + + def test_addOkCancelButtons(self): + self.dialog.addOkCancelButtons() + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_OK), wx.Button) + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_CANCEL), wx.Button) + self.assertCountEqual(self.dialog.GetMainButtonIds(), (wx.ID_OK, wx.ID_CANCEL)) + + def test_addYesNoButtons(self): + self.dialog.addYesNoButtons() + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_YES), wx.Button) + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_NO), wx.Button) + self.assertCountEqual(self.dialog.GetMainButtonIds(), (wx.ID_YES, wx.ID_NO)) + + def test_addYesNoCancelButtons(self): + self.dialog.addYesNoCancelButtons() + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_YES), wx.Button) + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_NO), wx.Button) + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_CANCEL), wx.Button) + self.assertCountEqual(self.dialog.GetMainButtonIds(), (wx.ID_YES, wx.ID_NO, wx.ID_CANCEL)) + + def test_addSaveNoCancelButtons(self): + self.dialog.addSaveNoCancelButtons() + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_SAVE), wx.Button) + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_NO), wx.Button) + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_CANCEL), wx.Button) + self.assertCountEqual(self.dialog.GetMainButtonIds(), (wx.ID_SAVE, wx.ID_NO, wx.ID_CANCEL)) From 214e62b70068e3ddba8c29ca80d5b53066073286 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 1 Nov 2024 13:36:55 +1100 Subject: [PATCH 062/209] Add hasDefaultAction property --- source/gui/messageDialog.py | 12 ++++ tests/unit/test_messageDialog.py | 108 ++++++++++++++++++++++--------- 2 files changed, 90 insertions(+), 30 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 65b2f8743a4..30b45d66901 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -378,6 +378,18 @@ def isBlocking(self) -> bool: """Whether or not the dialog is blocking""" return self.IsModal() and self._defaultReturnCode is None + @property + def hasDefaultAction(self) -> bool: + escapeId = self.GetEscapeId() + return escapeId != MessageDialogEscapeCode.NONE and ( + any( + command in (MessageDialogReturnCode.CANCEL, MessageDialogReturnCode.OK) + for command in self._commands + ) + if escapeId == MessageDialogEscapeCode.DEFAULT + else True + ) + # endregion # region Public class methods diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 55c89b7022f..571e0f0c0ed 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -57,73 +57,121 @@ class Test_MessageDialog_Buttons(MDTestBase): def test_addOkButton(self): """Test adding an OK button to the dialog.""" self.dialog.addOkButton() - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_OK), wx.Button) - self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_OK]) + with self.subTest("Check button types"): + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_OK), wx.Button) + with self.subTest("Test in main buttons"): + self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_OK]) + with self.subTest("Test has default action."): + self.assertTrue(self.dialog.hasDefaultAction) def test_addCancelButton(self): """Test adding a Cancel button to the dialog.""" self.dialog.addCancelButton() - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_CANCEL), wx.Button) - self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_CANCEL]) + with self.subTest("Check button types"): + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_CANCEL), wx.Button) + with self.subTest("Test in main buttons"): + self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_CANCEL]) + with self.subTest("Test has default action."): + self.assertTrue(self.dialog.hasDefaultAction) def test_addYesButton(self): """Test adding a Yes button to the dialog.""" self.dialog.addYesButton() - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_YES), wx.Button) - self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_YES]) + with self.subTest("Check button types"): + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_YES), wx.Button) + with self.subTest("Test in main buttons"): + self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_YES]) + with self.subTest("Test has default action."): + self.assertFalse(self.dialog.hasDefaultAction) def test_addNoButton(self): """Test adding a No button to the dialog.""" self.dialog.addNoButton() - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_NO), wx.Button) - self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_NO]) + with self.subTest("Check button types"): + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_NO), wx.Button) + with self.subTest("Test in main buttons"): + self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_NO]) + with self.subTest("Test has default action."): + self.assertFalse(self.dialog.hasDefaultAction) def test_addSaveButton(self): """Test adding a Save button to the dialog.""" self.dialog.addSaveButton() - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_SAVE), wx.Button) - self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_SAVE]) + with self.subTest("Check button types"): + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_SAVE), wx.Button) + with self.subTest("Test in main buttons"): + self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_SAVE]) + with self.subTest("Test has default action."): + self.assertFalse(self.dialog.hasDefaultAction) def test_addApplyButton(self): """Test adding an Apply button to the dialog.""" self.dialog.addApplyButton() - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_APPLY), wx.Button) - self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_APPLY]) + with self.subTest("Check button types"): + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_APPLY), wx.Button) + with self.subTest("Test in main buttons"): + self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_APPLY]) + with self.subTest("Test has default action."): + self.assertFalse(self.dialog.hasDefaultAction) def test_addCloseButton(self): """Test adding a Close button to the dialog.""" self.dialog.addCloseButton() - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_CLOSE), wx.Button) - self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_CLOSE]) + with self.subTest("Check button types"): + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_CLOSE), wx.Button) + with self.subTest("Test in main buttons"): + self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_CLOSE]) + with self.subTest("Test has default action."): + self.assertFalse(self.dialog.hasDefaultAction) def test_addHelpButton(self): """Test adding a Help button to the dialog.""" self.dialog.addHelpButton() - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_HELP), wx.Button) - self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_HELP]) + with self.subTest("Check button types"): + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_HELP), wx.Button) + with self.subTest("Test in main buttons"): + self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_HELP]) + with self.subTest("Test has default action."): + self.assertFalse(self.dialog.hasDefaultAction) def test_addOkCancelButtons(self): self.dialog.addOkCancelButtons() - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_OK), wx.Button) - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_CANCEL), wx.Button) - self.assertCountEqual(self.dialog.GetMainButtonIds(), (wx.ID_OK, wx.ID_CANCEL)) + with self.subTest("Check button types"): + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_OK), wx.Button) + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_CANCEL), wx.Button) + with self.subTest("Test in main buttons"): + self.assertCountEqual(self.dialog.GetMainButtonIds(), (wx.ID_OK, wx.ID_CANCEL)) + with self.subTest("Test has default action."): + self.assertTrue(self.dialog.hasDefaultAction) def test_addYesNoButtons(self): self.dialog.addYesNoButtons() - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_YES), wx.Button) - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_NO), wx.Button) - self.assertCountEqual(self.dialog.GetMainButtonIds(), (wx.ID_YES, wx.ID_NO)) + with self.subTest("Check button types"): + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_YES), wx.Button) + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_NO), wx.Button) + with self.subTest("Test in main buttons"): + self.assertCountEqual(self.dialog.GetMainButtonIds(), (wx.ID_YES, wx.ID_NO)) + with self.subTest("Test has default action."): + self.assertFalse(self.dialog.hasDefaultAction) def test_addYesNoCancelButtons(self): self.dialog.addYesNoCancelButtons() - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_YES), wx.Button) - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_NO), wx.Button) - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_CANCEL), wx.Button) - self.assertCountEqual(self.dialog.GetMainButtonIds(), (wx.ID_YES, wx.ID_NO, wx.ID_CANCEL)) + with self.subTest("Check button types"): + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_YES), wx.Button) + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_NO), wx.Button) + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_CANCEL), wx.Button) + with self.subTest("Test in main buttons"): + self.assertCountEqual(self.dialog.GetMainButtonIds(), (wx.ID_YES, wx.ID_NO, wx.ID_CANCEL)) + with self.subTest("Test has default action."): + self.assertTrue(self.dialog.hasDefaultAction) def test_addSaveNoCancelButtons(self): self.dialog.addSaveNoCancelButtons() - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_SAVE), wx.Button) - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_NO), wx.Button) - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_CANCEL), wx.Button) - self.assertCountEqual(self.dialog.GetMainButtonIds(), (wx.ID_SAVE, wx.ID_NO, wx.ID_CANCEL)) + with self.subTest("Check button types"): + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_SAVE), wx.Button) + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_NO), wx.Button) + self.assertIsInstance(self.dialog.FindWindowById(wx.ID_CANCEL), wx.Button) + with self.subTest("Test in main buttons"): + self.assertCountEqual(self.dialog.GetMainButtonIds(), (wx.ID_SAVE, wx.ID_NO, wx.ID_CANCEL)) + with self.subTest("Test has default action."): + self.assertTrue(self.dialog.hasDefaultAction) From 8c11e0b116f1e4c8fc69a7ecf8374022eb5521bc Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 1 Nov 2024 13:39:37 +1100 Subject: [PATCH 063/209] Add docstrings --- tests/unit/test_messageDialog.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 571e0f0c0ed..d413f2d76e2 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -135,6 +135,7 @@ def test_addHelpButton(self): self.assertFalse(self.dialog.hasDefaultAction) def test_addOkCancelButtons(self): + """Test adding OK and Cancel buttons to the dialog.""" self.dialog.addOkCancelButtons() with self.subTest("Check button types"): self.assertIsInstance(self.dialog.FindWindowById(wx.ID_OK), wx.Button) @@ -145,6 +146,7 @@ def test_addOkCancelButtons(self): self.assertTrue(self.dialog.hasDefaultAction) def test_addYesNoButtons(self): + """Test adding Yes and No buttons to the dialog.""" self.dialog.addYesNoButtons() with self.subTest("Check button types"): self.assertIsInstance(self.dialog.FindWindowById(wx.ID_YES), wx.Button) @@ -155,6 +157,7 @@ def test_addYesNoButtons(self): self.assertFalse(self.dialog.hasDefaultAction) def test_addYesNoCancelButtons(self): + """Test adding Yes, No and Cancel buttons to the dialog.""" self.dialog.addYesNoCancelButtons() with self.subTest("Check button types"): self.assertIsInstance(self.dialog.FindWindowById(wx.ID_YES), wx.Button) @@ -166,6 +169,7 @@ def test_addYesNoCancelButtons(self): self.assertTrue(self.dialog.hasDefaultAction) def test_addSaveNoCancelButtons(self): + """Test adding Save, Don't save and Cancel buttons to the dialog.""" self.dialog.addSaveNoCancelButtons() with self.subTest("Check button types"): self.assertIsInstance(self.dialog.FindWindowById(wx.ID_SAVE), wx.Button) From 2e836aeabfa5bb1e6ff999b300f888825a7ae27c Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 1 Nov 2024 16:46:30 +1100 Subject: [PATCH 064/209] Improved implementation of default action getters --- source/gui/messageDialog.py | 44 +++++++++++++++++++--- tests/unit/test_messageDialog.py | 64 ++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 6 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 30b45d66901..f14c5ef76cc 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -443,16 +443,48 @@ def _realize_layout(self) -> None: if gui._isDebug(): log.debug(f"Layout completed in {time.time() - startTime:.3f} seconds") - @property - def _defaultAction(self) -> MessageDialogCallback | None: - if (defaultReturnCode := self._defaultReturnCode) is not None: + def _getDefaultAction(self) -> _MessageDialogCommand | None: + escapeId = self.GetEscapeId() + if escapeId == MessageDialogEscapeCode.NONE: + return None + elif escapeId == MessageDialogEscapeCode.DEFAULT: + return self._commands.get( + MessageDialogReturnCode.CANCEL, + self._commands.get(self.GetAffirmativeId(), None), + ) + else: try: - return self._commands[defaultReturnCode] + return self._commands[escapeId] except KeyError: raise RuntimeError( - f"Default return code {defaultReturnCode} is not associated with a callback", + f"Escape ID {escapeId} is not associated with a command", ) - return None + + def _getDefaultActionOrFallback(self) -> _MessageDialogCommand: + # Try using the user-specified default action. + try: + if (defaultAction := self._getDefaultAction()) is not None: + return defaultAction + except KeyError: + log.exception("Default action was not in commands. This indicates a logic error.") + + # Default action is unavailable. Try using the default focus instead. + try: + if (defaultFocus := self.GetDefaultItem()) is not None: + return self._commands[defaultFocus.getId()] + except KeyError: + log.exception("Default focus was not in commands. This indicates a logic error.") + + # Default focus is unavailable. Try using the first button instead. + try: + return next(iter(self._commands.values())) + except StopIteration: + log.exception( + "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 _MessageDialogCommand() def __setIcon(self, type: MessageDialogType) -> None: if (iconID := type._wxIconId) is not None: diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index d413f2d76e2..0fbb8e9eee5 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -63,6 +63,8 @@ def test_addOkButton(self): self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_OK]) with self.subTest("Test has default action."): self.assertTrue(self.dialog.hasDefaultAction) + with self.subTest("Test default action assignment."): + self.assertIsNotNone(self.dialog._getDefaultAction()) def test_addCancelButton(self): """Test adding a Cancel button to the dialog.""" @@ -73,6 +75,8 @@ def test_addCancelButton(self): self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_CANCEL]) with self.subTest("Test has default action."): self.assertTrue(self.dialog.hasDefaultAction) + with self.subTest("Test default action assignment."): + self.assertIsNotNone(self.dialog._getDefaultAction()) def test_addYesButton(self): """Test adding a Yes button to the dialog.""" @@ -83,6 +87,8 @@ def test_addYesButton(self): self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_YES]) with self.subTest("Test has default action."): self.assertFalse(self.dialog.hasDefaultAction) + with self.subTest("Test default action assignment."): + self.assertIsNone(self.dialog._getDefaultAction()) def test_addNoButton(self): """Test adding a No button to the dialog.""" @@ -93,6 +99,8 @@ def test_addNoButton(self): self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_NO]) with self.subTest("Test has default action."): self.assertFalse(self.dialog.hasDefaultAction) + with self.subTest("Test default action assignment."): + self.assertIsNone(self.dialog._getDefaultAction()) def test_addSaveButton(self): """Test adding a Save button to the dialog.""" @@ -103,6 +111,8 @@ def test_addSaveButton(self): self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_SAVE]) with self.subTest("Test has default action."): self.assertFalse(self.dialog.hasDefaultAction) + with self.subTest("Test default action assignment."): + self.assertIsNone(self.dialog._getDefaultAction()) def test_addApplyButton(self): """Test adding an Apply button to the dialog.""" @@ -113,6 +123,8 @@ def test_addApplyButton(self): self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_APPLY]) with self.subTest("Test has default action."): self.assertFalse(self.dialog.hasDefaultAction) + with self.subTest("Test default action assignment."): + self.assertIsNone(self.dialog._getDefaultAction()) def test_addCloseButton(self): """Test adding a Close button to the dialog.""" @@ -123,6 +135,8 @@ def test_addCloseButton(self): self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_CLOSE]) with self.subTest("Test has default action."): self.assertFalse(self.dialog.hasDefaultAction) + with self.subTest("Test default action assignment."): + self.assertIsNone(self.dialog._getDefaultAction()) def test_addHelpButton(self): """Test adding a Help button to the dialog.""" @@ -133,6 +147,8 @@ def test_addHelpButton(self): self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_HELP]) with self.subTest("Test has default action."): self.assertFalse(self.dialog.hasDefaultAction) + with self.subTest("Test default action assignment."): + self.assertIsNone(self.dialog._getDefaultAction()) def test_addOkCancelButtons(self): """Test adding OK and Cancel buttons to the dialog.""" @@ -144,6 +160,8 @@ def test_addOkCancelButtons(self): self.assertCountEqual(self.dialog.GetMainButtonIds(), (wx.ID_OK, wx.ID_CANCEL)) with self.subTest("Test has default action."): self.assertTrue(self.dialog.hasDefaultAction) + with self.subTest("Test default action assignment."): + self.assertIsNotNone(self.dialog._getDefaultAction()) def test_addYesNoButtons(self): """Test adding Yes and No buttons to the dialog.""" @@ -155,6 +173,8 @@ def test_addYesNoButtons(self): self.assertCountEqual(self.dialog.GetMainButtonIds(), (wx.ID_YES, wx.ID_NO)) with self.subTest("Test has default action."): self.assertFalse(self.dialog.hasDefaultAction) + with self.subTest("Test default action assignment."): + self.assertIsNone(self.dialog._getDefaultAction()) def test_addYesNoCancelButtons(self): """Test adding Yes, No and Cancel buttons to the dialog.""" @@ -167,6 +187,8 @@ def test_addYesNoCancelButtons(self): self.assertCountEqual(self.dialog.GetMainButtonIds(), (wx.ID_YES, wx.ID_NO, wx.ID_CANCEL)) with self.subTest("Test has default action."): self.assertTrue(self.dialog.hasDefaultAction) + with self.subTest("Test default action assignment."): + self.assertIsNotNone(self.dialog._getDefaultAction()) def test_addSaveNoCancelButtons(self): """Test adding Save, Don't save and Cancel buttons to the dialog.""" @@ -179,3 +201,45 @@ def test_addSaveNoCancelButtons(self): self.assertCountEqual(self.dialog.GetMainButtonIds(), (wx.ID_SAVE, wx.ID_NO, wx.ID_CANCEL)) with self.subTest("Test has default action."): self.assertTrue(self.dialog.hasDefaultAction) + with self.subTest("Test default action assignment."): + self.assertIsNotNone(self.dialog._getDefaultAction()) + + def test_defaultAction_defaultEscape_OkCancel(self): + def okCallback(): + pass + + def cancelCallback(*a): + pass + + self.dialog.addOkButton(callback=okCallback).addCancelButton(callback=cancelCallback) + self.assertEqual(self.dialog._getDefaultAction().callback, cancelCallback) + + def test_defaultAction_defaultEscape_CancelOk(self): + def okCallback(): + pass + + def cancelCallback(*a): + pass + + self.dialog.addCancelButton(callback=cancelCallback).addOkButton(callback=okCallback) + self.assertEqual(self.dialog._getDefaultAction().callback, cancelCallback) + + def test_defaultAction_defaultEscape_OkClose(self): + def okCallback(): + pass + + def closeCallback(*a): + pass + + self.dialog.addOkButton(callback=okCallback).addCloseButton(callback=closeCallback) + self.assertEqual(self.dialog._getDefaultAction().callback, okCallback) + + def test_defaultAction_defaultEscape_CloseOk(self): + def okCallback(): + pass + + def closeCallback(*a): + pass + + self.dialog.addCloseButton(callback=closeCallback).addOkButton(callback=okCallback) + self.assertEqual(self.dialog._getDefaultAction().callback, okCallback) From 561e9d09639e56c00e091799a77419c2d08f44af Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 1 Nov 2024 16:57:32 +1100 Subject: [PATCH 065/209] Slightly refactored tests and improved docstrings for some tests --- tests/unit/test_messageDialog.py | 54 +++++++++++++------------------- 1 file changed, 22 insertions(+), 32 deletions(-) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 0fbb8e9eee5..3bc08271d59 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -12,6 +12,14 @@ from gui.messageDialog import MessageDialog, MessageDialogType +def dummyCallback1(*a): + pass + + +def dummyCallback2(*a): + pass + + class MDTestBase(unittest.TestCase): """Base class for test cases testing MessageDialog. Handles wx initialisation.""" @@ -204,42 +212,24 @@ def test_addSaveNoCancelButtons(self): with self.subTest("Test default action assignment."): self.assertIsNotNone(self.dialog._getDefaultAction()) - def test_defaultAction_defaultEscape_OkCancel(self): - def okCallback(): - pass - - def cancelCallback(*a): - pass - self.dialog.addOkButton(callback=okCallback).addCancelButton(callback=cancelCallback) - self.assertEqual(self.dialog._getDefaultAction().callback, cancelCallback) +class Test_MessageDialog_DefaultAction(MDTestBase): + def test_defaultAction_defaultEscape_OkCancel(self): + """Test that when adding OK and Cancel buttons with default escape code, that the default action is cancel.""" + self.dialog.addOkButton(callback=dummyCallback1).addCancelButton(callback=dummyCallback2) + self.assertEqual(self.dialog._getDefaultAction().callback, dummyCallback2) def test_defaultAction_defaultEscape_CancelOk(self): - def okCallback(): - pass - - def cancelCallback(*a): - pass - - self.dialog.addCancelButton(callback=cancelCallback).addOkButton(callback=okCallback) - self.assertEqual(self.dialog._getDefaultAction().callback, cancelCallback) + """Test that when adding cancel and ok buttons with default escape code, that the default action is cancel.""" + self.dialog.addCancelButton(callback=dummyCallback2).addOkButton(callback=dummyCallback1) + self.assertEqual(self.dialog._getDefaultAction().callback, dummyCallback2) def test_defaultAction_defaultEscape_OkClose(self): - def okCallback(): - pass - - def closeCallback(*a): - pass - - self.dialog.addOkButton(callback=okCallback).addCloseButton(callback=closeCallback) - self.assertEqual(self.dialog._getDefaultAction().callback, okCallback) + """Test that when adding OK and Close buttons with default escape code, that the default action is OK.""" + self.dialog.addOkButton(callback=dummyCallback1).addCloseButton(callback=dummyCallback2) + self.assertEqual(self.dialog._getDefaultAction().callback, dummyCallback1) def test_defaultAction_defaultEscape_CloseOk(self): - def okCallback(): - pass - - def closeCallback(*a): - pass - - self.dialog.addCloseButton(callback=closeCallback).addOkButton(callback=okCallback) - self.assertEqual(self.dialog._getDefaultAction().callback, okCallback) + """Test that when adding Close and OK buttons with default escape code, that the default action is OK.""" + self.dialog.addCloseButton(callback=dummyCallback2).addOkButton(callback=dummyCallback1) + self.assertEqual(self.dialog._getDefaultAction().callback, dummyCallback1) From 86fc67480ec9955e2ae4dcaf6d5cba0f072ae573 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 1 Nov 2024 17:19:15 +1100 Subject: [PATCH 066/209] Slight refacter to closing logic --- source/gui/messageDialog.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index f14c5ef76cc..6a6a33754ec 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -23,7 +23,7 @@ # TODO: Change to type statement when Python 3.12 or later is in use. -MessageDialogCallback: TypeAlias = Callable[[wx.CommandEvent], Any] +MessageDialogCallback: TypeAlias = Callable[[], Any] class MessageDialogReturnCode(IntEnum): @@ -530,19 +530,20 @@ def _onCloseEvent(self, evt: wx.CloseEvent): def _onButton(self, evt: wx.CommandEvent): id = evt.GetId() log.debug(f"Got button event on {id=}") - command = self._commands.get(id) - if command is None: - return + try: + self._execute_command(id) + except KeyError: + log.debug(f"No command registered for {id=}.") + + def _execute_command(self, id: int): + command = self._commands[id] callback, close = command if callback is not None: if close: self.Hide() - callback(evt) + callback() if close: - # closeEvent = wx.PyEvent(0, wx.EVT_CLOSE.typeId) - # closeEvent.SetEventObject(evt.GetEventObject()) self.SetReturnCode(id) - # self.GetEventHandler().QueueEvent(closeEvent) self.Close() # endregion From 725ef74834b773e29d40737a43a106974ddd2067 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Sun, 3 Nov 2024 17:20:09 +1100 Subject: [PATCH 067/209] Re-implemented get default action and tests for same --- source/gui/messageDialog.py | 29 ++++++++++++------- tests/unit/test_messageDialog.py | 48 ++++++++++++++++++++++---------- 2 files changed, 53 insertions(+), 24 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 6a6a33754ec..05a51952890 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -443,18 +443,25 @@ def _realize_layout(self) -> None: if gui._isDebug(): log.debug(f"Layout completed in {time.time() - startTime:.3f} seconds") - def _getDefaultAction(self) -> _MessageDialogCommand | None: + def _getDefaultAction(self) -> tuple[int, _MessageDialogCommand | None]: escapeId = self.GetEscapeId() if escapeId == MessageDialogEscapeCode.NONE: - return None + return escapeId, None elif escapeId == MessageDialogEscapeCode.DEFAULT: - return self._commands.get( - MessageDialogReturnCode.CANCEL, - self._commands.get(self.GetAffirmativeId(), None), - ) + affirmativeAction: _MessageDialogCommand | None = None + affirmativeId: int = self.GetAffirmativeId() + for id, command in self._commands.items(): + if id == MessageDialogReturnCode.CANCEL: + return id, command + elif id == affirmativeId: + affirmativeAction = command + if affirmativeAction is None: + return MessageDialogEscapeCode.NONE, None + else: + return affirmativeId, affirmativeAction else: try: - return self._commands[escapeId] + return escapeId, self._commands[escapeId] except KeyError: raise RuntimeError( f"Escape ID {escapeId} is not associated with a command", @@ -513,11 +520,12 @@ def _onCloseEvent(self, evt: wx.CloseEvent): if not evt.CanVeto(): # We must close the dialog, regardless of state. self._instances.remove(self) - self.EndModal() + 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. + self._execute_command(self._getDefaultAction()) pass self.Hide() if self.IsModal(): @@ -535,8 +543,9 @@ def _onButton(self, evt: wx.CommandEvent): except KeyError: log.debug(f"No command registered for {id=}.") - def _execute_command(self, id: int): - command = self._commands[id] + def _execute_command(self, id: int, command: _MessageDialogCommand | None = None): + if command is None: + command = self._commands[id] callback, close = command if callback is not None: if close: diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 3bc08271d59..e93543549b3 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -9,7 +9,15 @@ from unittest.mock import MagicMock, patch import wx -from gui.messageDialog import MessageDialog, MessageDialogType +from gui.messageDialog import ( + MessageDialog, + MessageDialogEscapeCode, + MessageDialogReturnCode, + MessageDialogType, +) + + +NO_CALLBACK = (MessageDialogEscapeCode.NONE, None) def dummyCallback1(*a): @@ -72,7 +80,9 @@ def test_addOkButton(self): with self.subTest("Test has default action."): self.assertTrue(self.dialog.hasDefaultAction) with self.subTest("Test default action assignment."): - self.assertIsNotNone(self.dialog._getDefaultAction()) + id, callback = self.dialog._getDefaultAction() + self.assertEqual(id, MessageDialogReturnCode.OK) + self.assertIsNotNone(callback) def test_addCancelButton(self): """Test adding a Cancel button to the dialog.""" @@ -84,7 +94,9 @@ def test_addCancelButton(self): with self.subTest("Test has default action."): self.assertTrue(self.dialog.hasDefaultAction) with self.subTest("Test default action assignment."): - self.assertIsNotNone(self.dialog._getDefaultAction()) + id, callback = self.dialog._getDefaultAction() + self.assertEqual(id, MessageDialogReturnCode.CANCEL) + self.assertIsNotNone(callback) def test_addYesButton(self): """Test adding a Yes button to the dialog.""" @@ -96,7 +108,7 @@ def test_addYesButton(self): with self.subTest("Test has default action."): self.assertFalse(self.dialog.hasDefaultAction) with self.subTest("Test default action assignment."): - self.assertIsNone(self.dialog._getDefaultAction()) + self.assertEqual(self.dialog._getDefaultAction(), NO_CALLBACK) def test_addNoButton(self): """Test adding a No button to the dialog.""" @@ -108,7 +120,7 @@ def test_addNoButton(self): with self.subTest("Test has default action."): self.assertFalse(self.dialog.hasDefaultAction) with self.subTest("Test default action assignment."): - self.assertIsNone(self.dialog._getDefaultAction()) + self.assertEqual(self.dialog._getDefaultAction(), NO_CALLBACK) def test_addSaveButton(self): """Test adding a Save button to the dialog.""" @@ -120,7 +132,7 @@ def test_addSaveButton(self): with self.subTest("Test has default action."): self.assertFalse(self.dialog.hasDefaultAction) with self.subTest("Test default action assignment."): - self.assertIsNone(self.dialog._getDefaultAction()) + self.assertEqual(self.dialog._getDefaultAction(), NO_CALLBACK) def test_addApplyButton(self): """Test adding an Apply button to the dialog.""" @@ -132,7 +144,7 @@ def test_addApplyButton(self): with self.subTest("Test has default action."): self.assertFalse(self.dialog.hasDefaultAction) with self.subTest("Test default action assignment."): - self.assertIsNone(self.dialog._getDefaultAction()) + self.assertEqual(self.dialog._getDefaultAction(), NO_CALLBACK) def test_addCloseButton(self): """Test adding a Close button to the dialog.""" @@ -144,7 +156,7 @@ def test_addCloseButton(self): with self.subTest("Test has default action."): self.assertFalse(self.dialog.hasDefaultAction) with self.subTest("Test default action assignment."): - self.assertIsNone(self.dialog._getDefaultAction()) + self.assertEqual(self.dialog._getDefaultAction(), NO_CALLBACK) def test_addHelpButton(self): """Test adding a Help button to the dialog.""" @@ -156,7 +168,7 @@ def test_addHelpButton(self): with self.subTest("Test has default action."): self.assertFalse(self.dialog.hasDefaultAction) with self.subTest("Test default action assignment."): - self.assertIsNone(self.dialog._getDefaultAction()) + self.assertEqual(self.dialog._getDefaultAction(), NO_CALLBACK) def test_addOkCancelButtons(self): """Test adding OK and Cancel buttons to the dialog.""" @@ -182,7 +194,7 @@ def test_addYesNoButtons(self): with self.subTest("Test has default action."): self.assertFalse(self.dialog.hasDefaultAction) with self.subTest("Test default action assignment."): - self.assertIsNone(self.dialog._getDefaultAction()) + self.assertEqual(self.dialog._getDefaultAction(), NO_CALLBACK) def test_addYesNoCancelButtons(self): """Test adding Yes, No and Cancel buttons to the dialog.""" @@ -217,19 +229,27 @@ class Test_MessageDialog_DefaultAction(MDTestBase): def test_defaultAction_defaultEscape_OkCancel(self): """Test that when adding OK and Cancel buttons with default escape code, that the default action is cancel.""" self.dialog.addOkButton(callback=dummyCallback1).addCancelButton(callback=dummyCallback2) - self.assertEqual(self.dialog._getDefaultAction().callback, dummyCallback2) + id, command = self.dialog._getDefaultAction() + self.assertEqual(id, MessageDialogReturnCode.CANCEL) + self.assertEqual(command.callback, dummyCallback2) def test_defaultAction_defaultEscape_CancelOk(self): """Test that when adding cancel and ok buttons with default escape code, that the default action is cancel.""" self.dialog.addCancelButton(callback=dummyCallback2).addOkButton(callback=dummyCallback1) - self.assertEqual(self.dialog._getDefaultAction().callback, dummyCallback2) + id, command = self.dialog._getDefaultAction() + self.assertEqual(id, MessageDialogReturnCode.CANCEL) + self.assertEqual(command.callback, dummyCallback2) def test_defaultAction_defaultEscape_OkClose(self): """Test that when adding OK and Close buttons with default escape code, that the default action is OK.""" self.dialog.addOkButton(callback=dummyCallback1).addCloseButton(callback=dummyCallback2) - self.assertEqual(self.dialog._getDefaultAction().callback, dummyCallback1) + id, command = self.dialog._getDefaultAction() + self.assertEqual(id, MessageDialogReturnCode.OK) + self.assertEqual(command.callback, dummyCallback1) def test_defaultAction_defaultEscape_CloseOk(self): """Test that when adding Close and OK buttons with default escape code, that the default action is OK.""" self.dialog.addCloseButton(callback=dummyCallback2).addOkButton(callback=dummyCallback1) - self.assertEqual(self.dialog._getDefaultAction().callback, dummyCallback1) + id, command = self.dialog._getDefaultAction() + self.assertEqual(id, MessageDialogReturnCode.OK) + self.assertEqual(command.callback, dummyCallback1) From 48ec32b6f1f55ce7a8aad59402d2b38a487448a3 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 4 Nov 2024 10:08:23 +1100 Subject: [PATCH 068/209] Made _getDefaultActionOrFallback comply with the return type opf _getDefaultAction --- source/gui/messageDialog.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 05a51952890..c149b88bdd8 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -467,31 +467,33 @@ def _getDefaultAction(self) -> tuple[int, _MessageDialogCommand | None]: f"Escape ID {escapeId} is not associated with a command", ) - def _getDefaultActionOrFallback(self) -> _MessageDialogCommand: + def _getDefaultActionOrFallback(self) -> tuple[int, _MessageDialogCommand]: # Try using the user-specified default action. try: - if (defaultAction := self._getDefaultAction()) is not None: - return defaultAction + id, action = self._getDefaultAction() + if action is not None: + return id, action except KeyError: log.exception("Default action was not in commands. This indicates a logic error.") # Default action is unavailable. Try using the default focus instead. try: if (defaultFocus := self.GetDefaultItem()) is not None: - return self._commands[defaultFocus.getId()] + id = defaultFocus.getId() + return id, self._commands[id] except KeyError: log.exception("Default focus was not in commands. This indicates a logic error.") # Default focus is unavailable. Try using the first button instead. try: - return next(iter(self._commands.values())) + return next(iter(self._commands.items())) except StopIteration: log.exception( "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 _MessageDialogCommand() + return MessageDialogEscapeCode.NONE, _MessageDialogCommand() def __setIcon(self, type: MessageDialogType) -> None: if (iconID := type._wxIconId) is not None: @@ -519,6 +521,7 @@ def _onCloseEvent(self, evt: wx.CloseEvent): log.debug(f"Got {'vetoable' if evt.CanVeto() else 'non-vetoable'} close event.") if not evt.CanVeto(): # We must close the dialog, regardless of state. + self._execute_command(self._getDefaultActionOrFallback()) self._instances.remove(self) self.EndModal(self.GetReturnCode()) self.Destroy() @@ -543,10 +546,17 @@ def _onButton(self, evt: wx.CommandEvent): except KeyError: log.debug(f"No command registered for {id=}.") - def _execute_command(self, id: int, command: _MessageDialogCommand | None = None): + def _execute_command( + self, + id: int, + command: _MessageDialogCommand | None = None, + *, + _canCallClose: bool = True, + ): if command is None: command = self._commands[id] callback, close = command + close &= _canCallClose if callback is not None: if close: self.Hide() From 4114a0a5cdfe190ff8540ac4df5a813d9433f232 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 4 Nov 2024 12:08:05 +1100 Subject: [PATCH 069/209] Added additional setters and tests --- source/gui/messageDialog.py | 23 +++++++- tests/unit/test_messageDialog.py | 90 +++++++++++++++++++++++++++++--- 2 files changed, 103 insertions(+), 10 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index c149b88bdd8..68861165d7c 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -273,7 +273,7 @@ def addButton( closes_dialog=closes_dialog or default_action, ) if default_focus: - button.SetDefault() + self.SetDefaultItem(button) if default_action: self.SetEscapeId(buttonId) self.__isLayoutFullyRealized = False @@ -343,6 +343,25 @@ def addButtons(self, *buttons: DefaultMessageDialogButtons | MessageDialogButton addSaveNoCancelButtons = partialmethod(addButtons, DefaultMessageDialogButtons.SAVE_NO_CANCEL) addSaveNoCancelButtons.__doc__ = "Add Save, Don't save and Cancel buttons to the dialog." + def setDefaultFocus(self, id: MessageDialogReturnCode) -> Self: + if (win := self.FindWindowById(id)) is not None: + self.SetDefaultItem(win) + else: + raise ValueError(f"Unable to find button with {id=}.") + return self + + def SetEscapeId(self, id: MessageDialogReturnCode | MessageDialogEscapeCode) -> Self: + if id not in (MessageDialogEscapeCode.DEFAULT, MessageDialogEscapeCode.NONE): + if id not in self._commands: + raise KeyError(f"No command registered for {id=}.") + if not self._commands[id].closes_dialog: + raise ValueError("Default actions that do not close the dialog are not supported.") + super().SetEscapeId(id) + return self + + def setDefaultAction(self, id: MessageDialogReturnCode | MessageDialogEscapeCode) -> Self: + return self.SetEscapeId(id) + def Show(self) -> bool: """Show a non-blocking dialog. Attach buttons with button handlers""" @@ -479,7 +498,7 @@ def _getDefaultActionOrFallback(self) -> tuple[int, _MessageDialogCommand]: # Default action is unavailable. Try using the default focus instead. try: if (defaultFocus := self.GetDefaultItem()) is not None: - id = defaultFocus.getId() + id = defaultFocus.GetId() return id, self._commands[id] except KeyError: log.exception("Default focus was not in commands. This indicates a logic error.") diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index e93543549b3..126cb370b91 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -230,26 +230,100 @@ def test_defaultAction_defaultEscape_OkCancel(self): """Test that when adding OK and Cancel buttons with default escape code, that the default action is cancel.""" self.dialog.addOkButton(callback=dummyCallback1).addCancelButton(callback=dummyCallback2) id, command = self.dialog._getDefaultAction() - self.assertEqual(id, MessageDialogReturnCode.CANCEL) - self.assertEqual(command.callback, dummyCallback2) + with self.subTest("Test getting the default action."): + self.assertEqual(id, MessageDialogReturnCode.CANCEL) + self.assertEqual(command.callback, dummyCallback2) + with self.subTest( + "Test getting the default action or fallback returns the same as getting the default action.", + ): + self.assertEqual((id, command), self.dialog._getDefaultActionOrFallback()) def test_defaultAction_defaultEscape_CancelOk(self): """Test that when adding cancel and ok buttons with default escape code, that the default action is cancel.""" self.dialog.addCancelButton(callback=dummyCallback2).addOkButton(callback=dummyCallback1) id, command = self.dialog._getDefaultAction() - self.assertEqual(id, MessageDialogReturnCode.CANCEL) - self.assertEqual(command.callback, dummyCallback2) + with self.subTest("Test getting the default action."): + self.assertEqual(id, MessageDialogReturnCode.CANCEL) + self.assertEqual(command.callback, dummyCallback2) + with self.subTest( + "Test getting the default action or fallback returns the same as getting the default action.", + ): + self.assertEqual((id, command), self.dialog._getDefaultActionOrFallback()) def test_defaultAction_defaultEscape_OkClose(self): """Test that when adding OK and Close buttons with default escape code, that the default action is OK.""" self.dialog.addOkButton(callback=dummyCallback1).addCloseButton(callback=dummyCallback2) id, command = self.dialog._getDefaultAction() - self.assertEqual(id, MessageDialogReturnCode.OK) - self.assertEqual(command.callback, dummyCallback1) + with self.subTest("Test getting the default action."): + self.assertEqual(id, MessageDialogReturnCode.OK) + self.assertEqual(command.callback, dummyCallback1) + with self.subTest( + "Test getting the default action or fallback returns the same as getting the default action.", + ): + self.assertEqual((id, command), self.dialog._getDefaultActionOrFallback()) def test_defaultAction_defaultEscape_CloseOk(self): """Test that when adding Close and OK buttons with default escape code, that the default action is OK.""" self.dialog.addCloseButton(callback=dummyCallback2).addOkButton(callback=dummyCallback1) id, command = self.dialog._getDefaultAction() - self.assertEqual(id, MessageDialogReturnCode.OK) - self.assertEqual(command.callback, dummyCallback1) + with self.subTest("Test getting the default action."): + self.assertEqual(id, MessageDialogReturnCode.OK) + self.assertEqual(command.callback, dummyCallback1) + with self.subTest( + "Test getting the default action or fallback returns the same as getting the default action.", + ): + self.assertEqual((id, command), self.dialog._getDefaultActionOrFallback()) + + def test_setDefaultAction_existant_action(self): + self.dialog.addYesNoButtons() + self.dialog.setDefaultAction(MessageDialogReturnCode.YES) + id, command = self.dialog._getDefaultAction() + with self.subTest("Test getting the default action."): + self.assertEqual(id, MessageDialogReturnCode.YES) + self.assertIsNone(command.callback) + with self.subTest( + "Test getting the default action or fallback returns the same as getting the default action.", + ): + self.assertEqual((id, command), self.dialog._getDefaultActionOrFallback()) + + def test_setDefaultAction_nonexistant_action(self): + self.dialog.addYesNoButtons() + with self.assertRaises(KeyError): + self.dialog.setDefaultAction(MessageDialogReturnCode.APPLY) + self.assertEqual(self.dialog._getDefaultAction(), NO_CALLBACK) + + def test_setDefaultAction_nonclosing_action(self): + self.dialog.addOkButton().addApplyButton(closes_dialog=False) + with self.assertRaises(ValueError): + self.dialog.setDefaultAction(MessageDialogReturnCode.APPLY) + + def test_getDefaultActionOrFallback_no_controls(self): + id, command = self.dialog._getDefaultActionOrFallback() + self.assertEqual(id, MessageDialogEscapeCode.NONE) + self.assertIsNotNone(command) + self.assertEqual(command.closes_dialog, True) + + def test_getDefaultActionOrFallback_no_defaultFocus(self): + self.dialog.addApplyButton().addCloseButton() + self.assertIsNone(self.dialog.GetDefaultItem()) + id, command = self.dialog._getDefaultActionOrFallback() + self.assertEqual(id, MessageDialogReturnCode.APPLY) + self.assertIsNotNone(command) + self.assertEqual(command.closes_dialog, True) + + def test_getDefaultActionOrFallback_no_defaultAction(self): + self.dialog.addApplyButton().addCloseButton() + self.dialog.setDefaultFocus(MessageDialogReturnCode.CLOSE) + self.assertEqual(self.dialog.GetDefaultItem().GetId(), MessageDialogReturnCode.CLOSE) + id, command = self.dialog._getDefaultActionOrFallback() + self.assertEqual(id, MessageDialogReturnCode.CLOSE) + self.assertIsNotNone(command) + self.assertEqual(command.closes_dialog, True) + + def test_getDefaultActionOrFallback_custom_defaultAction(self): + self.dialog.addApplyButton().addCloseButton() + self.dialog.setDefaultAction(MessageDialogReturnCode.CLOSE) + id, command = self.dialog._getDefaultActionOrFallback() + self.assertEqual(id, MessageDialogReturnCode.CLOSE) + self.assertIsNotNone(command) + self.assertEqual(command.closes_dialog, True) From b4ff1e1abb7a90bf3503afe29ba4813c64a0ffa4 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 4 Nov 2024 13:19:16 +1100 Subject: [PATCH 070/209] Call default callback when closing programatically --- source/gui/__init__.py | 4 ++-- source/gui/messageDialog.py | 13 +++++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/source/gui/__init__.py b/source/gui/__init__.py index 2d8b4c118da..00b59b5d84e 100644 --- a/source/gui/__init__.py +++ b/source/gui/__init__.py @@ -593,8 +593,8 @@ def onModelessOkCancelDialog(self, evt): "- Exiting NVDA does not cause errors", "Non-modal OK/Cancel Dialog", ) - .addOkButton(callback=lambda _: messageBox("You pressed OK!")) - .addCancelButton(callback=lambda _: messageBox("You pressed Cancel!")) + .addOkButton(callback=lambda: messageBox("You pressed OK!")) + .addCancelButton(callback=lambda: messageBox("You pressed Cancel!")) ) dlg.ShowModal() diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 68861165d7c..2fad9c3ddaa 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -275,7 +275,7 @@ def addButton( if default_focus: self.SetDefaultItem(button) if default_action: - self.SetEscapeId(buttonId) + self.setDefaultAction(buttonId) self.__isLayoutFullyRealized = False return self @@ -540,15 +540,20 @@ def _onCloseEvent(self, evt: wx.CloseEvent): log.debug(f"Got {'vetoable' if evt.CanVeto() else 'non-vetoable'} close event.") if not evt.CanVeto(): # We must close the dialog, regardless of state. - self._execute_command(self._getDefaultActionOrFallback()) + self.Hide() + self._execute_command(*self._getDefaultActionOrFallback()) self._instances.remove(self) 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. - self._execute_command(self._getDefaultAction()) - pass + id, command = self._getDefaultAction() + if id == MessageDialogEscapeCode.NONE or command is None or not command.closes_dialog: + evt.Veto() + return + self.Hide() + self._execute_command(id, command, _canCallClose=False) self.Hide() if self.IsModal(): self.EndModal(self.GetReturnCode()) From a1faa51fae7934553aef407c81db92cf10825553 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 4 Nov 2024 15:37:11 +1100 Subject: [PATCH 071/209] Added docstrings to tests --- tests/unit/test_messageDialog.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 126cb370b91..5b9f55aa16c 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -275,6 +275,7 @@ def test_defaultAction_defaultEscape_CloseOk(self): self.assertEqual((id, command), self.dialog._getDefaultActionOrFallback()) def test_setDefaultAction_existant_action(self): + """Test that setting the default action results in the correct action being returned from both getDefaultAction and getDefaultActionOrFallback.""" self.dialog.addYesNoButtons() self.dialog.setDefaultAction(MessageDialogReturnCode.YES) id, command = self.dialog._getDefaultAction() @@ -287,23 +288,30 @@ def test_setDefaultAction_existant_action(self): self.assertEqual((id, command), self.dialog._getDefaultActionOrFallback()) def test_setDefaultAction_nonexistant_action(self): + """Test that setting the default action to an action that has not been set up results in KeyError, and that a fallback action is returned from getDefaultActionOrFallback.""" self.dialog.addYesNoButtons() - with self.assertRaises(KeyError): - self.dialog.setDefaultAction(MessageDialogReturnCode.APPLY) - self.assertEqual(self.dialog._getDefaultAction(), NO_CALLBACK) + with self.subTest("Test getting the default action."): + with self.assertRaises(KeyError): + self.dialog.setDefaultAction(MessageDialogReturnCode.APPLY) + with self.subTest("Test getting the fallback default action."): + self.assertEqual(self.dialog._getDefaultAction(), NO_CALLBACK) def test_setDefaultAction_nonclosing_action(self): + """Check that setting the default action to an action that does not close the dialog fails with a ValueError.""" self.dialog.addOkButton().addApplyButton(closes_dialog=False) - with self.assertRaises(ValueError): - self.dialog.setDefaultAction(MessageDialogReturnCode.APPLY) + with self.subTest("Test getting the default action."): + with self.assertRaises(ValueError): + self.dialog.setDefaultAction(MessageDialogReturnCode.APPLY) def test_getDefaultActionOrFallback_no_controls(self): + """Test that getDefaultActionOrFallback returns wx.ID_NONE and a close command with no callback when the dialog has no buttons.""" id, command = self.dialog._getDefaultActionOrFallback() self.assertEqual(id, MessageDialogEscapeCode.NONE) self.assertIsNotNone(command) self.assertEqual(command.closes_dialog, True) def test_getDefaultActionOrFallback_no_defaultFocus(self): + """Test that getDefaultActionOrFallback returns the first button when no default action or default focus is specified.""" self.dialog.addApplyButton().addCloseButton() self.assertIsNone(self.dialog.GetDefaultItem()) id, command = self.dialog._getDefaultActionOrFallback() @@ -312,6 +320,7 @@ def test_getDefaultActionOrFallback_no_defaultFocus(self): self.assertEqual(command.closes_dialog, True) def test_getDefaultActionOrFallback_no_defaultAction(self): + """Test that getDefaultActionOrFallback returns the default focus if one is specified but there is no default action.""" self.dialog.addApplyButton().addCloseButton() self.dialog.setDefaultFocus(MessageDialogReturnCode.CLOSE) self.assertEqual(self.dialog.GetDefaultItem().GetId(), MessageDialogReturnCode.CLOSE) @@ -321,6 +330,7 @@ def test_getDefaultActionOrFallback_no_defaultAction(self): self.assertEqual(command.closes_dialog, True) def test_getDefaultActionOrFallback_custom_defaultAction(self): + """Test that getDefaultActionOrFallback returns the custom defaultAction if set.""" self.dialog.addApplyButton().addCloseButton() self.dialog.setDefaultAction(MessageDialogReturnCode.CLOSE) id, command = self.dialog._getDefaultActionOrFallback() From d7518048cbacf9ab901b1650eaa637ec3101dde1 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 4 Nov 2024 16:49:32 +1100 Subject: [PATCH 072/209] Made fallback default action more robust --- source/gui/messageDialog.py | 60 ++++++++++++++++++++------------ tests/unit/test_messageDialog.py | 26 ++++++++++---- 2 files changed, 57 insertions(+), 29 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 2fad9c3ddaa..586ecc90acf 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -487,32 +487,46 @@ def _getDefaultAction(self) -> tuple[int, _MessageDialogCommand | None]: ) def _getDefaultActionOrFallback(self) -> tuple[int, _MessageDialogCommand]: - # Try using the user-specified default action. - try: - id, action = self._getDefaultAction() - if action is not None: - return id, action - except KeyError: - log.exception("Default action was not in commands. This indicates a logic error.") + def getAction() -> tuple[int, _MessageDialogCommand]: + # Try using the user-specified default action. + try: + id, action = self._getDefaultAction() + if action is not None: + return id, action + except KeyError: + log.debug("Default action was not in commands. This indicates a logic error.") - # Default action is unavailable. Try using the default focus instead. - try: - if (defaultFocus := self.GetDefaultItem()) is not None: - id = defaultFocus.GetId() - return id, self._commands[id] - except KeyError: - log.exception("Default focus was not in commands. This indicates a logic error.") + # Default action is unavailable. Try using the default focus instead. + try: + if (defaultFocus := self.GetDefaultItem()) is not None: + id = defaultFocus.GetId() + return id, self._commands[id] + except KeyError: + log.debug("Default focus was not in commands. This indicates a logic error.") - # Default focus is unavailable. Try using the first button instead. - try: - return next(iter(self._commands.items())) - except StopIteration: - log.exception( - "No commands have been registered. If the dialog is shown, this indicates a logic error.", - ) + # Default focus is unavailable. Try using the first registered command that closes the dialog instead. + firstCommand: tuple[int, _MessageDialogCommand] | None = None + for id, command in self._commands.items(): + if command.closes_dialog: + return id, command + if firstCommand is None: + firstCommand = (id, command) + # No commands that close the dialog have been registered. Use the first command instead. + if firstCommand is not None: + return firstCommand + else: + log.debug( + "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 MessageDialogEscapeCode.NONE, _MessageDialogCommand() - # No commands have been registered. Create one of our own. - return MessageDialogEscapeCode.NONE, _MessageDialogCommand() + id, command = getAction() + if not command.closes_dialog: + log.warn(f"Overriding command for {id=} to close dialog.") + command = command._replace(closes_dialog=True) + return id, command def __setIcon(self, type: MessageDialogType) -> None: if (iconID := type._wxIconId) is not None: diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 5b9f55aa16c..8cffdbae90f 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -233,6 +233,7 @@ def test_defaultAction_defaultEscape_OkCancel(self): with self.subTest("Test getting the default action."): self.assertEqual(id, MessageDialogReturnCode.CANCEL) self.assertEqual(command.callback, dummyCallback2) + self.assertTrue(command.closes_dialog) with self.subTest( "Test getting the default action or fallback returns the same as getting the default action.", ): @@ -245,6 +246,7 @@ def test_defaultAction_defaultEscape_CancelOk(self): with self.subTest("Test getting the default action."): self.assertEqual(id, MessageDialogReturnCode.CANCEL) self.assertEqual(command.callback, dummyCallback2) + self.assertTrue(command.closes_dialog) with self.subTest( "Test getting the default action or fallback returns the same as getting the default action.", ): @@ -257,6 +259,7 @@ def test_defaultAction_defaultEscape_OkClose(self): with self.subTest("Test getting the default action."): self.assertEqual(id, MessageDialogReturnCode.OK) self.assertEqual(command.callback, dummyCallback1) + self.assertTrue(command.closes_dialog) with self.subTest( "Test getting the default action or fallback returns the same as getting the default action.", ): @@ -269,6 +272,7 @@ def test_defaultAction_defaultEscape_CloseOk(self): with self.subTest("Test getting the default action."): self.assertEqual(id, MessageDialogReturnCode.OK) self.assertEqual(command.callback, dummyCallback1) + self.assertTrue(command.closes_dialog) with self.subTest( "Test getting the default action or fallback returns the same as getting the default action.", ): @@ -282,6 +286,7 @@ def test_setDefaultAction_existant_action(self): with self.subTest("Test getting the default action."): self.assertEqual(id, MessageDialogReturnCode.YES) self.assertIsNone(command.callback) + self.assertTrue(command.closes_dialog) with self.subTest( "Test getting the default action or fallback returns the same as getting the default action.", ): @@ -308,16 +313,25 @@ def test_getDefaultActionOrFallback_no_controls(self): id, command = self.dialog._getDefaultActionOrFallback() self.assertEqual(id, MessageDialogEscapeCode.NONE) self.assertIsNotNone(command) - self.assertEqual(command.closes_dialog, True) + self.assertTrue(command.closes_dialog) - def test_getDefaultActionOrFallback_no_defaultFocus(self): + def test_getDefaultActionOrFallback_no_defaultFocus_closing_button(self): """Test that getDefaultActionOrFallback returns the first button when no default action or default focus is specified.""" - self.dialog.addApplyButton().addCloseButton() + self.dialog.addApplyButton(closes_dialog=False).addCloseButton() + self.assertIsNone(self.dialog.GetDefaultItem()) + id, command = self.dialog._getDefaultActionOrFallback() + self.assertEqual(id, MessageDialogReturnCode.CLOSE) + self.assertIsNotNone(command) + self.assertTrue(command.closes_dialog) + + def test_getDefaultActionOrFallback_no_defaultFocus_no_closing_button(self): + """Test that getDefaultActionOrFallback returns the first button when no default action or default focus is specified.""" + self.dialog.addApplyButton(closes_dialog=False).addCloseButton(closes_dialog=False) self.assertIsNone(self.dialog.GetDefaultItem()) id, command = self.dialog._getDefaultActionOrFallback() self.assertEqual(id, MessageDialogReturnCode.APPLY) self.assertIsNotNone(command) - self.assertEqual(command.closes_dialog, True) + self.assertTrue(command.closes_dialog) def test_getDefaultActionOrFallback_no_defaultAction(self): """Test that getDefaultActionOrFallback returns the default focus if one is specified but there is no default action.""" @@ -327,7 +341,7 @@ def test_getDefaultActionOrFallback_no_defaultAction(self): id, command = self.dialog._getDefaultActionOrFallback() self.assertEqual(id, MessageDialogReturnCode.CLOSE) self.assertIsNotNone(command) - self.assertEqual(command.closes_dialog, True) + self.assertTrue(command.closes_dialog) def test_getDefaultActionOrFallback_custom_defaultAction(self): """Test that getDefaultActionOrFallback returns the custom defaultAction if set.""" @@ -336,4 +350,4 @@ def test_getDefaultActionOrFallback_custom_defaultAction(self): id, command = self.dialog._getDefaultActionOrFallback() self.assertEqual(id, MessageDialogReturnCode.CLOSE) self.assertIsNotNone(command) - self.assertEqual(command.closes_dialog, True) + self.assertTrue(command.closes_dialog) From abb5a6db27bcc3ae710dd568769f725a0b282c37 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 4 Nov 2024 16:57:00 +1100 Subject: [PATCH 073/209] Renamed several double-underscored variables and methods to be single-underscored --- source/gui/messageDialog.py | 36 ++++++++++++++++---------------- tests/unit/test_messageDialog.py | 12 +++++------ 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 586ecc90acf..6d0664bf1a5 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -194,12 +194,12 @@ def __init__( ): self.helpId = helpId super().__init__(parent, title=title) - self.__isLayoutFullyRealized = False + self._isLayoutFullyRealized = False self._commands: dict[int, _MessageDialogCommand] = {} self._defaultReturnCode: MessageDialogReturnCode | None = None - self.__setIcon(dialogType) - self.__setSound(dialogType) + self._setIcon(dialogType) + self._setSound(dialogType) self.Bind(wx.EVT_SHOW, self._onShowEvt, source=self) self.Bind(wx.EVT_ACTIVATE, self._onDialogActivated, source=self) self.Bind(wx.EVT_CLOSE, self._onCloseEvent) @@ -207,8 +207,8 @@ def __init__( mainSizer = wx.BoxSizer(wx.VERTICAL) contentsSizer = guiHelper.BoxSizerHelper(parent=self, orientation=wx.VERTICAL) - self.__contentsSizer = contentsSizer - self.__mainSizer = mainSizer + self._contentsSizer = contentsSizer + self._mainSizer = mainSizer # Use SetLabelText to avoid ampersands being interpreted as accelerators. text = wx.StaticText(self) @@ -219,7 +219,7 @@ def __init__( buttonHelper = guiHelper.ButtonHelper(wx.HORIZONTAL) contentsSizer.addDialogDismissButtons(buttonHelper) - self.__buttonHelper = buttonHelper + self._buttonHelper = buttonHelper self._addButtons(buttonHelper) if buttons is not None: self.addButtons(*buttons) @@ -263,7 +263,7 @@ def addButton( If multiple buttons with `default=True` are added, the last one added will be the default button. :return: The dialog instance. """ - button = self.__buttonHelper.addButton(*args, **kwargs) + button = self._buttonHelper.addButton(*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) @@ -276,7 +276,7 @@ def addButton( self.SetDefaultItem(button) if default_action: self.setDefaultAction(buttonId) - self.__isLayoutFullyRealized = False + self._isLayoutFullyRealized = False return self @addButton.register @@ -452,13 +452,13 @@ def _addContents(self, contentsSizer: guiHelper.BoxSizerHelper) -> None: # region Internal API def _realize_layout(self) -> None: - if self.__isLayoutFullyRealized: + if self._isLayoutFullyRealized: return if gui._isDebug(): startTime = time.time() log.debug("Laying out message dialog") - self.__mainSizer.Fit(self) - self.__isLayoutFullyRealized = True + self._mainSizer.Fit(self) + self._isLayoutFullyRealized = True if gui._isDebug(): log.debug(f"Layout completed in {time.time() - startTime:.3f} seconds") @@ -528,24 +528,24 @@ def getAction() -> tuple[int, _MessageDialogCommand]: command = command._replace(closes_dialog=True) return id, command - def __setIcon(self, type: MessageDialogType) -> None: + def _setIcon(self, type: MessageDialogType) -> None: if (iconID := type._wxIconId) is not None: icon = wx.ArtProvider.GetIconBundle(iconID, client=wx.ART_MESSAGE_BOX) self.SetIcons(icon) - def __setSound(self, type: MessageDialogType) -> None: - self.__soundID = type._windowsSoundId + def _setSound(self, type: MessageDialogType) -> None: + self._soundID = type._windowsSoundId - def __playSound(self) -> None: - if self.__soundID is not None: - winsound.MessageBeep(self.__soundID) + def _playSound(self) -> None: + if self._soundID is not None: + winsound.MessageBeep(self._soundID) def _onDialogActivated(self, evt: wx.ActivateEvent): evt.Skip() def _onShowEvt(self, evt: wx.ShowEvent): if evt.IsShown(): - self.__playSound() + self._playSound() if (defaultItem := self.GetDefaultItem()) is not None: defaultItem.SetFocus() evt.Skip() diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 8cffdbae90f..cc82fba97d9 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -43,12 +43,12 @@ class Test_MessageDialog_Icons(MDTestBase): def test_setIcon_with_type_with_icon(self, mocked_GetIconBundle: MagicMock): mocked_GetIconBundle.return_value = wx.IconBundle() type = MessageDialogType.ERROR - self.dialog._MessageDialog__setIcon(type) + self.dialog._setIcon(type) mocked_GetIconBundle.assert_called_once() def test_setIcon_with_type_without_icon(self, mocked_GetIconBundle: MagicMock): type = MessageDialogType.STANDARD - self.dialog._MessageDialog__setIcon(type) + self.dialog._setIcon(type) mocked_GetIconBundle.assert_not_called() @@ -58,14 +58,14 @@ class Test_MessageDialog_Sounds(MDTestBase): def test_playSound_with_type_with_Sound(self, mocked_MessageBeep: MagicMock): type = MessageDialogType.ERROR - self.dialog._MessageDialog__setSound(type) - self.dialog._MessageDialog__playSound() + self.dialog._setSound(type) + self.dialog._playSound() mocked_MessageBeep.assert_called_once() def test_playSound_with_type_without_Sound(self, mocked_MessageBeep: MagicMock): type = MessageDialogType.STANDARD - self.dialog._MessageDialog__setSound(type) - self.dialog._MessageDialog__playSound() + self.dialog._setSound(type) + self.dialog._playSound() mocked_MessageBeep.assert_not_called() From 90117c318aa4667b8b72286d6ffa80584f745a9a Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 4 Nov 2024 17:43:14 +1100 Subject: [PATCH 074/209] Added tests and docstrings --- source/gui/messageDialog.py | 4 ++++ tests/unit/test_messageDialog.py | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 6d0664bf1a5..62e49dcf3f6 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -268,6 +268,10 @@ def addButton( buttonId = button.GetId() self.AddMainButtonId(buttonId) # Default actions that do not close the dialog do not make sense. + if default_action and not closes_dialog: + log.warning( + "Default actions that do not close the dialog are not supported. Forcing close_dialog to True.", + ) self._commands[buttonId] = _MessageDialogCommand( callback=callback, closes_dialog=closes_dialog or default_action, diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index cc82fba97d9..daeb527d9d9 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -11,6 +11,7 @@ import wx from gui.messageDialog import ( MessageDialog, + MessageDialogButton, MessageDialogEscapeCode, MessageDialogReturnCode, MessageDialogType, @@ -41,12 +42,14 @@ class Test_MessageDialog_Icons(MDTestBase): """Test that message dialog icons are set correctly.""" def test_setIcon_with_type_with_icon(self, mocked_GetIconBundle: MagicMock): + """Test that setting the dialog's icons has an effect when the dialog's type has icons.""" mocked_GetIconBundle.return_value = wx.IconBundle() type = MessageDialogType.ERROR self.dialog._setIcon(type) mocked_GetIconBundle.assert_called_once() def test_setIcon_with_type_without_icon(self, mocked_GetIconBundle: MagicMock): + """Test that setting the dialog's icons doesn't have an effect when the dialog's type doesn't have icons.""" type = MessageDialogType.STANDARD self.dialog._setIcon(type) mocked_GetIconBundle.assert_not_called() @@ -57,12 +60,14 @@ class Test_MessageDialog_Sounds(MDTestBase): """Test that message dialog sounds are set and played correctly.""" def test_playSound_with_type_with_Sound(self, mocked_MessageBeep: MagicMock): + """Test that sounds are played for message dialogs whose type has an associated sound.""" type = MessageDialogType.ERROR self.dialog._setSound(type) self.dialog._playSound() mocked_MessageBeep.assert_called_once() def test_playSound_with_type_without_Sound(self, mocked_MessageBeep: MagicMock): + """Test that no sounds are played for message dialogs whose type has an associated sound.""" type = MessageDialogType.STANDARD self.dialog._setSound(type) self.dialog._playSound() @@ -224,6 +229,41 @@ def test_addSaveNoCancelButtons(self): with self.subTest("Test default action assignment."): self.assertIsNotNone(self.dialog._getDefaultAction()) + def test_addButton_with_default_focus(self): + """Test adding a button with default focus.""" + self.dialog.addButton( + MessageDialogButton(label="Custom", id=MessageDialogReturnCode.CUSTOM_1, default_focus=True), + ) + self.assertEqual(self.dialog.GetDefaultItem().GetId(), MessageDialogReturnCode.CUSTOM_1) + + def test_addButton_with_default_action(self): + """Test adding a button with default action.""" + self.dialog.addButton( + MessageDialogButton( + label="Custom", + id=MessageDialogReturnCode.CUSTOM_1, + default_action=True, + closes_dialog=True, + ), + ) + id, command = self.dialog._getDefaultAction() + self.assertEqual(id, MessageDialogReturnCode.CUSTOM_1) + self.assertTrue(command.closes_dialog) + + def test_addButton_with_non_closing_default_action(self): + """Test adding a button with default action that does not close the dialog.""" + self.dialog.addButton( + MessageDialogButton( + label="Custom", + id=MessageDialogReturnCode.CUSTOM_1, + default_action=True, + closes_dialog=False, + ), + ) + id, command = self.dialog._getDefaultAction() + self.assertEqual(id, MessageDialogReturnCode.CUSTOM_1) + self.assertTrue(command.closes_dialog) + class Test_MessageDialog_DefaultAction(MDTestBase): def test_defaultAction_defaultEscape_OkCancel(self): From 734703f47831462f4f030fdbc58e84184842cdc0 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 4 Nov 2024 18:57:50 +1100 Subject: [PATCH 075/209] Added lots of docstrings. --- source/gui/messageDialog.py | 104 ++++++++++++++++++++++++++++++------ 1 file changed, 89 insertions(+), 15 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 62e49dcf3f6..f944409fd4b 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -23,7 +23,7 @@ # TODO: Change to type statement when Python 3.12 or later is in use. -MessageDialogCallback: TypeAlias = Callable[[], Any] +MessageDialogCallback_T: TypeAlias = Callable[[], Any] class MessageDialogReturnCode(IntEnum): @@ -50,7 +50,9 @@ class MessageDialogEscapeCode(IntEnum): NONE = wx.ID_NONE """The escape key should have no effect, and programatically attempting to close the dialog should fail.""" DEFAULT = 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.""" + """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 MessageDialogType(Enum): @@ -95,31 +97,50 @@ class MessageDialogButton(NamedTuple): """A button to add to a message dialog.""" id: MessageDialogReturnCode - """The ID to use for this button.""" + """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: MessageDialogCallback | None = None + callback: MessageDialogCallback_T | None = None """The callback to call when the button is clicked.""" default_focus: bool = False - """Whether this button should be the default button.""" + """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. + """ default_action: bool = False - """Whether this button is the default action. That is, whether pressing escape, the system close button, or programatically closing the dialog, should simulate pressing this button.""" + """Whether this button is the default action. + + The default 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 default action. + MessageDialogEscapeCode.DEFAULT may still result in this button being the default action. + """ closes_dialog: bool = True - """Whether this button should close the dialog when clicked.""" + """Whether this button should close the dialog when clicked. + + :note: Buttons with default_action=True and closes_dialog=False are not supported. + See the documentation of c{MessageDialog} for information on how these buttons are handled. + """ class DefaultMessageDialogButton(MessageDialogButton, Enum): """Default buttons for message dialogs.""" # Translators: An ok button on a message dialog. - OK = MessageDialogButton(id=MessageDialogReturnCode.OK, label=_("OK"), default_focus=True) + OK = MessageDialogButton(id=MessageDialogReturnCode.OK, label=_("OK")) # Translators: A yes button on a message dialog. - YES = MessageDialogButton(id=MessageDialogReturnCode.YES, label=_("&Yes"), default_focus=True) + YES = MessageDialogButton(id=MessageDialogReturnCode.YES, label=_("&Yes")) # Translators: A no button on a message dialog. NO = MessageDialogButton(id=MessageDialogReturnCode.NO, label=_("&No")) # Translators: A cancel button on a message dialog. @@ -127,11 +148,11 @@ class DefaultMessageDialogButton(MessageDialogButton, Enum): # Translators: A save button on a message dialog. SAVE = MessageDialogButton(id=MessageDialogReturnCode.SAVE, label=_("&Save")) # Translators: An apply button on a message dialog. - APPLY = MessageDialogButton(id=MessageDialogReturnCode.APPLY, label=_("&Apply")) + APPLY = MessageDialogButton(id=MessageDialogReturnCode.APPLY, label=_("&Apply"), closes_dialog=False) # Translators: A close button on a message dialog. CLOSE = MessageDialogButton(id=MessageDialogReturnCode.CLOSE, label=_("Close")) # Translators: A help button on a message dialog. - HELP = MessageDialogButton(id=MessageDialogReturnCode.HELP, label=_("Help")) + HELP = MessageDialogButton(id=MessageDialogReturnCode.HELP, label=_("Help"), closes_dialog=False) class DefaultMessageDialogButtons(tuple[DefaultMessageDialogButton], Enum): @@ -159,7 +180,7 @@ class DefaultMessageDialogButtons(tuple[DefaultMessageDialogButton], Enum): class _MessageDialogCommand(NamedTuple): """Internal representation of a command for a message dialog.""" - callback: MessageDialogCallback | None = None + callback: MessageDialogCallback_T | None = None """The callback function to be executed. Defaults to None.""" closes_dialog: bool = True """Indicates whether the dialog should be closed after the command is executed. Defaults to True.""" @@ -196,7 +217,6 @@ def __init__( super().__init__(parent, title=title) self._isLayoutFullyRealized = False self._commands: dict[int, _MessageDialogCommand] = {} - self._defaultReturnCode: MessageDialogReturnCode | None = None self._setIcon(dialogType) self._setSound(dialogType) @@ -348,13 +368,34 @@ def addButtons(self, *buttons: DefaultMessageDialogButtons | MessageDialogButton addSaveNoCancelButtons.__doc__ = "Add Save, Don't save and Cancel buttons to the dialog." def setDefaultFocus(self, id: MessageDialogReturnCode) -> 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.FindWindowById(id)) is not None: self.SetDefaultItem(win) else: - raise ValueError(f"Unable to find button with {id=}.") + raise KeyError(f"Unable to find button with {id=}.") return self def SetEscapeId(self, id: MessageDialogReturnCode | MessageDialogEscapeCode) -> 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 closes_dialog=True. + + The following special values are also supported: + * MessageDialogEscapeCode.NONE: If the dialog should only be closable via presses of internal buttons. + * MessageDialogEscapeCode.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 (MessageDialogEscapeCode.DEFAULT, MessageDialogEscapeCode.NONE): if id not in self._commands: raise KeyError(f"No command registered for {id=}.") @@ -364,6 +405,7 @@ def SetEscapeId(self, id: MessageDialogReturnCode | MessageDialogEscapeCode) -> return self def setDefaultAction(self, id: MessageDialogReturnCode | MessageDialogEscapeCode) -> Self: + """See MessageDialog.SetEscapeId.""" return self.SetEscapeId(id) def Show(self) -> bool: @@ -399,10 +441,11 @@ def ShowModal(self) -> MessageDialogReturnCode: @property def isBlocking(self) -> bool: """Whether or not the dialog is blocking""" - return self.IsModal() and self._defaultReturnCode is None + return self.IsModal() and self.hasDefaultAction @property def hasDefaultAction(self) -> bool: + """Whether the dialog has a valid default action.""" escapeId = self.GetEscapeId() return escapeId != MessageDialogEscapeCode.NONE and ( any( @@ -467,6 +510,11 @@ def _realize_layout(self) -> None: log.debug(f"Layout completed in {time.time() - startTime:.3f} seconds") def _getDefaultAction(self) -> tuple[int, _MessageDialogCommand | None]: + """Get the default action of this dialog. + + :raises RuntimeError: If attempting to get the default command from commands fails. + :return: The id and command of the default action. + """ escapeId = self.GetEscapeId() if escapeId == MessageDialogEscapeCode.NONE: return escapeId, None @@ -491,6 +539,21 @@ def _getDefaultAction(self) -> tuple[int, _MessageDialogCommand | None]: ) def _getDefaultActionOrFallback(self) -> tuple[int, _MessageDialogCommand]: + """Get a command that is guaranteed to close this dialog. + + Commands are returned in the following order of preference: + + 1. The developer-set default 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=MessageDialogEscapeCode.NONE and no callback. + + In all cases, if the command has `closes_dialog=False`, this will be overridden to `True` in the returned copy. + + :return: Id and command of the default command. + """ + def getAction() -> tuple[int, _MessageDialogCommand]: # Try using the user-specified default action. try: @@ -533,14 +596,17 @@ def getAction() -> tuple[int, _MessageDialogCommand]: return id, command def _setIcon(self, type: MessageDialogType) -> 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: MessageDialogType) -> 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) @@ -595,6 +661,14 @@ def _execute_command( *, _canCallClose: bool = True, ): + """Execute a command on this dialog. + + :param id: ID of the command to execute. + :param command: Command to execute, defaults to None + If None, the command to execute will be looked up in the dialog's registered commands. + :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. + """ if command is None: command = self._commands[id] callback, close = command From a4b708a79a79766232d709d37725f40620d30f16 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 4 Nov 2024 19:06:39 +1100 Subject: [PATCH 076/209] Made a bunch of class names more usable --- source/gui/messageDialog.py | 159 +++++++++++++++---------------- tests/unit/test_messageDialog.py | 70 +++++++------- 2 files changed, 113 insertions(+), 116 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index f944409fd4b..83527326677 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -23,10 +23,10 @@ # TODO: Change to type statement when Python 3.12 or later is in use. -MessageDialogCallback_T: TypeAlias = Callable[[], Any] +Callback_T: TypeAlias = Callable[[], Any] -class MessageDialogReturnCode(IntEnum): +class ReturnCode(IntEnum): """Enumeration of possible returns from c{MessageDialog}.""" OK = wx.ID_OK @@ -44,7 +44,7 @@ class MessageDialogReturnCode(IntEnum): CUSTOM_5 = wx.ID_HIGHEST + 5 -class MessageDialogEscapeCode(IntEnum): +class EscapeCode(IntEnum): """Enumeration of the behavior of the escape key and programmatic attempts to close a c{MessageDialog}.""" NONE = wx.ID_NONE @@ -55,7 +55,7 @@ class MessageDialogEscapeCode(IntEnum): """ -class MessageDialogType(Enum): +class DialogType(Enum): """Types of message dialogs. These are used to determine the icon and sound to play when the dialog is shown. """ @@ -93,10 +93,10 @@ def _windowsSoundId(self) -> int | None: return None -class MessageDialogButton(NamedTuple): +class Button(NamedTuple): """A button to add to a message dialog.""" - id: MessageDialogReturnCode + id: ReturnCode """The ID to use for this button. This will be returned after showing the dialog modally. @@ -106,7 +106,7 @@ class MessageDialogButton(NamedTuple): label: str """The label to display on the button.""" - callback: MessageDialogCallback_T | None = None + callback: Callback_T | None = None """The callback to call when the button is clicked.""" default_focus: bool = False @@ -123,7 +123,7 @@ class MessageDialogButton(NamedTuple): It is also called when programatically closing the dialog, such as when shutting down NVDA. :note: This only sets whether to override the default action. - MessageDialogEscapeCode.DEFAULT may still result in this button being the default action. + EscapeCode.DEFAULT may still result in this button being the default action. """ closes_dialog: bool = True @@ -134,53 +134,53 @@ class MessageDialogButton(NamedTuple): """ -class DefaultMessageDialogButton(MessageDialogButton, Enum): +class DefaultButton(Button, Enum): """Default buttons for message dialogs.""" # Translators: An ok button on a message dialog. - OK = MessageDialogButton(id=MessageDialogReturnCode.OK, label=_("OK")) + OK = Button(id=ReturnCode.OK, label=_("OK")) # Translators: A yes button on a message dialog. - YES = MessageDialogButton(id=MessageDialogReturnCode.YES, label=_("&Yes")) + YES = Button(id=ReturnCode.YES, label=_("&Yes")) # Translators: A no button on a message dialog. - NO = MessageDialogButton(id=MessageDialogReturnCode.NO, label=_("&No")) + NO = Button(id=ReturnCode.NO, label=_("&No")) # Translators: A cancel button on a message dialog. - CANCEL = MessageDialogButton(id=MessageDialogReturnCode.CANCEL, label=_("Cancel")) + CANCEL = Button(id=ReturnCode.CANCEL, label=_("Cancel")) # Translators: A save button on a message dialog. - SAVE = MessageDialogButton(id=MessageDialogReturnCode.SAVE, label=_("&Save")) + SAVE = Button(id=ReturnCode.SAVE, label=_("&Save")) # Translators: An apply button on a message dialog. - APPLY = MessageDialogButton(id=MessageDialogReturnCode.APPLY, label=_("&Apply"), closes_dialog=False) + APPLY = Button(id=ReturnCode.APPLY, label=_("&Apply"), closes_dialog=False) # Translators: A close button on a message dialog. - CLOSE = MessageDialogButton(id=MessageDialogReturnCode.CLOSE, label=_("Close")) + CLOSE = Button(id=ReturnCode.CLOSE, label=_("Close")) # Translators: A help button on a message dialog. - HELP = MessageDialogButton(id=MessageDialogReturnCode.HELP, label=_("Help"), closes_dialog=False) + HELP = Button(id=ReturnCode.HELP, label=_("Help"), closes_dialog=False) -class DefaultMessageDialogButtons(tuple[DefaultMessageDialogButton], Enum): +class DefaultButtonSet(tuple[DefaultButton], Enum): OK_CANCEL = ( - DefaultMessageDialogButton.OK, - DefaultMessageDialogButton.CANCEL, + DefaultButton.OK, + DefaultButton.CANCEL, ) YES_NO = ( - DefaultMessageDialogButton.YES, - DefaultMessageDialogButton.NO, + DefaultButton.YES, + DefaultButton.NO, ) YES_NO_CANCEL = ( - DefaultMessageDialogButton.YES, - DefaultMessageDialogButton.NO, - DefaultMessageDialogButton.CANCEL, + DefaultButton.YES, + DefaultButton.NO, + DefaultButton.CANCEL, ) SAVE_NO_CANCEL = ( - DefaultMessageDialogButton.SAVE, + DefaultButton.SAVE, # Translators: A don't save button on a message dialog. - DefaultMessageDialogButton.NO._replace(label=_("Do&n't save")), - DefaultMessageDialogButton.CANCEL, + DefaultButton.NO._replace(label=_("Do&n't save")), + DefaultButton.CANCEL, ) -class _MessageDialogCommand(NamedTuple): +class _Command(NamedTuple): """Internal representation of a command for a message dialog.""" - callback: MessageDialogCallback_T | None = None + callback: Callback_T | None = None """The callback function to be executed. Defaults to None.""" closes_dialog: bool = True """Indicates whether the dialog should be closed after the command is executed. Defaults to True.""" @@ -208,15 +208,15 @@ def __init__( parent: wx.Window | None, message: str, title: str = wx.MessageBoxCaptionStr, - dialogType: MessageDialogType = MessageDialogType.STANDARD, + dialogType: DialogType = DialogType.STANDARD, *, - buttons: Iterable[MessageDialogButton] | None = None, + buttons: Iterable[Button] | None = None, helpId: str = "", ): self.helpId = helpId super().__init__(parent, title=title) self._isLayoutFullyRealized = False - self._commands: dict[int, _MessageDialogCommand] = {} + self._commands: dict[int, _Command] = {} self._setIcon(dialogType) self._setSound(dialogType) @@ -292,7 +292,7 @@ def addButton( log.warning( "Default actions that do not close the dialog are not supported. Forcing close_dialog to True.", ) - self._commands[buttonId] = _MessageDialogCommand( + self._commands[buttonId] = _Command( callback=callback, closes_dialog=closes_dialog or default_action, ) @@ -306,14 +306,14 @@ def addButton( @addButton.register def _( self, - button: MessageDialogButton, + button: Button, *args, callback: Callable[[wx.CommandEvent], Any] | None = None, default_focus: bool | None = None, closes_dialog: bool | None = None, **kwargs, ) -> Self: - """Add a c{MessageDialogButton} to the dialog. + """Add a c{Button} to the dialog. :param button: The button to add. :param callback: Override for the callback specified in `button`, defaults to None. @@ -332,24 +332,24 @@ def _( keywords.update(kwargs) return self.addButton(self, *args, **keywords) - addOkButton = partialmethod(addButton, DefaultMessageDialogButton.OK) + addOkButton = partialmethod(addButton, DefaultButton.OK) addOkButton.__doc__ = "Add an OK button to the dialog." - addCancelButton = partialmethod(addButton, DefaultMessageDialogButton.CANCEL) + addCancelButton = partialmethod(addButton, DefaultButton.CANCEL) addCancelButton.__doc__ = "Add a Cancel button to the dialog." - addYesButton = partialmethod(addButton, DefaultMessageDialogButton.YES) + addYesButton = partialmethod(addButton, DefaultButton.YES) addYesButton.__doc__ = "Add a Yes button to the dialog." - addNoButton = partialmethod(addButton, DefaultMessageDialogButton.NO) + addNoButton = partialmethod(addButton, DefaultButton.NO) addNoButton.__doc__ = "Add a No button to the dialog." - addSaveButton = partialmethod(addButton, DefaultMessageDialogButton.SAVE) + addSaveButton = partialmethod(addButton, DefaultButton.SAVE) addSaveButton.__doc__ = "Add a Save button to the dialog." - addApplyButton = partialmethod(addButton, DefaultMessageDialogButton.APPLY) + addApplyButton = partialmethod(addButton, DefaultButton.APPLY) addApplyButton.__doc__ = "Add an Apply button to the dialog." - addCloseButton = partialmethod(addButton, DefaultMessageDialogButton.CLOSE) + addCloseButton = partialmethod(addButton, DefaultButton.CLOSE) addCloseButton.__doc__ = "Add a Close button to the dialog." - addHelpButton = partialmethod(addButton, DefaultMessageDialogButton.HELP) + addHelpButton = partialmethod(addButton, DefaultButton.HELP) addHelpButton.__doc__ = "Add a Help button to the dialog." - def addButtons(self, *buttons: DefaultMessageDialogButtons | MessageDialogButton) -> Self: + def addButtons(self, *buttons: DefaultButtonSet | Button) -> Self: """Add multiple buttons to the dialog. :return: The dialog instance. @@ -358,16 +358,16 @@ def addButtons(self, *buttons: DefaultMessageDialogButtons | MessageDialogButton self.addButton(button) return self - addOkCancelButtons = partialmethod(addButtons, DefaultMessageDialogButtons.OK_CANCEL) + addOkCancelButtons = partialmethod(addButtons, DefaultButtonSet.OK_CANCEL) addOkCancelButtons.__doc__ = "Add OK and Cancel buttons to the dialog." - addYesNoButtons = partialmethod(addButtons, DefaultMessageDialogButtons.YES_NO) + addYesNoButtons = partialmethod(addButtons, DefaultButtonSet.YES_NO) addYesNoButtons.__doc__ = "Add Yes and No buttons to the dialog." - addYesNoCancelButtons = partialmethod(addButtons, DefaultMessageDialogButtons.YES_NO_CANCEL) + addYesNoCancelButtons = partialmethod(addButtons, DefaultButtonSet.YES_NO_CANCEL) addYesNoCancelButtons.__doc__ = "Add Yes, No and Cancel buttons to the dialog." - addSaveNoCancelButtons = partialmethod(addButtons, DefaultMessageDialogButtons.SAVE_NO_CANCEL) + addSaveNoCancelButtons = partialmethod(addButtons, DefaultButtonSet.SAVE_NO_CANCEL) addSaveNoCancelButtons.__doc__ = "Add Save, Don't save and Cancel buttons to the dialog." - def setDefaultFocus(self, id: MessageDialogReturnCode) -> 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. @@ -380,7 +380,7 @@ def setDefaultFocus(self, id: MessageDialogReturnCode) -> Self: raise KeyError(f"Unable to find button with {id=}.") return self - def SetEscapeId(self, id: MessageDialogReturnCode | MessageDialogEscapeCode) -> 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. @@ -388,15 +388,15 @@ def SetEscapeId(self, id: MessageDialogReturnCode | MessageDialogEscapeCode) -> The action should have closes_dialog=True. The following special values are also supported: - * MessageDialogEscapeCode.NONE: If the dialog should only be closable via presses of internal buttons. - * MessageDialogEscapeCode.DEFAULT: If the cancel or affirmative (usually OK) button should be used. + * 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 (MessageDialogEscapeCode.DEFAULT, MessageDialogEscapeCode.NONE): + if id not in (EscapeCode.DEFAULT, EscapeCode.NONE): if id not in self._commands: raise KeyError(f"No command registered for {id=}.") if not self._commands[id].closes_dialog: @@ -404,7 +404,7 @@ def SetEscapeId(self, id: MessageDialogReturnCode | MessageDialogEscapeCode) -> super().SetEscapeId(id) return self - def setDefaultAction(self, id: MessageDialogReturnCode | MessageDialogEscapeCode) -> Self: + def setDefaultAction(self, id: ReturnCode | EscapeCode) -> Self: """See MessageDialog.SetEscapeId.""" return self.SetEscapeId(id) @@ -421,7 +421,7 @@ def Show(self) -> bool: self._instances.append(self) return ret - def ShowModal(self) -> MessageDialogReturnCode: + def ShowModal(self) -> ReturnCode: """Show a blocking dialog. Attach buttons with button handlers""" if not self.GetMainButtonIds(): @@ -447,12 +447,9 @@ def isBlocking(self) -> bool: def hasDefaultAction(self) -> bool: """Whether the dialog has a valid default action.""" escapeId = self.GetEscapeId() - return escapeId != MessageDialogEscapeCode.NONE and ( - any( - command in (MessageDialogReturnCode.CANCEL, MessageDialogReturnCode.OK) - for command in self._commands - ) - if escapeId == MessageDialogEscapeCode.DEFAULT + return escapeId != EscapeCode.NONE and ( + any(command in (ReturnCode.CANCEL, ReturnCode.OK) for command in self._commands) + if escapeId == EscapeCode.DEFAULT else True ) @@ -509,25 +506,25 @@ def _realize_layout(self) -> None: if gui._isDebug(): log.debug(f"Layout completed in {time.time() - startTime:.3f} seconds") - def _getDefaultAction(self) -> tuple[int, _MessageDialogCommand | None]: + def _getDefaultAction(self) -> tuple[int, _Command | None]: """Get the default action of this dialog. :raises RuntimeError: If attempting to get the default command from commands fails. :return: The id and command of the default action. """ escapeId = self.GetEscapeId() - if escapeId == MessageDialogEscapeCode.NONE: + if escapeId == EscapeCode.NONE: return escapeId, None - elif escapeId == MessageDialogEscapeCode.DEFAULT: - affirmativeAction: _MessageDialogCommand | None = None + elif escapeId == EscapeCode.DEFAULT: + affirmativeAction: _Command | None = None affirmativeId: int = self.GetAffirmativeId() for id, command in self._commands.items(): - if id == MessageDialogReturnCode.CANCEL: + if id == ReturnCode.CANCEL: return id, command elif id == affirmativeId: affirmativeAction = command if affirmativeAction is None: - return MessageDialogEscapeCode.NONE, None + return EscapeCode.NONE, None else: return affirmativeId, affirmativeAction else: @@ -538,7 +535,7 @@ def _getDefaultAction(self) -> tuple[int, _MessageDialogCommand | None]: f"Escape ID {escapeId} is not associated with a command", ) - def _getDefaultActionOrFallback(self) -> tuple[int, _MessageDialogCommand]: + def _getDefaultActionOrFallback(self) -> tuple[int, _Command]: """Get a command that is guaranteed to close this dialog. Commands are returned in the following order of preference: @@ -547,14 +544,14 @@ def _getDefaultActionOrFallback(self) -> tuple[int, _MessageDialogCommand]: 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=MessageDialogEscapeCode.NONE and no callback. + 5. A new action, with id=EscapeCode.NONE and no callback. In all cases, if the command has `closes_dialog=False`, this will be overridden to `True` in the returned copy. :return: Id and command of the default command. """ - def getAction() -> tuple[int, _MessageDialogCommand]: + def getAction() -> tuple[int, _Command]: # Try using the user-specified default action. try: id, action = self._getDefaultAction() @@ -572,7 +569,7 @@ def getAction() -> tuple[int, _MessageDialogCommand]: log.debug("Default focus was not in commands. This indicates a logic error.") # Default focus is unavailable. Try using the first registered command that closes the dialog instead. - firstCommand: tuple[int, _MessageDialogCommand] | None = None + firstCommand: tuple[int, _Command] | None = None for id, command in self._commands.items(): if command.closes_dialog: return id, command @@ -587,7 +584,7 @@ def getAction() -> tuple[int, _MessageDialogCommand]: ) # No commands have been registered. Create one of our own. - return MessageDialogEscapeCode.NONE, _MessageDialogCommand() + return EscapeCode.NONE, _Command() id, command = getAction() if not command.closes_dialog: @@ -595,13 +592,13 @@ def getAction() -> tuple[int, _MessageDialogCommand]: command = command._replace(closes_dialog=True) return id, command - def _setIcon(self, type: MessageDialogType) -> None: + 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: MessageDialogType) -> None: + def _setSound(self, type: DialogType) -> None: """Set the sound to be played when the dialog is shown.""" self._soundID = type._windowsSoundId @@ -633,7 +630,7 @@ def _onCloseEvent(self, evt: wx.CloseEvent): if self.GetReturnCode() == 0: # No button has been pressed, so this must be a close event from elsewhere. id, command = self._getDefaultAction() - if id == MessageDialogEscapeCode.NONE or command is None or not command.closes_dialog: + if id == EscapeCode.NONE or command is None or not command.closes_dialog: evt.Veto() return self.Hide() @@ -657,7 +654,7 @@ def _onButton(self, evt: wx.CommandEvent): def _execute_command( self, id: int, - command: _MessageDialogCommand | None = None, + command: _Command | None = None, *, _canCallClose: bool = True, ): @@ -685,15 +682,15 @@ def _execute_command( def _flattenButtons( - buttons: Iterable[DefaultMessageDialogButtons | MessageDialogButton], -) -> Iterator[MessageDialogButton]: - """Flatten an iterable of c{MessageDialogButton} or c{DefaultMessageDialogButtons} instances into an iterator of c{MessageDialogButton} instances. + buttons: Iterable[DefaultButtonSet | Button], +) -> Iterator[Button]: + """Flatten an iterable of c{Button} or c{DefaultButtonSet} instances into an iterator of c{Button} instances. :param buttons: The iterator of buttons and button sets to flatten. :yield: Each button contained in the input iterator or its children. """ for item in buttons: - if isinstance(item, DefaultMessageDialogButtons): + if isinstance(item, DefaultButtonSet): yield from item else: yield item diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index daeb527d9d9..340ae311b24 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -11,14 +11,14 @@ import wx from gui.messageDialog import ( MessageDialog, - MessageDialogButton, - MessageDialogEscapeCode, - MessageDialogReturnCode, - MessageDialogType, + Button, + EscapeCode, + ReturnCode, + DialogType, ) -NO_CALLBACK = (MessageDialogEscapeCode.NONE, None) +NO_CALLBACK = (EscapeCode.NONE, None) def dummyCallback1(*a): @@ -44,13 +44,13 @@ class Test_MessageDialog_Icons(MDTestBase): def test_setIcon_with_type_with_icon(self, mocked_GetIconBundle: MagicMock): """Test that setting the dialog's icons has an effect when the dialog's type has icons.""" mocked_GetIconBundle.return_value = wx.IconBundle() - type = MessageDialogType.ERROR + type = DialogType.ERROR self.dialog._setIcon(type) mocked_GetIconBundle.assert_called_once() def test_setIcon_with_type_without_icon(self, mocked_GetIconBundle: MagicMock): """Test that setting the dialog's icons doesn't have an effect when the dialog's type doesn't have icons.""" - type = MessageDialogType.STANDARD + type = DialogType.STANDARD self.dialog._setIcon(type) mocked_GetIconBundle.assert_not_called() @@ -61,14 +61,14 @@ class Test_MessageDialog_Sounds(MDTestBase): def test_playSound_with_type_with_Sound(self, mocked_MessageBeep: MagicMock): """Test that sounds are played for message dialogs whose type has an associated sound.""" - type = MessageDialogType.ERROR + type = DialogType.ERROR self.dialog._setSound(type) self.dialog._playSound() mocked_MessageBeep.assert_called_once() def test_playSound_with_type_without_Sound(self, mocked_MessageBeep: MagicMock): """Test that no sounds are played for message dialogs whose type has an associated sound.""" - type = MessageDialogType.STANDARD + type = DialogType.STANDARD self.dialog._setSound(type) self.dialog._playSound() mocked_MessageBeep.assert_not_called() @@ -86,7 +86,7 @@ def test_addOkButton(self): self.assertTrue(self.dialog.hasDefaultAction) with self.subTest("Test default action assignment."): id, callback = self.dialog._getDefaultAction() - self.assertEqual(id, MessageDialogReturnCode.OK) + self.assertEqual(id, ReturnCode.OK) self.assertIsNotNone(callback) def test_addCancelButton(self): @@ -100,7 +100,7 @@ def test_addCancelButton(self): self.assertTrue(self.dialog.hasDefaultAction) with self.subTest("Test default action assignment."): id, callback = self.dialog._getDefaultAction() - self.assertEqual(id, MessageDialogReturnCode.CANCEL) + self.assertEqual(id, ReturnCode.CANCEL) self.assertIsNotNone(callback) def test_addYesButton(self): @@ -232,36 +232,36 @@ def test_addSaveNoCancelButtons(self): def test_addButton_with_default_focus(self): """Test adding a button with default focus.""" self.dialog.addButton( - MessageDialogButton(label="Custom", id=MessageDialogReturnCode.CUSTOM_1, default_focus=True), + Button(label="Custom", id=ReturnCode.CUSTOM_1, default_focus=True), ) - self.assertEqual(self.dialog.GetDefaultItem().GetId(), MessageDialogReturnCode.CUSTOM_1) + self.assertEqual(self.dialog.GetDefaultItem().GetId(), ReturnCode.CUSTOM_1) def test_addButton_with_default_action(self): """Test adding a button with default action.""" self.dialog.addButton( - MessageDialogButton( + Button( label="Custom", - id=MessageDialogReturnCode.CUSTOM_1, + id=ReturnCode.CUSTOM_1, default_action=True, closes_dialog=True, ), ) id, command = self.dialog._getDefaultAction() - self.assertEqual(id, MessageDialogReturnCode.CUSTOM_1) + self.assertEqual(id, ReturnCode.CUSTOM_1) self.assertTrue(command.closes_dialog) def test_addButton_with_non_closing_default_action(self): """Test adding a button with default action that does not close the dialog.""" self.dialog.addButton( - MessageDialogButton( + Button( label="Custom", - id=MessageDialogReturnCode.CUSTOM_1, + id=ReturnCode.CUSTOM_1, default_action=True, closes_dialog=False, ), ) id, command = self.dialog._getDefaultAction() - self.assertEqual(id, MessageDialogReturnCode.CUSTOM_1) + self.assertEqual(id, ReturnCode.CUSTOM_1) self.assertTrue(command.closes_dialog) @@ -271,7 +271,7 @@ def test_defaultAction_defaultEscape_OkCancel(self): self.dialog.addOkButton(callback=dummyCallback1).addCancelButton(callback=dummyCallback2) id, command = self.dialog._getDefaultAction() with self.subTest("Test getting the default action."): - self.assertEqual(id, MessageDialogReturnCode.CANCEL) + self.assertEqual(id, ReturnCode.CANCEL) self.assertEqual(command.callback, dummyCallback2) self.assertTrue(command.closes_dialog) with self.subTest( @@ -284,7 +284,7 @@ def test_defaultAction_defaultEscape_CancelOk(self): self.dialog.addCancelButton(callback=dummyCallback2).addOkButton(callback=dummyCallback1) id, command = self.dialog._getDefaultAction() with self.subTest("Test getting the default action."): - self.assertEqual(id, MessageDialogReturnCode.CANCEL) + self.assertEqual(id, ReturnCode.CANCEL) self.assertEqual(command.callback, dummyCallback2) self.assertTrue(command.closes_dialog) with self.subTest( @@ -297,7 +297,7 @@ def test_defaultAction_defaultEscape_OkClose(self): self.dialog.addOkButton(callback=dummyCallback1).addCloseButton(callback=dummyCallback2) id, command = self.dialog._getDefaultAction() with self.subTest("Test getting the default action."): - self.assertEqual(id, MessageDialogReturnCode.OK) + self.assertEqual(id, ReturnCode.OK) self.assertEqual(command.callback, dummyCallback1) self.assertTrue(command.closes_dialog) with self.subTest( @@ -310,7 +310,7 @@ def test_defaultAction_defaultEscape_CloseOk(self): self.dialog.addCloseButton(callback=dummyCallback2).addOkButton(callback=dummyCallback1) id, command = self.dialog._getDefaultAction() with self.subTest("Test getting the default action."): - self.assertEqual(id, MessageDialogReturnCode.OK) + self.assertEqual(id, ReturnCode.OK) self.assertEqual(command.callback, dummyCallback1) self.assertTrue(command.closes_dialog) with self.subTest( @@ -321,10 +321,10 @@ def test_defaultAction_defaultEscape_CloseOk(self): def test_setDefaultAction_existant_action(self): """Test that setting the default action results in the correct action being returned from both getDefaultAction and getDefaultActionOrFallback.""" self.dialog.addYesNoButtons() - self.dialog.setDefaultAction(MessageDialogReturnCode.YES) + self.dialog.setDefaultAction(ReturnCode.YES) id, command = self.dialog._getDefaultAction() with self.subTest("Test getting the default action."): - self.assertEqual(id, MessageDialogReturnCode.YES) + self.assertEqual(id, ReturnCode.YES) self.assertIsNone(command.callback) self.assertTrue(command.closes_dialog) with self.subTest( @@ -337,7 +337,7 @@ def test_setDefaultAction_nonexistant_action(self): self.dialog.addYesNoButtons() with self.subTest("Test getting the default action."): with self.assertRaises(KeyError): - self.dialog.setDefaultAction(MessageDialogReturnCode.APPLY) + self.dialog.setDefaultAction(ReturnCode.APPLY) with self.subTest("Test getting the fallback default action."): self.assertEqual(self.dialog._getDefaultAction(), NO_CALLBACK) @@ -346,12 +346,12 @@ def test_setDefaultAction_nonclosing_action(self): self.dialog.addOkButton().addApplyButton(closes_dialog=False) with self.subTest("Test getting the default action."): with self.assertRaises(ValueError): - self.dialog.setDefaultAction(MessageDialogReturnCode.APPLY) + self.dialog.setDefaultAction(ReturnCode.APPLY) def test_getDefaultActionOrFallback_no_controls(self): """Test that getDefaultActionOrFallback returns wx.ID_NONE and a close command with no callback when the dialog has no buttons.""" id, command = self.dialog._getDefaultActionOrFallback() - self.assertEqual(id, MessageDialogEscapeCode.NONE) + self.assertEqual(id, EscapeCode.NONE) self.assertIsNotNone(command) self.assertTrue(command.closes_dialog) @@ -360,7 +360,7 @@ def test_getDefaultActionOrFallback_no_defaultFocus_closing_button(self): self.dialog.addApplyButton(closes_dialog=False).addCloseButton() self.assertIsNone(self.dialog.GetDefaultItem()) id, command = self.dialog._getDefaultActionOrFallback() - self.assertEqual(id, MessageDialogReturnCode.CLOSE) + self.assertEqual(id, ReturnCode.CLOSE) self.assertIsNotNone(command) self.assertTrue(command.closes_dialog) @@ -369,25 +369,25 @@ def test_getDefaultActionOrFallback_no_defaultFocus_no_closing_button(self): self.dialog.addApplyButton(closes_dialog=False).addCloseButton(closes_dialog=False) self.assertIsNone(self.dialog.GetDefaultItem()) id, command = self.dialog._getDefaultActionOrFallback() - self.assertEqual(id, MessageDialogReturnCode.APPLY) + self.assertEqual(id, ReturnCode.APPLY) self.assertIsNotNone(command) self.assertTrue(command.closes_dialog) def test_getDefaultActionOrFallback_no_defaultAction(self): """Test that getDefaultActionOrFallback returns the default focus if one is specified but there is no default action.""" self.dialog.addApplyButton().addCloseButton() - self.dialog.setDefaultFocus(MessageDialogReturnCode.CLOSE) - self.assertEqual(self.dialog.GetDefaultItem().GetId(), MessageDialogReturnCode.CLOSE) + self.dialog.setDefaultFocus(ReturnCode.CLOSE) + self.assertEqual(self.dialog.GetDefaultItem().GetId(), ReturnCode.CLOSE) id, command = self.dialog._getDefaultActionOrFallback() - self.assertEqual(id, MessageDialogReturnCode.CLOSE) + self.assertEqual(id, ReturnCode.CLOSE) self.assertIsNotNone(command) self.assertTrue(command.closes_dialog) def test_getDefaultActionOrFallback_custom_defaultAction(self): """Test that getDefaultActionOrFallback returns the custom defaultAction if set.""" self.dialog.addApplyButton().addCloseButton() - self.dialog.setDefaultAction(MessageDialogReturnCode.CLOSE) + self.dialog.setDefaultAction(ReturnCode.CLOSE) id, command = self.dialog._getDefaultActionOrFallback() - self.assertEqual(id, MessageDialogReturnCode.CLOSE) + self.assertEqual(id, ReturnCode.CLOSE) self.assertIsNotNone(command) self.assertTrue(command.closes_dialog) From 540b1bcd99d41dba9324522dc5539d3a6fee800d Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 4 Nov 2024 19:16:34 +1100 Subject: [PATCH 077/209] Fixed some type info --- source/gui/messageDialog.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 83527326677..29958e7086a 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -65,7 +65,7 @@ class DialogType(Enum): ERROR = auto() @property - def _wxIconId(self) -> int | None: + 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. @@ -267,7 +267,7 @@ def __init__( def addButton( self, *args, - callback: Callable[[wx.CommandEvent], Any] | None = None, + callback: Callback_T | None = None, default_focus: bool = False, default_action: bool = False, closes_dialog: bool = True, @@ -618,7 +618,6 @@ def _onShowEvt(self, evt: wx.ShowEvent): evt.Skip() def _onCloseEvent(self, evt: wx.CloseEvent): - log.debug(f"Got {'vetoable' if evt.CanVeto() else 'non-vetoable'} close event.") if not evt.CanVeto(): # We must close the dialog, regardless of state. self.Hide() From ed30a0c91b6f7276c1f3451c4a91b4297d32b1fb Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 6 Nov 2024 10:22:13 +1100 Subject: [PATCH 078/209] Added tests for _flatten_buttons --- tests/unit/test_messageDialog.py | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 340ae311b24..c308dbfd223 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -10,11 +10,14 @@ import wx from gui.messageDialog import ( + DefaultButton, + DefaultButtonSet, MessageDialog, Button, EscapeCode, ReturnCode, DialogType, + _flattenButtons, ) @@ -391,3 +394,38 @@ def test_getDefaultActionOrFallback_custom_defaultAction(self): self.assertEqual(id, ReturnCode.CLOSE) self.assertIsNotNone(command) self.assertTrue(command.closes_dialog) + + +class Test_FlattenButtons(unittest.TestCase): + """Tests for the _flattenButtons function.""" + + def test_flatten_single_button(self): + """Test flattening a single button.""" + button = Button(id=ReturnCode.OK, label="OK") + result = list(_flattenButtons([button])) + self.assertEqual(result, [button]) + + def test_flatten_multiple_buttons(self): + """Test flattening multiple buttons.""" + button1 = Button(id=ReturnCode.OK, label="OK") + button2 = Button(id=ReturnCode.CANCEL, label="Cancel") + result = list(_flattenButtons([button1, button2])) + self.assertEqual(result, [button1, button2]) + + def test_flatten_default_button_set(self): + """Test flattening a default button set.""" + result = list(_flattenButtons([DefaultButtonSet.OK_CANCEL])) + expected = [DefaultButton.OK.value, DefaultButton.CANCEL.value] + self.assertEqual(result, expected) + + def test_flatten_mixed_buttons_and_sets(self): + """Test flattening a mix of buttons and default button sets.""" + button = Button(id=ReturnCode.YES, label="Yes") + result = list(_flattenButtons([button, DefaultButtonSet.OK_CANCEL])) + expected = [button, DefaultButton.OK.value, DefaultButton.CANCEL.value] + self.assertEqual(result, expected) + + def test_flatten_empty(self): + """Test flattening an empty iterable.""" + result = list(_flattenButtons([])) + self.assertEqual(result, []) From 7577e9158d49f8628c1ccc10201fae74df9e2b93 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 6 Nov 2024 10:28:48 +1100 Subject: [PATCH 079/209] Made the Show method comply with that of wx.Dialog --- source/gui/messageDialog.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 29958e7086a..37d84f1297c 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -408,14 +408,20 @@ def setDefaultAction(self, id: ReturnCode | EscapeCode) -> Self: """See MessageDialog.SetEscapeId.""" return self.SetEscapeId(id) - def Show(self) -> bool: + def Show(self, show: bool = True) -> bool: """Show a non-blocking dialog. - Attach buttons with button handlers""" + + Attach buttons with button handlers + + :param show: If True, show the dialog. If False, hide it. Defaults to True. + """ + if not show: + return self.Hide() if not self.GetMainButtonIds(): raise RuntimeError("MessageDialogs cannot be shown without buttons.") self._realize_layout() log.debug("Showing") - ret = super().Show() + ret = super().Show(show) if ret: log.debug("Adding to instances") self._instances.append(self) From e7b5f602d3f625d86e8f79ffedce9e75443d5e8e Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 6 Nov 2024 12:14:19 +1100 Subject: [PATCH 080/209] Documentation improvements --- source/gui/messageDialog.py | 47 ++++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 37d84f1297c..1156838b7de 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -266,6 +266,8 @@ def __init__( @singledispatchmethod def addButton( self, + id: ReturnCode, + label: str, *args, callback: Callback_T | None = None, default_focus: bool = False, @@ -275,15 +277,20 @@ def addButton( ) -> Self: """Add a button to the dialog. - Any additional arguments are passed to `ButtonHelper.addButton`. - + :param id: The ID to use for the button. + If the dialog is to be shown modally, this will also be the return value if the dialog is closed with this button. + :param label: Text label to show on this button. :param callback: Function to call when the button is pressed, defaults to None. - :param default_focus: Whether the button should be the default (first focused) button in the dialog, defaults to False. + This is most useful for dialogs that are shown modelessly. + :param default_focus: whether this button should receive focus when the dialog is first opened, defaults to False. + If multiple buttons with `default_focus=True` are added, the last one added will receive initial focus. + :param default_action: Whether or not this button should be the default action for the dialog, defaults to False. + The default 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 `default_action=True` are added, the last one added will be the default action. :param closes_dialog: Whether the button should close the dialog when pressed, defaults to True. - If multiple buttons with `default=True` are added, the last one added will be the default button. - :return: The dialog instance. + :return: The updated instance for chaining. """ - button = self._buttonHelper.addButton(*args, **kwargs) + button = self._buttonHelper.addButton(self, id, *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) @@ -308,29 +315,41 @@ def _( self, button: Button, *args, - callback: Callable[[wx.CommandEvent], Any] | None = None, + label: str | None = None, + callback: Callback_T | None = None, default_focus: bool | None = None, + default_action: bool | None = None, closes_dialog: bool | None = None, **kwargs, ) -> Self: - """Add a c{Button} to the dialog. + """Add a :class:`Button` to the dialog. :param button: The button to add. - :param callback: Override for the callback specified in `button`, defaults to None. - :param default_focus: Override for the default specified in `button`, defaults to None. - If multiple buttons with `default=True` are added, the last one added will be the default button. - :param closes_dialog: Override for `button`'s `closes_dialog` property, defaults to None. - :return: The dialog instance. + :param label: Override for `button.label`, defaults to None. + :param callback: Override for `button.callback`, defaults to None. + :param default_focus: Override for `button.default_focus`, defaults to None. + :param default_action: Override for `button.default_action`, defaults to None + :param closes_dialog: Override for `button.closes_dialog`, defaults to None + :return: Updated dialog instance for chaining. + + .. seealso:: :class:`Button` """ + # We need to pass `id` as a positional argument as `singledispatchmethod` matches on the type of the first argument. + id = button.id keywords = button._asdict() + del keywords["id"] # Guaranteed to exist. + if label is not None: + keywords["label"] = label if default_focus is not None: keywords["default_focus"] = default_focus + if default_action is not None: + keywords["default_action"] = default_action if callback is not None: keywords["callback"] = callback if closes_dialog is not None: keywords["closes_dialog"] = closes_dialog keywords.update(kwargs) - return self.addButton(self, *args, **keywords) + return self.addButton(id, *args, **keywords) addOkButton = partialmethod(addButton, DefaultButton.OK) addOkButton.__doc__ = "Add an OK button to the dialog." From e0917b8b2e9f909cc486c2edc442bd890c11d449 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 6 Nov 2024 16:25:37 +1100 Subject: [PATCH 081/209] Restored behaviour of having an OK button as default --- source/gui/messageDialog.py | 2 +- tests/unit/test_messageDialog.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 1156838b7de..386ce7f3dcf 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -210,7 +210,7 @@ def __init__( title: str = wx.MessageBoxCaptionStr, dialogType: DialogType = DialogType.STANDARD, *, - buttons: Iterable[Button] | None = None, + buttons: Iterable[Button] | None = (DefaultButton.OK,), helpId: str = "", ): self.helpId = helpId diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index c308dbfd223..270648555dc 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -37,7 +37,7 @@ class MDTestBase(unittest.TestCase): def setUp(self) -> None: self.app = wx.App() - self.dialog = MessageDialog(None, "Test dialog") + self.dialog = MessageDialog(None, "Test dialog", buttons=None) @patch.object(wx.ArtProvider, "GetIconBundle") From e422e2660b7c19b343b44cfb2e7a49cb76a76fb8 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 6 Nov 2024 16:57:41 +1100 Subject: [PATCH 082/209] Fixed label not being applied to buttons. --- source/gui/messageDialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 386ce7f3dcf..5f9fbcfe769 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -172,7 +172,7 @@ class DefaultButtonSet(tuple[DefaultButton], Enum): SAVE_NO_CANCEL = ( DefaultButton.SAVE, # Translators: A don't save button on a message dialog. - DefaultButton.NO._replace(label=_("Do&n't save")), + DefaultButton.NO.value._replace(label=_("Do&n't save")), DefaultButton.CANCEL, ) @@ -290,7 +290,7 @@ def addButton( :param closes_dialog: Whether the button should close the dialog when pressed, defaults to True. :return: The updated instance for chaining. """ - button = self._buttonHelper.addButton(self, id, *args, **kwargs) + 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) From 403fed6894b80c5896153cd2e5e40112ba2534d9 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 6 Nov 2024 18:11:46 +1100 Subject: [PATCH 083/209] Made id and button positional-only --- source/gui/messageDialog.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 5f9fbcfe769..830308100fd 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -267,6 +267,7 @@ def __init__( def addButton( self, id: ReturnCode, + /, label: str, *args, callback: Callback_T | None = None, @@ -314,6 +315,7 @@ def addButton( def _( self, button: Button, + /, *args, label: str | None = None, callback: Callback_T | None = None, From dc7b4597f23de1a4a17e6dd0baa8059b6a742adb Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 11 Nov 2024 14:10:42 +1100 Subject: [PATCH 084/209] Rename several button properties to bring them into line with NVDA coding style and make the code more self-documenting. * Rename `default_focus` to `defaultFocus` * Rename `default_action` to `fallbackAction` * Rename `closes_dialog` to `closesDialog` --- source/gui/messageDialog.py | 118 ++++++++--------- tests/unit/test_messageDialog.py | 218 +++++++++++++++---------------- 2 files changed, 168 insertions(+), 168 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 830308100fd..963638acefd 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -109,27 +109,27 @@ class Button(NamedTuple): callback: Callback_T | None = None """The callback to call when the button is clicked.""" - default_focus: bool = False + 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. """ - default_action: bool = False - """Whether this button is the default action. + fallbackAction: bool = False + """Whether this button is the fallback action. - The default action is called when the user presses escape, the title bar close button, or the system menu close item. + 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 default action. - EscapeCode.DEFAULT may still result in this button being the default action. + :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`. """ - closes_dialog: bool = True + closesDialog: bool = True """Whether this button should close the dialog when clicked. - :note: Buttons with default_action=True and closes_dialog=False are not supported. + :note: Buttons with fallbackAction=True and closesDialog=False are not supported. See the documentation of c{MessageDialog} for information on how these buttons are handled. """ @@ -148,11 +148,11 @@ class DefaultButton(Button, Enum): # 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"), closes_dialog=False) + 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"), closes_dialog=False) + HELP = Button(id=ReturnCode.HELP, label=_("Help"), closesDialog=False) class DefaultButtonSet(tuple[DefaultButton], Enum): @@ -182,7 +182,7 @@ class _Command(NamedTuple): callback: Callback_T | None = None """The callback function to be executed. Defaults to None.""" - closes_dialog: bool = True + closesDialog: bool = True """Indicates whether the dialog should be closed after the command is executed. Defaults to True.""" @@ -271,9 +271,9 @@ def addButton( label: str, *args, callback: Callback_T | None = None, - default_focus: bool = False, - default_action: bool = False, - closes_dialog: bool = True, + defaultFocus: bool = False, + fallbackAction: bool = False, + closesDialog: bool = True, **kwargs, ) -> Self: """Add a button to the dialog. @@ -283,30 +283,30 @@ def addButton( :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 modelessly. - :param default_focus: whether this button should receive focus when the dialog is first opened, defaults to False. - If multiple buttons with `default_focus=True` are added, the last one added will receive initial focus. - :param default_action: Whether or not this button should be the default action for the dialog, defaults to False. - The default 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 `default_action=True` are added, the last one added will be the default action. - :param closes_dialog: Whether the button should close the dialog when pressed, defaults to True. + :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. :return: The updated instance for chaining. """ 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) - # Default actions that do not close the dialog do not make sense. - if default_action and not closes_dialog: + # fallback actions that do not close the dialog do not make sense. + if fallbackAction and not closesDialog: log.warning( - "Default actions that do not close the dialog are not supported. Forcing close_dialog to True.", + "fallback actions that do not close the dialog are not supported. Forcing close_dialog to True.", ) self._commands[buttonId] = _Command( callback=callback, - closes_dialog=closes_dialog or default_action, + closesDialog=closesDialog or fallbackAction, ) - if default_focus: + if defaultFocus: self.SetDefaultItem(button) - if default_action: + if fallbackAction: self.setDefaultAction(buttonId) self._isLayoutFullyRealized = False return self @@ -319,9 +319,9 @@ def _( *args, label: str | None = None, callback: Callback_T | None = None, - default_focus: bool | None = None, - default_action: bool | None = None, - closes_dialog: bool | None = None, + defaultFocus: bool | None = None, + fallbackAction: bool | None = None, + closesDialog: bool | None = None, **kwargs, ) -> Self: """Add a :class:`Button` to the dialog. @@ -329,9 +329,9 @@ def _( :param button: The button to add. :param label: Override for `button.label`, defaults to None. :param callback: Override for `button.callback`, defaults to None. - :param default_focus: Override for `button.default_focus`, defaults to None. - :param default_action: Override for `button.default_action`, defaults to None - :param closes_dialog: Override for `button.closes_dialog`, defaults to None + :param defaultFocus: Override for `button.defaultFocus`, defaults to None. + :param fallbackAction: Override for `button.fallbackAction`, defaults to None + :param closesDialog: Override for `button.closesDialog`, defaults to None :return: Updated dialog instance for chaining. .. seealso:: :class:`Button` @@ -342,14 +342,14 @@ def _( del keywords["id"] # Guaranteed to exist. if label is not None: keywords["label"] = label - if default_focus is not None: - keywords["default_focus"] = default_focus - if default_action is not None: - keywords["default_action"] = default_action + if defaultFocus is not None: + keywords["defaultFocus"] = defaultFocus + if fallbackAction is not None: + keywords["fallbackAction"] = fallbackAction if callback is not None: keywords["callback"] = callback - if closes_dialog is not None: - keywords["closes_dialog"] = closes_dialog + if closesDialog is not None: + keywords["closesDialog"] = closesDialog keywords.update(kwargs) return self.addButton(id, *args, **keywords) @@ -406,7 +406,7 @@ def SetEscapeId(self, id: ReturnCode | EscapeCode) -> Self: :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 closes_dialog=True. + 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. @@ -420,8 +420,8 @@ def SetEscapeId(self, id: ReturnCode | EscapeCode) -> Self: if id not in (EscapeCode.DEFAULT, EscapeCode.NONE): if id not in self._commands: raise KeyError(f"No command registered for {id=}.") - if not self._commands[id].closes_dialog: - raise ValueError("Default actions that do not close the dialog are not supported.") + if not self._commands[id].closesDialog: + raise ValueError("fallback actions that do not close the dialog are not supported.") super().SetEscapeId(id) return self @@ -472,7 +472,7 @@ def isBlocking(self) -> bool: @property def hasDefaultAction(self) -> bool: - """Whether the dialog has a valid default action.""" + """Whether the dialog has a valid fallback action.""" escapeId = self.GetEscapeId() return escapeId != EscapeCode.NONE and ( any(command in (ReturnCode.CANCEL, ReturnCode.OK) for command in self._commands) @@ -485,7 +485,7 @@ def hasDefaultAction(self) -> bool: # region Public class methods @classmethod def CloseInstances(cls) -> None: - """Close all dialogs with a default action""" + """Close all dialogs with a fallback action""" for instance in cls._instances: if not instance.isBlocking: instance.Close() @@ -533,11 +533,11 @@ def _realize_layout(self) -> None: if gui._isDebug(): log.debug(f"Layout completed in {time.time() - startTime:.3f} seconds") - def _getDefaultAction(self) -> tuple[int, _Command | None]: - """Get the default action of this dialog. + def _getFallbackAction(self) -> tuple[int, _Command | None]: + """Get the fallback action of this dialog. :raises RuntimeError: If attempting to get the default command from commands fails. - :return: The id and command of the default action. + :return: The id and command of the fallback action. """ escapeId = self.GetEscapeId() if escapeId == EscapeCode.NONE: @@ -562,32 +562,32 @@ def _getDefaultAction(self) -> tuple[int, _Command | None]: f"Escape ID {escapeId} is not associated with a command", ) - def _getDefaultActionOrFallback(self) -> tuple[int, _Command]: + def _getFallbackActionOrFallback(self) -> tuple[int, _Command]: """Get a command that is guaranteed to close this dialog. Commands are returned in the following order of preference: - 1. The developer-set default action. + 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 `closes_dialog=False`, this will be overridden to `True` in the returned copy. + 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() -> tuple[int, _Command]: - # Try using the user-specified default action. + # Try using the developer-specified fallback action. try: - id, action = self._getDefaultAction() + id, action = self._getFallbackAction() if action is not None: return id, action except KeyError: - log.debug("Default action was not in commands. This indicates a logic error.") + log.debug("fallback action was not in commands. This indicates a logic error.") - # Default action is unavailable. Try using the default focus instead. + # fallback action is unavailable. Try using the default focus instead. try: if (defaultFocus := self.GetDefaultItem()) is not None: id = defaultFocus.GetId() @@ -598,7 +598,7 @@ def getAction() -> tuple[int, _Command]: # Default focus is unavailable. Try using the first registered command that closes the dialog instead. firstCommand: tuple[int, _Command] | None = None for id, command in self._commands.items(): - if command.closes_dialog: + if command.closesDialog: return id, command if firstCommand is None: firstCommand = (id, command) @@ -614,9 +614,9 @@ def getAction() -> tuple[int, _Command]: return EscapeCode.NONE, _Command() id, command = getAction() - if not command.closes_dialog: + if not command.closesDialog: log.warn(f"Overriding command for {id=} to close dialog.") - command = command._replace(closes_dialog=True) + command = command._replace(closesDialog=True) return id, command def _setIcon(self, type: DialogType) -> None: @@ -648,15 +648,15 @@ def _onCloseEvent(self, evt: wx.CloseEvent): if not evt.CanVeto(): # We must close the dialog, regardless of state. self.Hide() - self._execute_command(*self._getDefaultActionOrFallback()) + self._execute_command(*self._getFallbackActionOrFallback()) self._instances.remove(self) 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. - id, command = self._getDefaultAction() - if id == EscapeCode.NONE or command is None or not command.closes_dialog: + id, command = self._getFallbackAction() + if id == EscapeCode.NONE or command is None or not command.closesDialog: evt.Veto() return self.Hide() diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 270648555dc..3716d99e448 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -85,10 +85,10 @@ def test_addOkButton(self): self.assertIsInstance(self.dialog.FindWindowById(wx.ID_OK), wx.Button) with self.subTest("Test in main buttons"): self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_OK]) - with self.subTest("Test has default action."): + with self.subTest("Test has fallback action."): self.assertTrue(self.dialog.hasDefaultAction) - with self.subTest("Test default action assignment."): - id, callback = self.dialog._getDefaultAction() + with self.subTest("Test fallback action assignment."): + id, callback = self.dialog._getFallbackAction() self.assertEqual(id, ReturnCode.OK) self.assertIsNotNone(callback) @@ -99,10 +99,10 @@ def test_addCancelButton(self): self.assertIsInstance(self.dialog.FindWindowById(wx.ID_CANCEL), wx.Button) with self.subTest("Test in main buttons"): self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_CANCEL]) - with self.subTest("Test has default action."): + with self.subTest("Test has fallback action."): self.assertTrue(self.dialog.hasDefaultAction) - with self.subTest("Test default action assignment."): - id, callback = self.dialog._getDefaultAction() + with self.subTest("Test fallback action assignment."): + id, callback = self.dialog._getFallbackAction() self.assertEqual(id, ReturnCode.CANCEL) self.assertIsNotNone(callback) @@ -113,10 +113,10 @@ def test_addYesButton(self): self.assertIsInstance(self.dialog.FindWindowById(wx.ID_YES), wx.Button) with self.subTest("Test in main buttons"): self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_YES]) - with self.subTest("Test has default action."): + with self.subTest("Test has fallback action."): self.assertFalse(self.dialog.hasDefaultAction) - with self.subTest("Test default action assignment."): - self.assertEqual(self.dialog._getDefaultAction(), NO_CALLBACK) + with self.subTest("Test fallback action assignment."): + self.assertEqual(self.dialog._getFallbackAction(), NO_CALLBACK) def test_addNoButton(self): """Test adding a No button to the dialog.""" @@ -125,10 +125,10 @@ def test_addNoButton(self): self.assertIsInstance(self.dialog.FindWindowById(wx.ID_NO), wx.Button) with self.subTest("Test in main buttons"): self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_NO]) - with self.subTest("Test has default action."): + with self.subTest("Test has fallback action."): self.assertFalse(self.dialog.hasDefaultAction) - with self.subTest("Test default action assignment."): - self.assertEqual(self.dialog._getDefaultAction(), NO_CALLBACK) + with self.subTest("Test fallback action assignment."): + self.assertEqual(self.dialog._getFallbackAction(), NO_CALLBACK) def test_addSaveButton(self): """Test adding a Save button to the dialog.""" @@ -137,10 +137,10 @@ def test_addSaveButton(self): self.assertIsInstance(self.dialog.FindWindowById(wx.ID_SAVE), wx.Button) with self.subTest("Test in main buttons"): self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_SAVE]) - with self.subTest("Test has default action."): + with self.subTest("Test has fallback action."): self.assertFalse(self.dialog.hasDefaultAction) - with self.subTest("Test default action assignment."): - self.assertEqual(self.dialog._getDefaultAction(), NO_CALLBACK) + with self.subTest("Test fallback action assignment."): + self.assertEqual(self.dialog._getFallbackAction(), NO_CALLBACK) def test_addApplyButton(self): """Test adding an Apply button to the dialog.""" @@ -149,10 +149,10 @@ def test_addApplyButton(self): self.assertIsInstance(self.dialog.FindWindowById(wx.ID_APPLY), wx.Button) with self.subTest("Test in main buttons"): self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_APPLY]) - with self.subTest("Test has default action."): + with self.subTest("Test has fallback action."): self.assertFalse(self.dialog.hasDefaultAction) - with self.subTest("Test default action assignment."): - self.assertEqual(self.dialog._getDefaultAction(), NO_CALLBACK) + with self.subTest("Test fallback action assignment."): + self.assertEqual(self.dialog._getFallbackAction(), NO_CALLBACK) def test_addCloseButton(self): """Test adding a Close button to the dialog.""" @@ -161,10 +161,10 @@ def test_addCloseButton(self): self.assertIsInstance(self.dialog.FindWindowById(wx.ID_CLOSE), wx.Button) with self.subTest("Test in main buttons"): self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_CLOSE]) - with self.subTest("Test has default action."): + with self.subTest("Test has fallback action."): self.assertFalse(self.dialog.hasDefaultAction) - with self.subTest("Test default action assignment."): - self.assertEqual(self.dialog._getDefaultAction(), NO_CALLBACK) + with self.subTest("Test fallback action assignment."): + self.assertEqual(self.dialog._getFallbackAction(), NO_CALLBACK) def test_addHelpButton(self): """Test adding a Help button to the dialog.""" @@ -173,10 +173,10 @@ def test_addHelpButton(self): self.assertIsInstance(self.dialog.FindWindowById(wx.ID_HELP), wx.Button) with self.subTest("Test in main buttons"): self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_HELP]) - with self.subTest("Test has default action."): + with self.subTest("Test has fallback action."): self.assertFalse(self.dialog.hasDefaultAction) - with self.subTest("Test default action assignment."): - self.assertEqual(self.dialog._getDefaultAction(), NO_CALLBACK) + with self.subTest("Test fallback action assignment."): + self.assertEqual(self.dialog._getFallbackAction(), NO_CALLBACK) def test_addOkCancelButtons(self): """Test adding OK and Cancel buttons to the dialog.""" @@ -186,10 +186,10 @@ def test_addOkCancelButtons(self): self.assertIsInstance(self.dialog.FindWindowById(wx.ID_CANCEL), wx.Button) with self.subTest("Test in main buttons"): self.assertCountEqual(self.dialog.GetMainButtonIds(), (wx.ID_OK, wx.ID_CANCEL)) - with self.subTest("Test has default action."): + with self.subTest("Test has fallback action."): self.assertTrue(self.dialog.hasDefaultAction) - with self.subTest("Test default action assignment."): - self.assertIsNotNone(self.dialog._getDefaultAction()) + with self.subTest("Test fallback action assignment."): + self.assertIsNotNone(self.dialog._getFallbackAction()) def test_addYesNoButtons(self): """Test adding Yes and No buttons to the dialog.""" @@ -199,10 +199,10 @@ def test_addYesNoButtons(self): self.assertIsInstance(self.dialog.FindWindowById(wx.ID_NO), wx.Button) with self.subTest("Test in main buttons"): self.assertCountEqual(self.dialog.GetMainButtonIds(), (wx.ID_YES, wx.ID_NO)) - with self.subTest("Test has default action."): + with self.subTest("Test has fallback action."): self.assertFalse(self.dialog.hasDefaultAction) - with self.subTest("Test default action assignment."): - self.assertEqual(self.dialog._getDefaultAction(), NO_CALLBACK) + with self.subTest("Test fallback action assignment."): + self.assertEqual(self.dialog._getFallbackAction(), NO_CALLBACK) def test_addYesNoCancelButtons(self): """Test adding Yes, No and Cancel buttons to the dialog.""" @@ -213,10 +213,10 @@ def test_addYesNoCancelButtons(self): self.assertIsInstance(self.dialog.FindWindowById(wx.ID_CANCEL), wx.Button) with self.subTest("Test in main buttons"): self.assertCountEqual(self.dialog.GetMainButtonIds(), (wx.ID_YES, wx.ID_NO, wx.ID_CANCEL)) - with self.subTest("Test has default action."): + with self.subTest("Test has fallback action."): self.assertTrue(self.dialog.hasDefaultAction) - with self.subTest("Test default action assignment."): - self.assertIsNotNone(self.dialog._getDefaultAction()) + with self.subTest("Test fallback action assignment."): + self.assertIsNotNone(self.dialog._getFallbackAction()) def test_addSaveNoCancelButtons(self): """Test adding Save, Don't save and Cancel buttons to the dialog.""" @@ -227,173 +227,173 @@ def test_addSaveNoCancelButtons(self): self.assertIsInstance(self.dialog.FindWindowById(wx.ID_CANCEL), wx.Button) with self.subTest("Test in main buttons"): self.assertCountEqual(self.dialog.GetMainButtonIds(), (wx.ID_SAVE, wx.ID_NO, wx.ID_CANCEL)) - with self.subTest("Test has default action."): + with self.subTest("Test has fallback action."): self.assertTrue(self.dialog.hasDefaultAction) - with self.subTest("Test default action assignment."): - self.assertIsNotNone(self.dialog._getDefaultAction()) + with self.subTest("Test fallback action assignment."): + self.assertIsNotNone(self.dialog._getFallbackAction()) - def test_addButton_with_default_focus(self): + def test_addButton_with_defaultFocus(self): """Test adding a button with default focus.""" self.dialog.addButton( - Button(label="Custom", id=ReturnCode.CUSTOM_1, default_focus=True), + Button(label="Custom", id=ReturnCode.CUSTOM_1, defaultFocus=True), ) self.assertEqual(self.dialog.GetDefaultItem().GetId(), ReturnCode.CUSTOM_1) - def test_addButton_with_default_action(self): - """Test adding a button with default action.""" + def test_addButton_with_fallbackAction(self): + """Test adding a button with fallback action.""" self.dialog.addButton( Button( label="Custom", id=ReturnCode.CUSTOM_1, - default_action=True, - closes_dialog=True, + fallbackAction=True, + closesDialog=True, ), ) - id, command = self.dialog._getDefaultAction() + id, command = self.dialog._getFallbackAction() self.assertEqual(id, ReturnCode.CUSTOM_1) - self.assertTrue(command.closes_dialog) + self.assertTrue(command.closesDialog) - def test_addButton_with_non_closing_default_action(self): - """Test adding a button with default action that does not close the dialog.""" + def test_addButton_with_non_closing_fallbackAction(self): + """Test adding a button with fallback action that does not close the dialog.""" self.dialog.addButton( Button( label="Custom", id=ReturnCode.CUSTOM_1, - default_action=True, - closes_dialog=False, + fallbackAction=True, + closesDialog=False, ), ) - id, command = self.dialog._getDefaultAction() + id, command = self.dialog._getFallbackAction() self.assertEqual(id, ReturnCode.CUSTOM_1) - self.assertTrue(command.closes_dialog) + self.assertTrue(command.closesDialog) class Test_MessageDialog_DefaultAction(MDTestBase): def test_defaultAction_defaultEscape_OkCancel(self): - """Test that when adding OK and Cancel buttons with default escape code, that the default action is cancel.""" + """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) - id, command = self.dialog._getDefaultAction() - with self.subTest("Test getting the default action."): + id, command = self.dialog._getFallbackAction() + with self.subTest("Test getting the fallback action."): self.assertEqual(id, ReturnCode.CANCEL) self.assertEqual(command.callback, dummyCallback2) - self.assertTrue(command.closes_dialog) + self.assertTrue(command.closesDialog) with self.subTest( - "Test getting the default action or fallback returns the same as getting the default action.", + "Test getting the fallback action or fallback returns the same as getting the fallback action.", ): - self.assertEqual((id, command), self.dialog._getDefaultActionOrFallback()) + self.assertEqual((id, command), self.dialog._getFallbackActionOrFallback()) def test_defaultAction_defaultEscape_CancelOk(self): - """Test that when adding cancel and ok buttons with default escape code, that the default action is cancel.""" + """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) - id, command = self.dialog._getDefaultAction() - with self.subTest("Test getting the default action."): + id, command = self.dialog._getFallbackAction() + with self.subTest("Test getting the fallback action."): self.assertEqual(id, ReturnCode.CANCEL) self.assertEqual(command.callback, dummyCallback2) - self.assertTrue(command.closes_dialog) + self.assertTrue(command.closesDialog) with self.subTest( - "Test getting the default action or fallback returns the same as getting the default action.", + "Test getting the fallback action or fallback returns the same as getting the fallback action.", ): - self.assertEqual((id, command), self.dialog._getDefaultActionOrFallback()) + self.assertEqual((id, command), self.dialog._getFallbackActionOrFallback()) def test_defaultAction_defaultEscape_OkClose(self): - """Test that when adding OK and Close buttons with default escape code, that the default action is OK.""" + """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) - id, command = self.dialog._getDefaultAction() - with self.subTest("Test getting the default action."): + id, command = self.dialog._getFallbackAction() + with self.subTest("Test getting the fallback action."): self.assertEqual(id, ReturnCode.OK) self.assertEqual(command.callback, dummyCallback1) - self.assertTrue(command.closes_dialog) + self.assertTrue(command.closesDialog) with self.subTest( - "Test getting the default action or fallback returns the same as getting the default action.", + "Test getting the fallback action or fallback returns the same as getting the fallback action.", ): - self.assertEqual((id, command), self.dialog._getDefaultActionOrFallback()) + self.assertEqual((id, command), self.dialog._getFallbackActionOrFallback()) def test_defaultAction_defaultEscape_CloseOk(self): - """Test that when adding Close and OK buttons with default escape code, that the default action is OK.""" + """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) - id, command = self.dialog._getDefaultAction() - with self.subTest("Test getting the default action."): + id, command = self.dialog._getFallbackAction() + with self.subTest("Test getting the fallback action."): self.assertEqual(id, ReturnCode.OK) self.assertEqual(command.callback, dummyCallback1) - self.assertTrue(command.closes_dialog) + self.assertTrue(command.closesDialog) with self.subTest( - "Test getting the default action or fallback returns the same as getting the default action.", + "Test getting the fallback action or fallback returns the same as getting the fallback action.", ): - self.assertEqual((id, command), self.dialog._getDefaultActionOrFallback()) + self.assertEqual((id, command), self.dialog._getFallbackActionOrFallback()) def test_setDefaultAction_existant_action(self): - """Test that setting the default action results in the correct action being returned from both getDefaultAction and getDefaultActionOrFallback.""" + """Test that setting the fallback action results in the correct action being returned from both getFallbackAction and getFallbackActionOrFallback.""" self.dialog.addYesNoButtons() self.dialog.setDefaultAction(ReturnCode.YES) - id, command = self.dialog._getDefaultAction() - with self.subTest("Test getting the default action."): + id, command = self.dialog._getFallbackAction() + with self.subTest("Test getting the fallback action."): self.assertEqual(id, ReturnCode.YES) self.assertIsNone(command.callback) - self.assertTrue(command.closes_dialog) + self.assertTrue(command.closesDialog) with self.subTest( - "Test getting the default action or fallback returns the same as getting the default action.", + "Test getting the fallback action or fallback returns the same as getting the fallback action.", ): - self.assertEqual((id, command), self.dialog._getDefaultActionOrFallback()) + self.assertEqual((id, command), self.dialog._getFallbackActionOrFallback()) def test_setDefaultAction_nonexistant_action(self): - """Test that setting the default action to an action that has not been set up results in KeyError, and that a fallback action is returned from getDefaultActionOrFallback.""" + """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 default action."): + with self.subTest("Test getting the fallback action."): with self.assertRaises(KeyError): self.dialog.setDefaultAction(ReturnCode.APPLY) - with self.subTest("Test getting the fallback default action."): - self.assertEqual(self.dialog._getDefaultAction(), NO_CALLBACK) + with self.subTest("Test getting the fallback fallback action."): + self.assertEqual(self.dialog._getFallbackAction(), NO_CALLBACK) def test_setDefaultAction_nonclosing_action(self): - """Check that setting the default action to an action that does not close the dialog fails with a ValueError.""" - self.dialog.addOkButton().addApplyButton(closes_dialog=False) - with self.subTest("Test getting the default action."): + """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 getting the fallback action."): with self.assertRaises(ValueError): self.dialog.setDefaultAction(ReturnCode.APPLY) - def test_getDefaultActionOrFallback_no_controls(self): - """Test that getDefaultActionOrFallback returns wx.ID_NONE and a close command with no callback when the dialog has no buttons.""" - id, command = self.dialog._getDefaultActionOrFallback() + def test_getFallbackActionOrFallback_no_controls(self): + """Test that getFallbackActionOrFallback returns wx.ID_NONE and a close command with no callback when the dialog has no buttons.""" + id, command = self.dialog._getFallbackActionOrFallback() self.assertEqual(id, EscapeCode.NONE) self.assertIsNotNone(command) - self.assertTrue(command.closes_dialog) + self.assertTrue(command.closesDialog) - def test_getDefaultActionOrFallback_no_defaultFocus_closing_button(self): - """Test that getDefaultActionOrFallback returns the first button when no default action or default focus is specified.""" - self.dialog.addApplyButton(closes_dialog=False).addCloseButton() + def test_getFallbackActionOrFallback_no_defaultFocus_closing_button(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()) - id, command = self.dialog._getDefaultActionOrFallback() + id, command = self.dialog._getFallbackActionOrFallback() self.assertEqual(id, ReturnCode.CLOSE) self.assertIsNotNone(command) - self.assertTrue(command.closes_dialog) + self.assertTrue(command.closesDialog) - def test_getDefaultActionOrFallback_no_defaultFocus_no_closing_button(self): - """Test that getDefaultActionOrFallback returns the first button when no default action or default focus is specified.""" - self.dialog.addApplyButton(closes_dialog=False).addCloseButton(closes_dialog=False) + def test_getFallbackActionOrFallback_no_defaultFocus_no_closing_button(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()) - id, command = self.dialog._getDefaultActionOrFallback() + id, command = self.dialog._getFallbackActionOrFallback() self.assertEqual(id, ReturnCode.APPLY) self.assertIsNotNone(command) - self.assertTrue(command.closes_dialog) + self.assertTrue(command.closesDialog) - def test_getDefaultActionOrFallback_no_defaultAction(self): - """Test that getDefaultActionOrFallback returns the default focus if one is specified but there is no default action.""" + def test_getFallbackActionOrFallback_no_defaultAction(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) - id, command = self.dialog._getDefaultActionOrFallback() + id, command = self.dialog._getFallbackActionOrFallback() self.assertEqual(id, ReturnCode.CLOSE) self.assertIsNotNone(command) - self.assertTrue(command.closes_dialog) + self.assertTrue(command.closesDialog) - def test_getDefaultActionOrFallback_custom_defaultAction(self): - """Test that getDefaultActionOrFallback returns the custom defaultAction if set.""" + def test_getFallbackActionOrFallback_custom_defaultAction(self): + """Test that getFallbackActionOrFallback returns the custom defaultAction if set.""" self.dialog.addApplyButton().addCloseButton() self.dialog.setDefaultAction(ReturnCode.CLOSE) - id, command = self.dialog._getDefaultActionOrFallback() + id, command = self.dialog._getFallbackActionOrFallback() self.assertEqual(id, ReturnCode.CLOSE) self.assertIsNotNone(command) - self.assertTrue(command.closes_dialog) + self.assertTrue(command.closesDialog) class Test_FlattenButtons(unittest.TestCase): From bd63423cb9486c0829138fe65e8608fb225ff84f Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 11 Nov 2024 16:58:30 +1100 Subject: [PATCH 085/209] Parameterized tests for the default button adders to make code more DRY --- requirements.txt | 1 + tests/unit/test_messageDialog.py | 226 ++++++++++--------------------- 2 files changed, 74 insertions(+), 153 deletions(-) diff --git a/requirements.txt b/requirements.txt index e5b45abbf8d..dc58f33da61 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,6 +25,7 @@ py2exe==0.13.0.2 # Creating XML unit test reports unittest-xml-reporting==3.2.0 +parameterized==0.9.0 # Building user documentation Markdown==3.6.0 diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 3716d99e448..7322a2f32f1 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -19,6 +19,8 @@ DialogType, _flattenButtons, ) +from parameterized import parameterized +from typing import Iterable, NamedTuple NO_CALLBACK = (EscapeCode.NONE, None) @@ -32,6 +34,13 @@ def dummyCallback2(*a): pass +class AddDefaultButtonHelpersArgList(NamedTuple): + func: str + expectedButtons: Iterable[int] + expectedHasFallback: bool = False + expectedFallbackId: int = wx.ID_NONE + + class MDTestBase(unittest.TestCase): """Base class for test cases testing MessageDialog. Handles wx initialisation.""" @@ -78,159 +87,70 @@ def test_playSound_with_type_without_Sound(self, mocked_MessageBeep: MagicMock): class Test_MessageDialog_Buttons(MDTestBase): - def test_addOkButton(self): - """Test adding an OK button to the dialog.""" - self.dialog.addOkButton() - with self.subTest("Check button types"): - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_OK), wx.Button) - with self.subTest("Test in main buttons"): - self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_OK]) - with self.subTest("Test has fallback action."): - self.assertTrue(self.dialog.hasDefaultAction) - with self.subTest("Test fallback action assignment."): - id, callback = self.dialog._getFallbackAction() - self.assertEqual(id, ReturnCode.OK) - self.assertIsNotNone(callback) - - def test_addCancelButton(self): - """Test adding a Cancel button to the dialog.""" - self.dialog.addCancelButton() - with self.subTest("Check button types"): - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_CANCEL), wx.Button) - with self.subTest("Test in main buttons"): - self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_CANCEL]) - with self.subTest("Test has fallback action."): - self.assertTrue(self.dialog.hasDefaultAction) - with self.subTest("Test fallback action assignment."): - id, callback = self.dialog._getFallbackAction() - self.assertEqual(id, ReturnCode.CANCEL) - self.assertIsNotNone(callback) - - def test_addYesButton(self): - """Test adding a Yes button to the dialog.""" - self.dialog.addYesButton() - with self.subTest("Check button types"): - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_YES), wx.Button) - with self.subTest("Test in main buttons"): - self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_YES]) - with self.subTest("Test has fallback action."): - self.assertFalse(self.dialog.hasDefaultAction) - with self.subTest("Test fallback action assignment."): - self.assertEqual(self.dialog._getFallbackAction(), NO_CALLBACK) - - def test_addNoButton(self): - """Test adding a No button to the dialog.""" - self.dialog.addNoButton() - with self.subTest("Check button types"): - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_NO), wx.Button) - with self.subTest("Test in main buttons"): - self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_NO]) - with self.subTest("Test has fallback action."): - self.assertFalse(self.dialog.hasDefaultAction) - with self.subTest("Test fallback action assignment."): - self.assertEqual(self.dialog._getFallbackAction(), NO_CALLBACK) - - def test_addSaveButton(self): - """Test adding a Save button to the dialog.""" - self.dialog.addSaveButton() - with self.subTest("Check button types"): - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_SAVE), wx.Button) - with self.subTest("Test in main buttons"): - self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_SAVE]) - with self.subTest("Test has fallback action."): - self.assertFalse(self.dialog.hasDefaultAction) - with self.subTest("Test fallback action assignment."): - self.assertEqual(self.dialog._getFallbackAction(), NO_CALLBACK) - - def test_addApplyButton(self): - """Test adding an Apply button to the dialog.""" - self.dialog.addApplyButton() - with self.subTest("Check button types"): - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_APPLY), wx.Button) - with self.subTest("Test in main buttons"): - self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_APPLY]) - with self.subTest("Test has fallback action."): - self.assertFalse(self.dialog.hasDefaultAction) - with self.subTest("Test fallback action assignment."): - self.assertEqual(self.dialog._getFallbackAction(), NO_CALLBACK) - - def test_addCloseButton(self): - """Test adding a Close button to the dialog.""" - self.dialog.addCloseButton() - with self.subTest("Check button types"): - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_CLOSE), wx.Button) - with self.subTest("Test in main buttons"): - self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_CLOSE]) - with self.subTest("Test has fallback action."): - self.assertFalse(self.dialog.hasDefaultAction) - with self.subTest("Test fallback action assignment."): - self.assertEqual(self.dialog._getFallbackAction(), NO_CALLBACK) - - def test_addHelpButton(self): - """Test adding a Help button to the dialog.""" - self.dialog.addHelpButton() - with self.subTest("Check button types"): - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_HELP), wx.Button) - with self.subTest("Test in main buttons"): - self.assertEqual(self.dialog.GetMainButtonIds(), [wx.ID_HELP]) - with self.subTest("Test has fallback action."): - self.assertFalse(self.dialog.hasDefaultAction) - with self.subTest("Test fallback action assignment."): - self.assertEqual(self.dialog._getFallbackAction(), NO_CALLBACK) - - def test_addOkCancelButtons(self): - """Test adding OK and Cancel buttons to the dialog.""" - self.dialog.addOkCancelButtons() - with self.subTest("Check button types"): - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_OK), wx.Button) - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_CANCEL), wx.Button) - with self.subTest("Test in main buttons"): - self.assertCountEqual(self.dialog.GetMainButtonIds(), (wx.ID_OK, wx.ID_CANCEL)) - with self.subTest("Test has fallback action."): - self.assertTrue(self.dialog.hasDefaultAction) - with self.subTest("Test fallback action assignment."): - self.assertIsNotNone(self.dialog._getFallbackAction()) - - def test_addYesNoButtons(self): - """Test adding Yes and No buttons to the dialog.""" - self.dialog.addYesNoButtons() - with self.subTest("Check button types"): - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_YES), wx.Button) - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_NO), wx.Button) - with self.subTest("Test in main buttons"): - self.assertCountEqual(self.dialog.GetMainButtonIds(), (wx.ID_YES, wx.ID_NO)) - with self.subTest("Test has fallback action."): - self.assertFalse(self.dialog.hasDefaultAction) - with self.subTest("Test fallback action assignment."): - self.assertEqual(self.dialog._getFallbackAction(), NO_CALLBACK) - - def test_addYesNoCancelButtons(self): - """Test adding Yes, No and Cancel buttons to the dialog.""" - self.dialog.addYesNoCancelButtons() - with self.subTest("Check button types"): - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_YES), wx.Button) - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_NO), wx.Button) - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_CANCEL), wx.Button) - with self.subTest("Test in main buttons"): - self.assertCountEqual(self.dialog.GetMainButtonIds(), (wx.ID_YES, wx.ID_NO, wx.ID_CANCEL)) - with self.subTest("Test has fallback action."): - self.assertTrue(self.dialog.hasDefaultAction) - with self.subTest("Test fallback action assignment."): - self.assertIsNotNone(self.dialog._getFallbackAction()) - - def test_addSaveNoCancelButtons(self): - """Test adding Save, Don't save and Cancel buttons to the dialog.""" - self.dialog.addSaveNoCancelButtons() - with self.subTest("Check button types"): - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_SAVE), wx.Button) - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_NO), wx.Button) - self.assertIsInstance(self.dialog.FindWindowById(wx.ID_CANCEL), wx.Button) - with self.subTest("Test in main buttons"): - self.assertCountEqual(self.dialog.GetMainButtonIds(), (wx.ID_SAVE, wx.ID_NO, wx.ID_CANCEL)) - with self.subTest("Test has fallback action."): - self.assertTrue(self.dialog.hasDefaultAction) - with self.subTest("Test fallback action assignment."): - self.assertIsNotNone(self.dialog._getFallbackAction()) + @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.hasDefaultAction, expectedHasFallback) + with self.subTest("Test whether getting the fallback action returns the expected id and action type"): + actualFallbackId, actualFallbackAction = self.dialog._getFallbackAction() + self.assertEqual(actualFallbackId, expectedFallbackId) + if expectedHasFallback: + self.assertIsNotNone(actualFallbackAction) + else: + self.assertIsNone(actualFallbackAction) def test_addButton_with_defaultFocus(self): """Test adding a button with default focus.""" From 3efdb8068d1cb999ee0a988446225945c43e25ff Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 13 Nov 2024 15:19:03 +1100 Subject: [PATCH 086/209] Test that buttons are unique when adding --- source/gui/messageDialog.py | 9 +++++ tests/unit/test_messageDialog.py | 57 +++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 963638acefd..5f19249f011 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -291,6 +291,8 @@ def addButton( :param closesDialog: Whether the button should close the dialog when pressed, defaults to True. :return: The updated instance for chaining. """ + if id in self._commands: + raise KeyError("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() @@ -375,6 +377,13 @@ def addButtons(self, *buttons: DefaultButtonSet | Button) -> Self: :return: The dialog instance. """ + uniqueButtons = set(button.id for button in _flattenButtons(buttons)) + flatButtons = tuple(_flattenButtons(buttons)) + if len(uniqueButtons) != len(flatButtons): + raise KeyError("Button IDs must be unique.") + if not uniqueButtons.isdisjoint(self._commands): + raise KeyError("You may not add a new button with an existing id.") + for button in _flattenButtons(buttons): self.addButton(button) return self diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 7322a2f32f1..e6aedd72d7a 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -5,6 +5,7 @@ """Unit tests for the message dialog API.""" +from copy import deepcopy import unittest from unittest.mock import MagicMock, patch @@ -20,7 +21,7 @@ _flattenButtons, ) from parameterized import parameterized -from typing import Iterable, NamedTuple +from typing import Any, Iterable, NamedTuple NO_CALLBACK = (EscapeCode.NONE, None) @@ -34,6 +35,21 @@ 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 (dialog.GetMainButtonIds(),) + (deepcopy(dialog._commands),) + (item.GetId() if (item := dialog.GetDefaultItem()) is not None else None,) + (dialog.GetEscapeId(),) + dialog._isLayoutFullyRealized + + class AddDefaultButtonHelpersArgList(NamedTuple): func: str expectedButtons: Iterable[int] @@ -41,6 +57,12 @@ class AddDefaultButtonHelpersArgList(NamedTuple): expectedFallbackId: int = wx.ID_NONE +class MethodCall(NamedTuple): + name: str + args: tuple[Any] = tuple() + kwargs: dict[str, Any] = dict() + + class MDTestBase(unittest.TestCase): """Base class for test cases testing MessageDialog. Handles wx initialisation.""" @@ -187,6 +209,39 @@ def test_addButton_with_non_closing_fallbackAction(self): self.assertEqual(id, ReturnCode.CUSTOM_1) self.assertTrue(command.closesDialog) + @parameterized.expand( + ( + ( + "buttons_same_id", + MethodCall("addOkButton", kwargs={"callback": dummyCallback1}), + MethodCall("addOkButton", kwargs={"callback": dummyCallback2}), + ), + ( + "Button_then_ButtonSet_containing_same_id", + MethodCall("addOkButton"), + MethodCall("addOkCancelButtons"), + ), + ( + "ButtonSet_then_Button_with_id_from_set", + MethodCall("addOkCancelButtons"), + MethodCall("addOkButton"), + ), + ( + "ButtonSets_containing_same_id", + MethodCall("addOkCancelButtons"), + MethodCall("addYesNoCancelButtons"), + ), + ), + ) + def test_subsequent_add(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)) + class Test_MessageDialog_DefaultAction(MDTestBase): def test_defaultAction_defaultEscape_OkCancel(self): From 67d597056d742fa494d0a983614e0940483a7ed1 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 13 Nov 2024 16:04:25 +1100 Subject: [PATCH 087/209] Changed addButtons to accept an Collection of Buttons, rather than an unpacked tuple of Buttons and DefaultButtonSets --- source/gui/messageDialog.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 5f19249f011..777e147aba4 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -18,7 +18,7 @@ from gui import guiHelper from functools import partialmethod, singledispatchmethod from collections import deque -from collections.abc import Iterable, Iterator, Callable +from collections.abc import Collection, Iterable, Iterator, Callable from logHandler import log @@ -372,18 +372,16 @@ def _( addHelpButton = partialmethod(addButton, DefaultButton.HELP) addHelpButton.__doc__ = "Add a Help button to the dialog." - def addButtons(self, *buttons: DefaultButtonSet | Button) -> Self: + def addButtons(self, buttons: Collection[Button]) -> Self: """Add multiple buttons to the dialog. :return: The dialog instance. """ - uniqueButtons = set(button.id for button in _flattenButtons(buttons)) - flatButtons = tuple(_flattenButtons(buttons)) - if len(uniqueButtons) != len(flatButtons): + buttonIds = set(button.id for button in buttons) + if len(buttonIds) != len(buttons): raise KeyError("Button IDs must be unique.") - if not uniqueButtons.isdisjoint(self._commands): + if not buttonIds.isdisjoint(self._commands): raise KeyError("You may not add a new button with an existing id.") - for button in _flattenButtons(buttons): self.addButton(button) return self From 14016401a9511c794c7b29e3bcc5c7f766461888 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 13 Nov 2024 16:14:56 +1100 Subject: [PATCH 088/209] Remove _flattenButtons --- source/gui/messageDialog.py | 19 ++-------------- tests/unit/test_messageDialog.py | 38 -------------------------------- 2 files changed, 2 insertions(+), 55 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 777e147aba4..b4449006c88 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -18,7 +18,7 @@ from gui import guiHelper from functools import partialmethod, singledispatchmethod from collections import deque -from collections.abc import Collection, Iterable, Iterator, Callable +from collections.abc import Collection, Iterable, Callable from logHandler import log @@ -382,7 +382,7 @@ def addButtons(self, buttons: Collection[Button]) -> Self: 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 _flattenButtons(buttons): + for button in buttons: self.addButton(button) return self @@ -712,18 +712,3 @@ def _execute_command( self.Close() # endregion - - -def _flattenButtons( - buttons: Iterable[DefaultButtonSet | Button], -) -> Iterator[Button]: - """Flatten an iterable of c{Button} or c{DefaultButtonSet} instances into an iterator of c{Button} instances. - - :param buttons: The iterator of buttons and button sets to flatten. - :yield: Each button contained in the input iterator or its children. - """ - for item in buttons: - if isinstance(item, DefaultButtonSet): - yield from item - else: - yield item diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index e6aedd72d7a..bb1508541a8 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -11,14 +11,11 @@ import wx from gui.messageDialog import ( - DefaultButton, - DefaultButtonSet, MessageDialog, Button, EscapeCode, ReturnCode, DialogType, - _flattenButtons, ) from parameterized import parameterized from typing import Any, Iterable, NamedTuple @@ -369,38 +366,3 @@ def test_getFallbackActionOrFallback_custom_defaultAction(self): self.assertEqual(id, ReturnCode.CLOSE) self.assertIsNotNone(command) self.assertTrue(command.closesDialog) - - -class Test_FlattenButtons(unittest.TestCase): - """Tests for the _flattenButtons function.""" - - def test_flatten_single_button(self): - """Test flattening a single button.""" - button = Button(id=ReturnCode.OK, label="OK") - result = list(_flattenButtons([button])) - self.assertEqual(result, [button]) - - def test_flatten_multiple_buttons(self): - """Test flattening multiple buttons.""" - button1 = Button(id=ReturnCode.OK, label="OK") - button2 = Button(id=ReturnCode.CANCEL, label="Cancel") - result = list(_flattenButtons([button1, button2])) - self.assertEqual(result, [button1, button2]) - - def test_flatten_default_button_set(self): - """Test flattening a default button set.""" - result = list(_flattenButtons([DefaultButtonSet.OK_CANCEL])) - expected = [DefaultButton.OK.value, DefaultButton.CANCEL.value] - self.assertEqual(result, expected) - - def test_flatten_mixed_buttons_and_sets(self): - """Test flattening a mix of buttons and default button sets.""" - button = Button(id=ReturnCode.YES, label="Yes") - result = list(_flattenButtons([button, DefaultButtonSet.OK_CANCEL])) - expected = [button, DefaultButton.OK.value, DefaultButton.CANCEL.value] - self.assertEqual(result, expected) - - def test_flatten_empty(self): - """Test flattening an empty iterable.""" - result = list(_flattenButtons([])) - self.assertEqual(result, []) From 3c59af3ba255e0da0f38096729ef957aeaec0a45 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 13 Nov 2024 17:49:56 +1100 Subject: [PATCH 089/209] Fixed some types --- source/gui/messageDialog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index b4449006c88..483fcf6f692 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -210,7 +210,7 @@ def __init__( title: str = wx.MessageBoxCaptionStr, dialogType: DialogType = DialogType.STANDARD, *, - buttons: Iterable[Button] | None = (DefaultButton.OK,), + buttons: Collection[Button] | None = (DefaultButton.OK,), helpId: str = "", ): self.helpId = helpId @@ -242,7 +242,7 @@ def __init__( self._buttonHelper = buttonHelper self._addButtons(buttonHelper) if buttons is not None: - self.addButtons(*buttons) + self.addButtons(buttons) mainSizer.Add( contentsSizer.sizer, @@ -292,7 +292,7 @@ def addButton( :return: The updated instance for chaining. """ if id in self._commands: - raise KeyError("A button with {id=} has already been added.") + 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() From b095d2e094c587f1acb1b7923fcb87ba79e9ef6b Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 13 Nov 2024 17:51:02 +1100 Subject: [PATCH 090/209] Tightened some logic and made the close box enabled only when there is a valid EscapeId --- source/gui/messageDialog.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 483fcf6f692..039c0b690b7 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -215,6 +215,7 @@ def __init__( ): self.helpId = helpId super().__init__(parent, title=title) + self.EnableCloseButton(False) self._isLayoutFullyRealized = False self._commands: dict[int, _Command] = {} @@ -310,6 +311,7 @@ def addButton( self.SetDefaultItem(button) if fallbackAction: self.setDefaultAction(buttonId) + self.EnableCloseButton(self.hasDefaultAction) self._isLayoutFullyRealized = False return self @@ -427,8 +429,9 @@ def SetEscapeId(self, id: ReturnCode | EscapeCode) -> Self: if id not in (EscapeCode.DEFAULT, EscapeCode.NONE): 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.") + if not self._commands[id].closesDialog: + raise ValueError("fallback actions that do not close the dialog are not supported.") + self.EnableCloseButton(id != EscapeCode.NONE) super().SetEscapeId(id) return self @@ -482,7 +485,7 @@ def hasDefaultAction(self) -> bool: """Whether the dialog has a valid fallback action.""" escapeId = self.GetEscapeId() return escapeId != EscapeCode.NONE and ( - any(command in (ReturnCode.CANCEL, ReturnCode.OK) for command in self._commands) + any(id in (ReturnCode.CANCEL, self.GetAffirmativeId()) and command.closesDialog for id, command in self._commands.items()) if escapeId == EscapeCode.DEFAULT else True ) From 904befd8839f051dd5ece0077042e88f6df0c4ba Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 13 Nov 2024 17:51:32 +1100 Subject: [PATCH 091/209] Fixed test dialog --- source/gui/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/source/gui/__init__.py b/source/gui/__init__.py index 00b59b5d84e..673f876516f 100644 --- a/source/gui/__init__.py +++ b/source/gui/__init__.py @@ -592,6 +592,7 @@ def onModelessOkCancelDialog(self, evt): "- You are still able to interact with NVDA's GUI\n" "- Exiting NVDA does not cause errors", "Non-modal OK/Cancel Dialog", + buttons=None ) .addOkButton(callback=lambda: messageBox("You pressed OK!")) .addCancelButton(callback=lambda: messageBox("You pressed Cancel!")) From 59ea918acb81ce651d13d2eb0c629df66f8f648f Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 13 Nov 2024 17:52:59 +1100 Subject: [PATCH 092/209] Formatted --- source/gui/__init__.py | 2 +- source/gui/messageDialog.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/source/gui/__init__.py b/source/gui/__init__.py index 673f876516f..baef1c32003 100644 --- a/source/gui/__init__.py +++ b/source/gui/__init__.py @@ -592,7 +592,7 @@ def onModelessOkCancelDialog(self, evt): "- You are still able to interact with NVDA's GUI\n" "- Exiting NVDA does not cause errors", "Non-modal OK/Cancel Dialog", - buttons=None + buttons=None, ) .addOkButton(callback=lambda: messageBox("You pressed OK!")) .addCancelButton(callback=lambda: messageBox("You pressed Cancel!")) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 039c0b690b7..c6f0b9d22ec 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -18,7 +18,7 @@ from gui import guiHelper from functools import partialmethod, singledispatchmethod from collections import deque -from collections.abc import Collection, Iterable, Callable +from collections.abc import Collection, Callable from logHandler import log @@ -485,7 +485,10 @@ def hasDefaultAction(self) -> bool: """Whether the dialog has a valid fallback action.""" escapeId = self.GetEscapeId() return escapeId != EscapeCode.NONE and ( - any(id in (ReturnCode.CANCEL, self.GetAffirmativeId()) and command.closesDialog for id, command in self._commands.items()) + any( + id in (ReturnCode.CANCEL, self.GetAffirmativeId()) and command.closesDialog + for id, command in self._commands.items() + ) if escapeId == EscapeCode.DEFAULT else True ) From 49c6a2da9c2e3a5629bf2425a2c85281ae30ed59 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 14 Nov 2024 17:26:40 +1100 Subject: [PATCH 093/209] Reimplemented gui.message.messageBox using MessageDialog --- source/gui/message.py | 37 +++++++++--------- source/gui/messageDialog.py | 67 +++++++++++++++++++++++++++++--- tests/unit/test_messageDialog.py | 44 +++++++++++++++++++++ 3 files changed, 123 insertions(+), 25 deletions(-) diff --git a/source/gui/message.py b/source/gui/message.py index 76df44218f9..cfbbc9ef576 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -104,28 +104,27 @@ def messageBox( ), ) from gui import mainFrame + from gui.messageDialog import ( + MessageDialog, + _messageBoxButtonStylesToMessageDialogButtons, + _MessageButtonIconStylesToMessageDialogType, + _messageDialogReturnCodeToMessageBoxReturnCode, + ) 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 + if not core._hasShutdownBeenTriggered: + res = MessageDialog( + parent=parent or mainFrame, + message=message, + title=caption, + dialogType=_MessageButtonIconStylesToMessageDialogType(style), + buttons=_messageBoxButtonStylesToMessageDialogButtons(style), + ).ShowModal() + else: + log.debugWarning("Not displaying message box as shutdown has been triggered.", stack_info=True) + res = wx.CANCEL + return _messageDialogReturnCodeToMessageBoxReturnCode(res) class DisplayableError(Exception): diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index c6f0b9d22ec..7f1f34a16a1 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -253,13 +253,13 @@ def __init__( # mainSizer.Fit(self) self.SetSizer(mainSizer) # Import late to avoid circular import - from gui import mainFrame + # from gui import mainFrame - if parent == 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() + # if parent == 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() # endregion @@ -542,6 +542,13 @@ def _realize_layout(self) -> None: startTime = time.time() log.debug("Laying out message dialog") self._mainSizer.Fit(self) + from gui import mainFrame + + if self.Parent == 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") @@ -718,3 +725,51 @@ def _execute_command( self.Close() # endregion + + +def _messageDialogReturnCodeToMessageBoxReturnCode(returnCode: ReturnCode) -> int: + 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 _MessageButtonIconStylesToMessageDialogType(flags: int) -> 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, ...]: + 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/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index bb1508541a8..8c70698adf4 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -16,6 +16,7 @@ EscapeCode, ReturnCode, DialogType, + _messageBoxButtonStylesToMessageDialogButtons, ) from parameterized import parameterized from typing import Any, Iterable, NamedTuple @@ -366,3 +367,46 @@ def test_getFallbackActionOrFallback_custom_defaultAction(self): self.assertEqual(id, ReturnCode.CLOSE) self.assertIsNotNone(command) self.assertTrue(command.closesDialog) + + +class Test_MessageBox_Shim(unittest.TestCase): + def test_messageBoxButtonStylesToMessageDialogButtons(self): + 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)), + ) From d567f1f723f7f6ff40a5a1427693a8c89967fa38 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 15 Nov 2024 16:22:29 +1100 Subject: [PATCH 094/209] Re-implemented the message box shim --- source/gui/message.py | 18 +++--------------- source/gui/messageDialog.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/source/gui/message.py b/source/gui/message.py index cfbbc9ef576..7e1d3b08493 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -103,28 +103,16 @@ def messageBox( "gui.message.messageBox is deprecated. Use gui.message.MessageDialog instead.", ), ) - from gui import mainFrame - from gui.messageDialog import ( - MessageDialog, - _messageBoxButtonStylesToMessageDialogButtons, - _MessageButtonIconStylesToMessageDialogType, - _messageDialogReturnCodeToMessageBoxReturnCode, - ) + from gui.messageDialog import MessageBoxShim import core from logHandler import log if not core._hasShutdownBeenTriggered: - res = MessageDialog( - parent=parent or mainFrame, - message=message, - title=caption, - dialogType=_MessageButtonIconStylesToMessageDialogType(style), - buttons=_messageBoxButtonStylesToMessageDialogButtons(style), - ).ShowModal() + res = MessageBoxShim().messageBox(message, caption, style, parent) else: log.debugWarning("Not displaying message box as shutdown has been triggered.", stack_info=True) res = wx.CANCEL - return _messageDialogReturnCodeToMessageBoxReturnCode(res) + return res class DisplayableError(Exception): diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 7f1f34a16a1..5cbc4b639d0 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -4,6 +4,8 @@ # For more details see: https://www.gnu.org/licenses/gpl-2.0.html from enum import Enum, IntEnum, auto +import sys +import threading import time from typing import Any, NamedTuple, TypeAlias, Self import winsound @@ -727,6 +729,38 @@ def _execute_command( # endregion +class MessageBoxShim: + def __init__(self): + self.event = threading.Event() + + def messageBox(self, message: str, caption: str, style: int, parent: wx.Window | None): + if wx.IsMainThread(): + self._messageBoxImpl(message, caption, style, parent) + else: + wx.CallAfter(self._messageBoxImpl, message, caption, style, parent) + self.event.wait() + try: + return self.result + except AttributeError: + raise self.exception + + def _messageBoxImpl(self, message: str, caption: str, style: int, parent: wx.Window | None): + from gui import mainFrame + + try: + dialog = MessageDialog( + parent=parent or mainFrame, + message=message, + title=caption, + dialogType=_MessageButtonIconStylesToMessageDialogType(style), + buttons=_messageBoxButtonStylesToMessageDialogButtons(style), + ) + self.result = _messageDialogReturnCodeToMessageBoxReturnCode(dialog.ShowModal()) + except Exception: + self.exception = sys.exception + self.event.set() + + def _messageDialogReturnCodeToMessageBoxReturnCode(returnCode: ReturnCode) -> int: match returnCode: case ReturnCode.YES: From fca7eb8244f10d3d574e4eec6d75f70c1e92bfff Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 15 Nov 2024 17:18:26 +1100 Subject: [PATCH 095/209] Refactored and documented. --- source/gui/messageDialog.py | 160 ++++++++++++++++++++----------- tests/unit/test_messageDialog.py | 6 +- 2 files changed, 106 insertions(+), 60 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 5cbc4b639d0..1c4f3145016 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -730,19 +730,35 @@ def _execute_command( class MessageBoxShim: + """Shim between :fun:`gui.message.messageBox` and :class:`MessageDialog`.""" + def __init__(self): - self.event = threading.Event() + self._event = threading.Event() def messageBox(self, message: str, caption: str, style: int, parent: wx.Window | None): + """Display a message box with the given message, caption, style, and parent window. + + If called from the main thread, the message box is directly shown. + If called from a different thread, the message box is scheduled to be shown on the main thread, and we wait for it to complete. + In either case, the return value of the message box is returned. + + :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, `gui.mainFrame` will be used. + :raises Exception: Any exception raised by attempting to create a message box. + :return: See :fun:`wx.MessageBox`. + """ if wx.IsMainThread(): self._messageBoxImpl(message, caption, style, parent) else: wx.CallAfter(self._messageBoxImpl, message, caption, style, parent) - self.event.wait() + self._event.wait() try: - return self.result + return self._result except AttributeError: - raise self.exception + # If event is True and result is undefined, exception must be an exception. + raise self._exception # type: ignore def _messageBoxImpl(self, message: str, caption: str, style: int, parent: wx.Window | None): from gui import mainFrame @@ -752,58 +768,88 @@ def _messageBoxImpl(self, message: str, caption: str, style: int, parent: wx.Win parent=parent or mainFrame, message=message, title=caption, - dialogType=_MessageButtonIconStylesToMessageDialogType(style), - buttons=_messageBoxButtonStylesToMessageDialogButtons(style), + dialogType=self._iconStylesToDialogType(style), + buttons=self._buttonStylesToButtons(style), ) - self.result = _messageDialogReturnCodeToMessageBoxReturnCode(dialog.ShowModal()) + self._result = self._returnCodeToWxButtonCode(dialog.ShowModal()) except Exception: - self.exception = sys.exception - self.event.set() - - -def _messageDialogReturnCodeToMessageBoxReturnCode(returnCode: ReturnCode) -> int: - 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 _MessageButtonIconStylesToMessageDialogType(flags: int) -> 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, ...]: - 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) + self._exception = sys.exception + # Signal that we've returned. + self._event.set() + + @staticmethod + def _returnCodeToWxButtonCode(returnCode: ReturnCode) -> int: + """Map from an instance of :class:`ReturnCode` to an int as returned by :fun:`wx.MessageBox`. + + Note that only YES, NO, OK, CANCEL and HELP returns are supported by :fun:`wx.MessageBox`, and thus by this function.` + + :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`. + """ + 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}") + + @staticmethod + def _iconStylesToDialogType(flags: int) -> DialogType: + """Map from a bitmask of styles as expected by :fun:`wx.MessageBox` to a :Class:`DialogType`. + + Note that 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`. + + :param flags: Style flags. + :return: Corresponding dialog type. + """ + # 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 + + @staticmethod + def _buttonStylesToButtons(flags: int) -> tuple[Button, ...]: + """Map from a bitmask of styles as expected by :fun:`wx.MessageBox` to a list of :class:`Button`s. + + Note that :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 that 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. + + 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. + """ + 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/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 8c70698adf4..e2d12668a93 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -16,7 +16,7 @@ EscapeCode, ReturnCode, DialogType, - _messageBoxButtonStylesToMessageDialogButtons, + MessageBoxShim, ) from parameterized import parameterized from typing import Any, Iterable, NamedTuple @@ -369,7 +369,7 @@ def test_getFallbackActionOrFallback_custom_defaultAction(self): self.assertTrue(command.closesDialog) -class Test_MessageBox_Shim(unittest.TestCase): +class Test_MessageBoxShim(unittest.TestCase): def test_messageBoxButtonStylesToMessageDialogButtons(self): YES, NO, OK, CANCEL, HELP = wx.YES, wx.NO, wx.OK, wx.CANCEL, wx.HELP outputToInputsMap = { @@ -408,5 +408,5 @@ def test_messageBoxButtonStylesToMessageDialogButtons(self): with self.subTest(flags=input): self.assertCountEqual( expectedOutput, - (button.id for button in _messageBoxButtonStylesToMessageDialogButtons(input)), + (button.id for button in MessageBoxShim._buttonStylesToButtons(input)), ) From 0bcb7dc67facf541c1bf65d14fb582eb3271964e Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 18 Nov 2024 12:43:01 +1100 Subject: [PATCH 096/209] Added a helper function for calling wx functions on the main thread and waiting for their return. --- source/gui/guiHelper.py | 58 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/source/gui/guiHelper.py b/source/gui/guiHelper.py index eb8a05ed1bf..d063a6706d6 100644 --- a/source/gui/guiHelper.py +++ b/source/gui/guiHelper.py @@ -43,11 +43,15 @@ def __init__(self, parent): ... """ +from collections.abc import Callable from contextlib import contextmanager +import sys +import threading import weakref from typing import ( Generic, Optional, + ParamSpec, Type, TypeVar, Union, @@ -458,3 +462,57 @@ class SIPABCMeta(wx.siplib.wrappertype, ABCMeta): """Meta class to be used for wx subclasses with abstract methods.""" pass + + +class _WxCallOnMainResult: + __slots__ = ("result", "exception") + + +# 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. + + Using this function is prefferable over calling :fun:`wx.CallAfter` directly when you care about the return time value of the function. + + :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, and this thread will wait until it returns. + :return: Return value from calling the function with given positional and keyword arguments. + """ + result = _WxCallOnMainResult() + event = threading.Event() + + def functionWrapper(): + print("In wrapper.") + try: + print("Calling function.") + result.result = function(*args, **kwargs) + except Exception: + print("Got an exception.") + result.exception = sys.exception + print("Set event.") + event.set() + print("Out of wrapper.") + + if wx.IsMainThread(): + print("On main thread, calling immediately.") + functionWrapper() + else: + print("In background, using call after.") + wx.CallAfter(functionWrapper) + print("Waiting...") + event.wait() + print("Done waiting.") + try: + return result.result + except AttributeError: + # If result is undefined, exception must be defined. + raise result.exception # type: ignore From 517482641f361e548ed5f036e4baf66cf954d21e Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 18 Nov 2024 12:47:17 +1100 Subject: [PATCH 097/209] Used new wxCallOnMain function foor message box shim --- source/gui/messageDialog.py | 35 ++++++++++------------------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 1c4f3145016..05f87740560 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -4,7 +4,6 @@ # For more details see: https://www.gnu.org/licenses/gpl-2.0.html from enum import Enum, IntEnum, auto -import sys import threading import time from typing import Any, NamedTuple, TypeAlias, Self @@ -16,7 +15,7 @@ from .contextHelp import ContextHelpMixin from .dpiScalingHelper import DpiScalingHelperMixinWithoutInit -from .guiHelper import SIPABCMeta +from .guiHelper import SIPABCMeta, wxCallOnMain from gui import guiHelper from functools import partialmethod, singledispatchmethod from collections import deque @@ -749,33 +748,19 @@ def messageBox(self, message: str, caption: str, style: int, parent: wx.Window | :raises Exception: Any exception raised by attempting to create a message box. :return: See :fun:`wx.MessageBox`. """ - if wx.IsMainThread(): - self._messageBoxImpl(message, caption, style, parent) - else: - wx.CallAfter(self._messageBoxImpl, message, caption, style, parent) - self._event.wait() - try: - return self._result - except AttributeError: - # If event is True and result is undefined, exception must be an exception. - raise self._exception # type: ignore + return wxCallOnMain(self._messageBoxImpl, message, caption, style, parent) def _messageBoxImpl(self, message: str, caption: str, style: int, parent: wx.Window | None): from gui import mainFrame - try: - dialog = MessageDialog( - parent=parent or mainFrame, - message=message, - title=caption, - dialogType=self._iconStylesToDialogType(style), - buttons=self._buttonStylesToButtons(style), - ) - self._result = self._returnCodeToWxButtonCode(dialog.ShowModal()) - except Exception: - self._exception = sys.exception - # Signal that we've returned. - self._event.set() + dialog = MessageDialog( + parent=parent or mainFrame, + message=message, + title=caption, + dialogType=self._iconStylesToDialogType(style), + buttons=self._buttonStylesToButtons(style), + ) + return self._returnCodeToWxButtonCode(dialog.ShowModal()) @staticmethod def _returnCodeToWxButtonCode(returnCode: ReturnCode) -> int: From c24b49e41ed75660835e917a1a84b17518292688 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 18 Nov 2024 14:43:10 +1100 Subject: [PATCH 098/209] Tidied up message box shim --- source/gui/message.py | 6 +- source/gui/messageDialog.py | 187 ++++++++++++++----------------- tests/unit/test_messageDialog.py | 4 +- 3 files changed, 93 insertions(+), 104 deletions(-) diff --git a/source/gui/message.py b/source/gui/message.py index 7e1d3b08493..a27227503ec 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -103,12 +103,14 @@ def messageBox( "gui.message.messageBox is deprecated. Use gui.message.MessageDialog instead.", ), ) - from gui.messageDialog import MessageBoxShim + from gui import mainFrame + from gui.messageDialog import _messageBoxShim + from gui.guiHelper import wxCallOnMain import core from logHandler import log if not core._hasShutdownBeenTriggered: - res = MessageBoxShim().messageBox(message, caption, style, parent) + res = wxCallOnMain(_messageBoxShim, message, caption, style, parent=parent or mainFrame) else: log.debugWarning("Not displaying message box as shutdown has been triggered.", stack_info=True) res = wx.CANCEL diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 05f87740560..cb68f1b2fb5 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -4,7 +4,6 @@ # For more details see: https://www.gnu.org/licenses/gpl-2.0.html from enum import Enum, IntEnum, auto -import threading import time from typing import Any, NamedTuple, TypeAlias, Self import winsound @@ -15,7 +14,7 @@ from .contextHelp import ContextHelpMixin from .dpiScalingHelper import DpiScalingHelperMixinWithoutInit -from .guiHelper import SIPABCMeta, wxCallOnMain +from .guiHelper import SIPABCMeta from gui import guiHelper from functools import partialmethod, singledispatchmethod from collections import deque @@ -728,113 +727,101 @@ def _execute_command( # endregion -class MessageBoxShim: - """Shim between :fun:`gui.message.messageBox` and :class:`MessageDialog`.""" +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. - def __init__(self): - self._event = threading.Event() + Shim between :fun:`gui.message.messageBox` and :class:`MessageDialog`. + Must be called from the GUI thread. - def messageBox(self, message: str, caption: str, style: int, parent: wx.Window | None): - """Display a message box with the given message, caption, style, and parent window. - - If called from the main thread, the message box is directly shown. - If called from a different thread, the message box is scheduled to be shown on the main thread, and we wait for it to complete. - In either case, the return value of the message box is returned. - - :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, `gui.mainFrame` will be used. - :raises Exception: Any exception raised by attempting to create a message box. - :return: See :fun:`wx.MessageBox`. - """ - return wxCallOnMain(self._messageBoxImpl, message, caption, style, parent) - - def _messageBoxImpl(self, message: str, caption: str, style: int, parent: wx.Window | None): - from gui import mainFrame - - dialog = MessageDialog( - parent=parent or mainFrame, - message=message, - title=caption, - dialogType=self._iconStylesToDialogType(style), - buttons=self._buttonStylesToButtons(style), - ) - return self._returnCodeToWxButtonCode(dialog.ShowModal()) - - @staticmethod - def _returnCodeToWxButtonCode(returnCode: ReturnCode) -> int: - """Map from an instance of :class:`ReturnCode` to an int as returned by :fun:`wx.MessageBox`. + :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, `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()) - Note that only YES, NO, OK, CANCEL and HELP returns are supported by :fun:`wx.MessageBox`, and thus by this function.` - :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`. - """ - 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 _messageDialogReturnCodeToMessageBoxReturnCode(returnCode: ReturnCode) -> int: + """Map from an instance of :class:`ReturnCode` to an int as returned by :fun:`wx.MessageBox`. - @staticmethod - def _iconStylesToDialogType(flags: int) -> DialogType: - """Map from a bitmask of styles as expected by :fun:`wx.MessageBox` to a :Class:`DialogType`. + Note that only YES, NO, OK, CANCEL and HELP returns are supported by :fun:`wx.MessageBox`, and thus by this function.` - Note that 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`. + :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`. + """ + 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`. + + Note that 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`. + + :param flags: Style flags. + :return: Corresponding dialog type. + """ + # 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 - :param flags: Style flags. - :return: Corresponding dialog type. - """ - # 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 - @staticmethod - def _buttonStylesToButtons(flags: int) -> tuple[Button, ...]: - """Map from a bitmask of styles as expected by :fun:`wx.MessageBox` to a list of :class:`Button`s. +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. - Note that :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 that :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 that 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. + Note that 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. - This function will always return a tuple of at least one button, typically an OK button. + 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. - """ - 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) + :param flags: Style flags. + :return: Tuple of :class:`Button` instances. + """ + 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/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index e2d12668a93..6c5b901ce7d 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -16,7 +16,7 @@ EscapeCode, ReturnCode, DialogType, - MessageBoxShim, + _MessageBoxButtonStylesToMessageDialogButtons, ) from parameterized import parameterized from typing import Any, Iterable, NamedTuple @@ -408,5 +408,5 @@ def test_messageBoxButtonStylesToMessageDialogButtons(self): with self.subTest(flags=input): self.assertCountEqual( expectedOutput, - (button.id for button in MessageBoxShim._buttonStylesToButtons(input)), + (button.id for button in _MessageBoxButtonStylesToMessageDialogButtons(input)), ) From 209b6537eff428ebcc612684ea5818cc9bc54c11 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 18 Nov 2024 14:49:55 +1100 Subject: [PATCH 099/209] Added new function to changes --- user_docs/en/changes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 51221b85726..fc0254fc28f 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -68,6 +68,7 @@ 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 syncronously calling wx functions from non-GUI threads, and getting their return value. #### API Breaking Changes From b0e686dd363715054b7f6a084665ac4cbbb3e294 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 18 Nov 2024 15:06:19 +1100 Subject: [PATCH 100/209] Only allow instantiating message dialogs from the GUI thread --- source/gui/messageDialog.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index cb68f1b2fb5..c499fa6ac3c 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -213,6 +213,8 @@ def __init__( buttons: Collection[Button] | None = (DefaultButton.OK,), helpId: str = "", ): + if not wx.IsMainThread(): + raise RuntimeError("Message dialogs can only be created from the GUI thread.") self.helpId = helpId super().__init__(parent, title=title) self.EnableCloseButton(False) From 9757bb2c6b83d30bf4ab1b4075dae4d961e1bc00 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 18 Nov 2024 16:15:12 +1100 Subject: [PATCH 101/209] Fixed implementation of isBlocking and FocusBlockingInstances --- source/gui/messageDialog.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index c499fa6ac3c..e9838b9b578 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -480,7 +480,7 @@ def ShowModal(self) -> ReturnCode: @property def isBlocking(self) -> bool: """Whether or not the dialog is blocking""" - return self.IsModal() and self.hasDefaultAction + return self.IsModal() and not self.hasDefaultAction @property def hasDefaultAction(self) -> bool: @@ -515,11 +515,13 @@ def BlockingInstancesExist(cls) -> bool: def FocusBlockingInstances(cls) -> None: """Raise and focus open dialogs without a default return code (eg Show without `self._defaultReturnCode`, or ShowModal without `wx.CANCEL`)""" + lastDialog: MessageDialog | None = None for dialog in cls._instances: if dialog.isBlocking: + lastDialog = dialog dialog.Raise() - dialog.SetFocus() - break + if lastDialog: + lastDialog.SetFocus() # endregion From 2c1643082c8e567c98f7ca62fe23f752f792b609 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 18 Nov 2024 16:56:26 +1100 Subject: [PATCH 102/209] Added helper methods for alert, confirm and query dialogs --- source/gui/messageDialog.py | 88 ++++++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index e9838b9b578..3914f739335 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -5,7 +5,7 @@ from enum import Enum, IntEnum, auto import time -from typing import Any, NamedTuple, TypeAlias, Self +from typing import Any, Literal, NamedTuple, TypeAlias, Self import winsound import wx @@ -14,7 +14,7 @@ from .contextHelp import ContextHelpMixin from .dpiScalingHelper import DpiScalingHelperMixinWithoutInit -from .guiHelper import SIPABCMeta +from .guiHelper import SIPABCMeta, wxCallOnMain from gui import guiHelper from functools import partialmethod, singledispatchmethod from collections import deque @@ -523,6 +523,90 @@ def FocusBlockingInstances(cls) -> None: 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. + + :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(): + cls(parent, message, caption, buttons=None).addOkButton(label=okLabel).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. + + :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(): + return ( + cls(parent, message, caption, buttons=None) + .addOkButton(label=okLabel) + .addCancelButton(label=cancelLabel) + .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. + + :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(): + return ( + cls(parent, message, caption, buttons=None) + .addYesButton(label=yesLabel) + .addNoButton(label=noLabel) + .addCancelButton(label=cancelLabel) + .ShowModal() + ) + + return wxCallOnMain(impl) # type: ignore + # endregion # region Methods for subclasses From 594c943a3a6bfc9fcc2cf722c2af6636e8d4cb93 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 18 Nov 2024 17:17:55 +1100 Subject: [PATCH 103/209] Added some methods to be implemented --- source/gui/messageDialog.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 3914f739335..f71a0ccf378 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -399,6 +399,14 @@ def addButtons(self, buttons: Collection[Button]) -> Self: 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: ... + def setOkLabel(self, label: str) -> Self: ... + def setHelpLabel(self, label: str) -> Self: ... + def setOkCancelLabels(self, okLabel: str, cancelLabel: str) -> Self: ... + def setYesNoLabels(self, yesLabel: str, noLabel: str) -> Self: ... + def setYesNoCancelLabels(self, yesLabel: str, noLabel: str, cancelLabel: str) -> Self: ... + def setMessage(self, message: str) -> Self: ... + def setDefaultFocus(self, id: ReturnCode) -> Self: """Set the button to be focused when the dialog first opens. From 9072f3aa0b287c0ab46a1b873e77a76e8c479d81 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 20 Nov 2024 11:54:23 +1100 Subject: [PATCH 104/209] Added more main thread checks --- source/gui/messageDialog.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index f71a0ccf378..6c258babbdd 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -203,6 +203,11 @@ class MessageDialog(DpiScalingHelperMixinWithoutInit, ContextHelpMixin, wx.Dialo """ # region Constructors + def __new__(cls, *args, **kwargs) -> Self: + if not wx.IsMainThread(): + raise RuntimeError("Message dialogs can only be created from the GUI thread.") + return super().__new__(cls, *args, **kwargs) + def __init__( self, parent: wx.Window | None, @@ -214,7 +219,7 @@ def __init__( helpId: str = "", ): if not wx.IsMainThread(): - raise RuntimeError("Message dialogs can only be created from the GUI thread.") + raise RuntimeError("Message dialogs can only be initialised from the GUI thread.") self.helpId = helpId super().__init__(parent, title=title) self.EnableCloseButton(False) @@ -471,6 +476,8 @@ def Show(self, show: bool = True) -> bool: def ShowModal(self) -> ReturnCode: """Show a blocking dialog. Attach buttons with button handlers""" + if not wx.IsMainThread(): + raise RuntimeError("Message dialogs can only be shown modally from the main thread.") if not self.GetMainButtonIds(): raise RuntimeError("MessageDialogs cannot be shown without buttons.") self._realize_layout() From f3331c361ff6d1eb042061ff2424a41878dbba50 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 20 Nov 2024 14:04:22 +1100 Subject: [PATCH 105/209] Started implementation of message.MessageDialog -> messageDialog.MessageDialog shim (untested) --- source/gui/nvdaControls.py | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/source/gui/nvdaControls.py b/source/gui/nvdaControls.py index 66016ebc01f..50bd5d0ded0 100644 --- a/source/gui/nvdaControls.py +++ b/source/gui/nvdaControls.py @@ -21,6 +21,7 @@ FeatureFlag, FlagValueEnum as FeatureFlagEnumT, ) +from gui import messageDialog from .dpiScalingHelper import DpiScalingHelperMixin from . import ( guiHelper, @@ -270,7 +271,41 @@ def __init__(self, *args, **kwargs): DpiScalingHelperMixin.__init__(self, self.GetHandle()) -class MessageDialog(DPIScaledDialog): +class MessageDialog(messageDialog.MessageDialog): + """Adapter around messageDialog.MessageDialog for compatibility.""" + + # Dialog types currently supported + DIALOG_TYPE_STANDARD = 1 + DIALOG_TYPE_WARNING = 2 + DIALOG_TYPE_ERROR = 3 + + @staticmethod + def _legasyDialogTypeToDialogType(dialogType: int) -> messageDialog.DialogType: + match dialogType: + case MessageDialog.DIALOG_TYPE_ERROR: + return messageDialog.DialogType.ERROR + case MessageDialog.DIALOG_TYPE_WARNING: + return messageDialog.DialogType.WARNING + case _: + return messageDialog.DialogType.STANDARD + + 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._legasyDialogTypeToDialogType(dialogType), + buttons=None, + ) + + +class LegasyMessageDialog(DPIScaledDialog): """Provides a more flexible message dialog. Consider overriding _addButtons, to set your own buttons and behaviour. """ From e266bc97db15c84d836747552194211c246fadb1 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 20 Nov 2024 15:20:48 +1100 Subject: [PATCH 106/209] Fixed duplicate superclass of gui.nvdaControls._ContinueCancelDialog --- source/gui/nvdaControls.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/source/gui/nvdaControls.py b/source/gui/nvdaControls.py index 50bd5d0ded0..13b40b46548 100644 --- a/source/gui/nvdaControls.py +++ b/source/gui/nvdaControls.py @@ -25,7 +25,6 @@ from .dpiScalingHelper import DpiScalingHelperMixin from . import ( guiHelper, - contextHelp, ) import winUser import winsound @@ -416,7 +415,6 @@ def _onShowEvt(self, evt): class _ContinueCancelDialog( - contextHelp.ContextHelpMixin, MessageDialog, ): """ From 5655da1ce90e1157ff288207de6ef34ca1912e93 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 20 Nov 2024 16:07:49 +1100 Subject: [PATCH 107/209] Refactored show checks into their own private method --- source/gui/messageDialog.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 6c258babbdd..d9c6f4a7608 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -463,8 +463,7 @@ def Show(self, show: bool = True) -> bool: """ if not show: return self.Hide() - if not self.GetMainButtonIds(): - raise RuntimeError("MessageDialogs cannot be shown without buttons.") + self._assertShowable() self._realize_layout() log.debug("Showing") ret = super().Show(show) @@ -476,10 +475,7 @@ def Show(self, show: bool = True) -> bool: def ShowModal(self) -> ReturnCode: """Show a blocking dialog. Attach buttons with button handlers""" - if not wx.IsMainThread(): - raise RuntimeError("Message dialogs can only be shown modally from the main thread.") - if not self.GetMainButtonIds(): - raise RuntimeError("MessageDialogs cannot be shown without buttons.") + self._assertShowable() self._realize_layout() self.__ShowModal = self.ShowModal self.ShowModal = super().ShowModal @@ -638,6 +634,21 @@ def _addContents(self, contentsSizer: guiHelper.BoxSizerHelper) -> None: # endregion # region Internal API + def _assertShowable(self, *, checkMainThread: bool = True, checkButtons: bool = True): + """Checks that must pass in order to show a Message Dialog. + + If any of the specified tests fails, an appropriate exception will be raised. + + :param checkMainThread: Whether to check that we're running on the GUI thread, defaults to True + :param checkButtons: Whether to check there is at least one command registered, defaults to True + :raises RuntimeError: If the main thread check fails. + :raises RuntimeError: If the button check fails. + """ + if checkMainThread and not wx.IsMainThread(): + raise RuntimeError("Message dialogs can only be shown from the main thread.") + if checkButtons and not self.GetMainButtonIds(): + raise RuntimeError("MessageDialogs cannot be shown without buttons.") + def _realize_layout(self) -> None: if self._isLayoutFullyRealized: return From 27352c3c08c083cd65eb3bc7e47370a324748caa Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 20 Nov 2024 16:33:36 +1100 Subject: [PATCH 108/209] Old message dialog shimmed to new one and working with screen curtain (probably more to do still) --- source/gui/nvdaControls.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/source/gui/nvdaControls.py b/source/gui/nvdaControls.py index 13b40b46548..276cc3d9392 100644 --- a/source/gui/nvdaControls.py +++ b/source/gui/nvdaControls.py @@ -303,6 +303,9 @@ def __init__( buttons=None, ) + def _assertShowable(self, *, checkButtons: bool = False, **kwargs): + return super()._assertShowable(checkButtons=checkButtons, **kwargs) + class LegasyMessageDialog(DPIScaledDialog): """Provides a more flexible message dialog. Consider overriding _addButtons, to set your own From e65e74bfbe072cf59c30ffa8850800258ff96d7b Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 21 Nov 2024 11:02:38 +1100 Subject: [PATCH 109/209] Refactor checks to make them easier to override in subclasses. --- source/gui/messageDialog.py | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index d9c6f4a7608..77d87ad5bc0 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -201,11 +201,12 @@ class MessageDialog(DpiScalingHelperMixinWithoutInit, ContextHelpMixin, wx.Dialo 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 + _FAIL_ON_NO_BUTTONS = True # region Constructors def __new__(cls, *args, **kwargs) -> Self: - if not wx.IsMainThread(): - raise RuntimeError("Message dialogs can only be created from the GUI thread.") + cls._checkMainThread() return super().__new__(cls, *args, **kwargs) def __init__( @@ -218,8 +219,7 @@ def __init__( buttons: Collection[Button] | None = (DefaultButton.OK,), helpId: str = "", ): - if not wx.IsMainThread(): - raise RuntimeError("Message dialogs can only be initialised from the GUI thread.") + self._checkMainThread() self.helpId = helpId super().__init__(parent, title=title) self.EnableCloseButton(False) @@ -463,7 +463,7 @@ def Show(self, show: bool = True) -> bool: """ if not show: return self.Hide() - self._assertShowable() + self._checkShowable() self._realize_layout() log.debug("Showing") ret = super().Show(show) @@ -475,7 +475,7 @@ def Show(self, show: bool = True) -> bool: def ShowModal(self) -> ReturnCode: """Show a blocking dialog. Attach buttons with button handlers""" - self._assertShowable() + self._checkShowable() self._realize_layout() self.__ShowModal = self.ShowModal self.ShowModal = super().ShowModal @@ -634,7 +634,7 @@ def _addContents(self, contentsSizer: guiHelper.BoxSizerHelper) -> None: # endregion # region Internal API - def _assertShowable(self, *, checkMainThread: bool = True, checkButtons: bool = True): + 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. @@ -644,11 +644,22 @@ def _assertShowable(self, *, checkMainThread: bool = True, checkButtons: bool = :raises RuntimeError: If the main thread check fails. :raises RuntimeError: If the button check fails. """ - if checkMainThread and not wx.IsMainThread(): - raise RuntimeError("Message dialogs can only be shown from the main thread.") - if checkButtons and not self.GetMainButtonIds(): + self._checkMainThread(checkMainThread) + self._checkHasButtons(checkButtons) + + def _checkHasButtons(self, check: bool | None = None): + 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): + 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 _realize_layout(self) -> None: if self._isLayoutFullyRealized: return From e72cf5d3ef0751ae6245b7d47ed53265b7c83bae Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 21 Nov 2024 11:03:36 +1100 Subject: [PATCH 110/209] Shim for gui.nvdaControls.MessageDialog seems to work fine. --- source/gui/nvdaControls.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/source/gui/nvdaControls.py b/source/gui/nvdaControls.py index 276cc3d9392..7f739ddd742 100644 --- a/source/gui/nvdaControls.py +++ b/source/gui/nvdaControls.py @@ -11,6 +11,7 @@ OrderedDict, Type, ) +import warnings import wx from wx.lib import scrolledpanel @@ -273,6 +274,10 @@ def __init__(self, *args, **kwargs): class MessageDialog(messageDialog.MessageDialog): """Adapter around messageDialog.MessageDialog for compatibility.""" + # 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 @@ -288,6 +293,13 @@ def _legasyDialogTypeToDialogType(dialogType: int) -> messageDialog.DialogType: case _: return messageDialog.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) + def __init__( self, parent: wx.Window | None, @@ -303,8 +315,9 @@ def __init__( buttons=None, ) - def _assertShowable(self, *, checkButtons: bool = False, **kwargs): - return super()._assertShowable(checkButtons=checkButtons, **kwargs) + def _addButtons(self, buttonHelper: guiHelper.ButtonHelper) -> None: + """Adds ok / cancel buttons. Can be overridden to provide alternative functionality.""" + self.addOkCancelButtons() class LegasyMessageDialog(DPIScaledDialog): From 5cb75b9428400a8423fd86aa69b57ea2635103b9 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 29 Nov 2024 15:51:05 +1100 Subject: [PATCH 111/209] Add sentinel value to allow discrimination of `None` and "not provided". --- source/gui/messageDialog.py | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 77d87ad5bc0..bebcbf1f2d6 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -26,6 +26,14 @@ Callback_T: TypeAlias = Callable[[], Any] +class _Missing_Type: + def __repr(self): + return "MISSING" + + +_MISSING = _Missing_Type() + + class ReturnCode(IntEnum): """Enumeration of possible returns from c{MessageDialog}.""" @@ -328,11 +336,11 @@ def _( button: Button, /, *args, - label: str | None = None, - callback: Callback_T | None = None, - defaultFocus: bool | None = None, - fallbackAction: bool | None = None, - closesDialog: bool | None = None, + label: str | _Missing_Type = _MISSING, + callback: Callback_T | _Missing_Type = _MISSING, + defaultFocus: bool | _Missing_Type = _MISSING, + fallbackAction: bool | _Missing_Type = _MISSING, + closesDialog: bool | _Missing_Type = _MISSING, **kwargs, ) -> Self: """Add a :class:`Button` to the dialog. @@ -347,19 +355,18 @@ def _( .. seealso:: :class:`Button` """ - # We need to pass `id` as a positional argument as `singledispatchmethod` matches on the type of the first argument. - id = button.id keywords = button._asdict() - del keywords["id"] # Guaranteed to exist. - if label is not None: + # 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 None: + if defaultFocus is not _MISSING: keywords["defaultFocus"] = defaultFocus - if fallbackAction is not None: + if fallbackAction is not _MISSING: keywords["fallbackAction"] = fallbackAction - if callback is not None: + if callback is not _MISSING: keywords["callback"] = callback - if closesDialog is not None: + if closesDialog is not _MISSING: keywords["closesDialog"] = closesDialog keywords.update(kwargs) return self.addButton(id, *args, **keywords) From 6cabca8b1505ca87b6bf91fdc5a6019e686617bf Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 29 Nov 2024 16:04:15 +1100 Subject: [PATCH 112/209] Allow overriding the implicit return code of the action ID by providing a return code to MessageDialog.addButton or Button. --- source/gui/messageDialog.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index bebcbf1f2d6..0dbe5435a6c 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -141,6 +141,12 @@ class Button(NamedTuple): See the documentation of c{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.""" @@ -188,10 +194,11 @@ class DefaultButtonSet(tuple[DefaultButton], Enum): class _Command(NamedTuple): """Internal representation of a command for a message dialog.""" - callback: Callback_T | None = None + callback: Callback_T | None """The callback function to be executed. Defaults to None.""" - closesDialog: bool = True + closesDialog: bool """Indicates whether the dialog should be closed after the command is executed. Defaults to True.""" + ReturnCode: ReturnCode class MessageDialog(DpiScalingHelperMixinWithoutInit, ContextHelpMixin, wx.Dialog, metaclass=SIPABCMeta): @@ -290,6 +297,7 @@ def addButton( defaultFocus: bool = False, fallbackAction: bool = False, closesDialog: bool = True, + returnCode: ReturnCode | None = None, **kwargs, ) -> Self: """Add a button to the dialog. @@ -316,11 +324,13 @@ def addButton( # 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 close_dialog to True.", + "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 or fallbackAction, + ReturnCode=buttonId if returnCode is None else returnCode, ) if defaultFocus: self.SetDefaultItem(button) @@ -341,6 +351,7 @@ def _( 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. @@ -368,6 +379,8 @@ def _( 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) @@ -763,7 +776,7 @@ def getAction() -> tuple[int, _Command]: ) # No commands have been registered. Create one of our own. - return EscapeCode.NONE, _Command() + return EscapeCode.NONE, _Command(callback=None, closesDialog=True, ReturnCode=wx.ID_NONE) id, command = getAction() if not command.closesDialog: @@ -846,14 +859,14 @@ def _execute_command( """ if command is None: command = self._commands[id] - callback, close = command + callback, close, returnCode = command close &= _canCallClose if callback is not None: if close: self.Hide() callback() if close: - self.SetReturnCode(id) + self.SetReturnCode(returnCode) self.Close() # endregion From d99f5a403c38cb4fb5924f248530559ffde006c9 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 29 Nov 2024 16:09:13 +1100 Subject: [PATCH 113/209] Update shims of gui.nvdaControls.MessageDialog and gui.nvdaControls._ContinueCancelDialog to return the same wx constants as their previous implementations. --- source/gui/nvdaControls.py | 38 ++++++++++++++++---------------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/source/gui/nvdaControls.py b/source/gui/nvdaControls.py index 7f739ddd742..ea72cc4b903 100644 --- a/source/gui/nvdaControls.py +++ b/source/gui/nvdaControls.py @@ -317,7 +317,8 @@ def __init__( def _addButtons(self, buttonHelper: guiHelper.ButtonHelper) -> None: """Adds ok / cancel buttons. Can be overridden to provide alternative functionality.""" - self.addOkCancelButtons() + self.addOkButton(returnCode=wx.OK) + self.addCancelButton(returnCode=wx.CANCEL) class LegasyMessageDialog(DPIScaledDialog): @@ -430,9 +431,7 @@ def _onShowEvt(self, evt): evt.Skip() -class _ContinueCancelDialog( - MessageDialog, -): +class _ContinueCancelDialog(MessageDialog): """ This implementation of a `gui.nvdaControls.MessageDialog`, provides `Continue` and `Cancel` buttons as its controls. These serve the same functions as `OK` and `Cancel` in other dialogs, but may be more desirable in some situations. @@ -463,29 +462,24 @@ def __init__( if helpId is not None: self.helpId = helpId super().__init__(parent, title, message, dialogType) + if helpId is not None: + # Help event has already been bound (in supersuperclass), so we need to re-bind it. + self.bindHelpEvent(helpId, self) def _addButtons(self, buttonHelper: guiHelper.ButtonHelper) -> None: """Override to add Continue and Cancel buttons.""" - - # Note: the order of the Continue and Cancel buttons is important, because running SetDefault() - # on the Cancel button while the Continue button is first, has no effect. Therefore the only way to - # allow a caller to make Cancel the default, is to put it first. - def _makeContinue(self, buttonHelper: guiHelper.ButtonHelper) -> wx.Button: + self.addOkButton( # Translators: The label for the Continue button in an NVDA dialog. - return buttonHelper.addButton(self, id=wx.ID_OK, label=_("&Continue")) - - def _makeCancel(self, buttonHelper: guiHelper.ButtonHelper) -> wx.Button: + label=_("&Continue"), + returnCode=wx.OK, + defaultFocus=self.continueButtonFirst, + ) + self.addCancelButton( # Translators: The label for the Cancel button in an NVDA dialog. - return buttonHelper.addButton(self, id=wx.ID_CANCEL, label=_("Cancel")) - - if self.continueButtonFirst: - continueButton = _makeContinue(self, buttonHelper) - cancelButton = _makeCancel(self, buttonHelper) - else: - cancelButton = _makeCancel(self, buttonHelper) - continueButton = _makeContinue(self, buttonHelper) - continueButton.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.OK)) - cancelButton.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.CANCEL)) + label=_("Cancel"), + returnCode=wx.CANCEL, + defaultFocus=not self.continueButtonFirst, + ) class EnhancedInputSlider(wx.Slider): From c4dc3f945894ff42093255f903982edea7563258 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 29 Nov 2024 16:17:14 +1100 Subject: [PATCH 114/209] Update changelog --- user_docs/en/changes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index fc0254fc28f..5d2e598101a 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -91,7 +91,7 @@ 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` function is deprecated. +* The `gui.message.messageBox` function and `gui.nvdaControls.MessageDialog` class are deprecated. Use `gui.message.MessageDialog` instead. ## 2024.4 From 1278d16f4a7aafe16db15f0ab3dda1f658043e09 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 29 Nov 2024 16:39:55 +1100 Subject: [PATCH 115/209] Added means of changing button labels. --- source/gui/messageDialog.py | 46 ++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 0dbe5435a6c..01cdb3b2cf4 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -424,12 +424,30 @@ def addButtons(self, buttons: Collection[Button]) -> Self: 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: ... - def setOkLabel(self, label: str) -> Self: ... - def setHelpLabel(self, label: str) -> Self: ... - def setOkCancelLabels(self, okLabel: str, cancelLabel: str) -> Self: ... - def setYesNoLabels(self, yesLabel: str, noLabel: str) -> Self: ... - def setYesNoCancelLabels(self, yesLabel: str, noLabel: str, cancelLabel: str) -> Self: ... + def setButtonLabel(self, id: ReturnCode, label: str) -> Self: + 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: + self._setButtonLabels((ReturnCode.OK, ReturnCode.CANCEL), (okLabel, cancelLabel)) + return self + + def setYesNoLabels(self, yesLabel: str, noLabel: str) -> Self: + self._setButtonLabels((ReturnCode.YES, ReturnCode.NO), (yesLabel, noLabel)) + return self + + def setYesNoCancelLabels(self, yesLabel: str, noLabel: str, cancelLabel: str) -> Self: + self._setButtonLabels( + (ReturnCode.YES, ReturnCode.NO, ReturnCode.CANCEL), + (yesLabel, noLabel, cancelLabel), + ) + return self + def setMessage(self, message: str) -> Self: ... def setDefaultFocus(self, id: ReturnCode) -> Self: @@ -784,6 +802,22 @@ def getAction() -> tuple[int, _Command]: command = command._replace(closesDialog=True) return id, command + def _setButtonLabels(self, ids: Collection[ReturnCode], labels: Collection[str]): + 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 ValueError( + 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: From c55c6e16667ac3981c4e675ba49531c51443356b Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 29 Nov 2024 17:00:07 +1100 Subject: [PATCH 116/209] Added the ability to set the message text independent of the __init__ --- source/gui/messageDialog.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 01cdb3b2cf4..3eb73c1829c 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -254,9 +254,8 @@ def __init__( self._mainSizer = mainSizer # Use SetLabelText to avoid ampersands being interpreted as accelerators. - text = wx.StaticText(self) - text.SetLabelText(message) - text.Wrap(self.scaleSize(self.GetSize().Width)) + self._messageControl = text = wx.StaticText(self) + self.setMessage(message) contentsSizer.addItem(text) self._addContents(contentsSizer) @@ -274,14 +273,6 @@ def __init__( ) # mainSizer.Fit(self) self.SetSizer(mainSizer) - # Import late to avoid circular import - # from gui import mainFrame - - # if parent == 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() # endregion @@ -448,7 +439,10 @@ def setYesNoCancelLabels(self, yesLabel: str, noLabel: str, cancelLabel: str) -> ) return self - def setMessage(self, message: str) -> Self: ... + def setMessage(self, message: str) -> Self: + 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. @@ -704,6 +698,7 @@ def _realize_layout(self) -> None: if gui._isDebug(): startTime = time.time() log.debug("Laying out message dialog") + self._messageControl.Wrap(self.scaleSize(self.GetSize().Width)) self._mainSizer.Fit(self) from gui import mainFrame From cc5274a1223ed615310ed4c730fab01675d8d982 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 2 Dec 2024 17:41:42 +1100 Subject: [PATCH 117/209] Improve logic of alert, confirm and ask classmethods. --- source/gui/messageDialog.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 3eb73c1829c..841f35d2677 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -584,7 +584,10 @@ def alert( """ def impl(): - cls(parent, message, caption, buttons=None).addOkButton(label=okLabel).ShowModal() + dlg = cls(parent, message, caption, buttons=(DefaultButton.OK,)) + if okLabel is not None: + dlg.setOkLabel(okLabel) + dlg.ShowModal() wxCallOnMain(impl) @@ -609,12 +612,12 @@ def confirm( """ def impl(): - return ( - cls(parent, message, caption, buttons=None) - .addOkButton(label=okLabel) - .addCancelButton(label=cancelLabel) - .ShowModal() - ) + 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 @@ -640,13 +643,14 @@ def ask( """ def impl(): - return ( - cls(parent, message, caption, buttons=None) - .addYesButton(label=yesLabel) - .addNoButton(label=noLabel) - .addCancelButton(label=cancelLabel) - .ShowModal() - ) + 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 From 17fd510ebc72aed8bf42cb870796cc8f2bcc86f4 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 2 Dec 2024 17:49:36 +1100 Subject: [PATCH 118/209] Slight reorganisation in some methods. --- source/gui/messageDialog.py | 42 ++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 841f35d2677..4a6d819b7fc 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -235,45 +235,43 @@ def __init__( helpId: str = "", ): self._checkMainThread() - self.helpId = helpId + self.helpId = helpId # Must be set before initialising ContextHelpMixin. super().__init__(parent, title=title) - self.EnableCloseButton(False) self._isLayoutFullyRealized = False self._commands: dict[int, _Command] = {} + # Stylistic matters. + self.EnableCloseButton(False) self._setIcon(dialogType) self._setSound(dialogType) + + # Bind event listeners. self.Bind(wx.EVT_SHOW, self._onShowEvt, source=self) self.Bind(wx.EVT_ACTIVATE, self._onDialogActivated, source=self) self.Bind(wx.EVT_CLOSE, self._onCloseEvent) self.Bind(wx.EVT_BUTTON, self._onButton) - mainSizer = wx.BoxSizer(wx.VERTICAL) - contentsSizer = guiHelper.BoxSizerHelper(parent=self, orientation=wx.VERTICAL) - self._contentsSizer = contentsSizer - self._mainSizer = mainSizer - - # Use SetLabelText to avoid ampersands being interpreted as accelerators. - self._messageControl = text = wx.StaticText(self) - self.setMessage(message) - contentsSizer.addItem(text) - self._addContents(contentsSizer) - - buttonHelper = guiHelper.ButtonHelper(wx.HORIZONTAL) + # 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) contentsSizer.addDialogDismissButtons(buttonHelper) - self._buttonHelper = buttonHelper - self._addButtons(buttonHelper) - if buttons is not None: - self.addButtons(buttons) - mainSizer.Add( contentsSizer.sizer, border=guiHelper.BORDER_FOR_DIALOGS, flag=wx.ALL, ) - # mainSizer.Fit(self) self.SetSizer(mainSizer) + # Finally, populate the dialog. + self.setMessage(message) + self._addContents(contentsSizer) + self._addButtons(buttonHelper) + if buttons is not None: + self.addButtons(buttons) + # endregion # region Public object API @@ -320,7 +318,7 @@ def addButton( closesDialog = True self._commands[buttonId] = _Command( callback=callback, - closesDialog=closesDialog or fallbackAction, + closesDialog=closesDialog, ReturnCode=buttonId if returnCode is None else returnCode, ) if defaultFocus: @@ -811,7 +809,7 @@ def _setButtonLabels(self, ids: Collection[ReturnCode], labels: Collection[str]) elif isinstance((button := self.FindWindow(id)), wx.Button): buttons.append(button) else: - raise ValueError( + 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): From 3e9850a13c346a8495abac688db771a37872e0cf Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 2 Dec 2024 17:55:19 +1100 Subject: [PATCH 119/209] Code documentation improvements. --- source/gui/messageDialog.py | 143 ++++++++++++++++++++++++++++-------- 1 file changed, 113 insertions(+), 30 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 4a6d819b7fc..56366f09e2c 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -27,11 +27,14 @@ class _Missing_Type: + """Sentinel class to provide a nice repr.""" + def __repr(self): return "MISSING" _MISSING = _Missing_Type() +"""Sentinel for discriminating between `None` and an actually omitted argument.""" class ReturnCode(IntEnum): @@ -217,10 +220,13 @@ class MessageDialog(DpiScalingHelperMixinWithoutInit, ContextHelpMixin, wx.Dialo 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) @@ -292,7 +298,6 @@ def addButton( """Add a button to the dialog. :param id: The ID to use for the button. - If the dialog is to be shown modally, this will also be the return value if the dialog is closed with this 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 modelessly. @@ -302,6 +307,9 @@ def addButton( 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: @@ -336,7 +344,7 @@ def _( /, *args, label: str | _Missing_Type = _MISSING, - callback: Callback_T | _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, @@ -346,14 +354,13 @@ def _( """Add a :class:`Button` to the dialog. :param button: The button to add. - :param label: Override for `button.label`, defaults to None. - :param callback: Override for `button.callback`, defaults to None. - :param defaultFocus: Override for `button.defaultFocus`, defaults to None. - :param fallbackAction: Override for `button.fallbackAction`, defaults to None - :param closesDialog: Override for `button.closesDialog`, defaults to None - :return: Updated dialog instance for chaining. - - .. seealso:: :class:`Button` + :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. @@ -414,6 +421,12 @@ def addButtons(self, buttons: Collection[Button]) -> Self: 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 @@ -423,14 +436,33 @@ def setButtonLabel(self, id: ReturnCode, label: str) -> Self: 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), @@ -438,6 +470,12 @@ def setYesNoCancelLabels(self, yesLabel: str, noLabel: str, cancelLabel: str) -> 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 @@ -481,13 +519,13 @@ def SetEscapeId(self, id: ReturnCode | EscapeCode) -> Self: return self def setDefaultAction(self, id: ReturnCode | EscapeCode) -> Self: - """See MessageDialog.SetEscapeId.""" + """See :meth:`MessageDialog.SetEscapeId`.""" return self.SetEscapeId(id) def Show(self, show: bool = True) -> bool: """Show a non-blocking dialog. - Attach buttons with button handlers + 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. """ @@ -496,25 +534,32 @@ def Show(self, show: bool = True) -> bool: self._checkShowable() self._realize_layout() log.debug("Showing") - ret = super().Show(show) - if ret: + shown = super().Show(show) + if shown: log.debug("Adding to instances") self._instances.append(self) - return ret + return shown def ShowModal(self) -> ReturnCode: """Show a blocking dialog. - Attach buttons with button handlers""" + + Attach buttons with :meth:`.addButton`, :meth:`.addButtons`, or any of their more specific helpers. + """ self._checkShowable() self._realize_layout() + + # We want to call `gui.message.showScriptModal` 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 + # Import late to avoid circular import. from .message import displayDialogAsModal log.debug("Adding to instances") self._instances.append(self) log.debug("Showing modal") ret = displayDialogAsModal(self) + + # Restore our implementation of ShowModal. self.ShowModal = self.__ShowModal return ret @@ -525,7 +570,10 @@ def isBlocking(self) -> bool: @property def hasDefaultAction(self) -> bool: - """Whether the dialog has a valid fallback action.""" + """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.NONE and ( any( @@ -541,21 +589,22 @@ def hasDefaultAction(self) -> bool: # region Public class methods @classmethod def CloseInstances(cls) -> None: - """Close all dialogs with a fallback action""" + """Close all dialogs with a fallback action. + + This does not force-close all instances, so instances may vito being closed. + """ for instance in cls._instances: if not instance.isBlocking: instance.Close() @classmethod def BlockingInstancesExist(cls) -> bool: - """Check if dialogs are open without a default return code - (eg Show without `self._defaultReturnCode`, or ShowModal without `wx.CANCEL`)""" + """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 dialogs without a default return code - (eg Show without `self._defaultReturnCode`, or ShowModal without `wx.CANCEL`)""" + """Raise and focus open modal dialogs without a fallback action.""" lastDialog: MessageDialog | None = None for dialog in cls._instances: if dialog.isBlocking: @@ -672,16 +721,23 @@ def _checkShowable(self, *, checkMainThread: bool | None = None, checkButtons: b """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 - :param checkButtons: Whether to check there is at least one command registered, defaults to True - :raises RuntimeError: If the main thread check fails. - :raises RuntimeError: If the button check fails. + :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(): @@ -689,12 +745,19 @@ def _checkHasButtons(self, check: bool | None = None): @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 _realize_layout(self) -> None: + """Perform layout adjustments prior to showing the dialog.""" if self._isLayoutFullyRealized: return if gui._isDebug(): @@ -800,6 +863,14 @@ def getAction() -> tuple[int, _Command]: return id, 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] = [] @@ -834,6 +905,10 @@ def _onDialogActivated(self, evt: wx.ActivateEvent): evt.Skip() def _onShowEvt(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: @@ -841,6 +916,10 @@ def _onShowEvt(self, evt: wx.ShowEvent): 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() @@ -866,6 +945,10 @@ def _onCloseEvent(self, evt: wx.CloseEvent): self._instances.remove(self) def _onButton(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: @@ -883,9 +966,9 @@ def _execute_command( """Execute a command on this dialog. :param id: ID of the command to execute. - :param command: Command to execute, defaults to None + :param command: Command to execute, defaults to None. If None, the command to execute will be looked up in the dialog's registered commands. - :param _canCallClose: Whether or not to close the dialog if the command says to, defaults to True + :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. """ if command is None: @@ -912,7 +995,7 @@ def _messageBoxShim(message: str, caption: str, style: int, parent: wx.Window | :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, `gui.mainFrame` will be used. + :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`. """ @@ -929,7 +1012,7 @@ def _messageBoxShim(message: str, caption: str, style: int, parent: wx.Window | def _messageDialogReturnCodeToMessageBoxReturnCode(returnCode: ReturnCode) -> int: """Map from an instance of :class:`ReturnCode` to an int as returned by :fun:`wx.MessageBox`. - Note that only YES, NO, OK, CANCEL and HELP returns are supported by :fun:`wx.MessageBox`, and thus by this function.` + Note that only YES, NO, OK, CANCEL and HELP returns are supported by :fun:`wx.MessageBox`, and thus by this function. :param returnCode: Return from :class:`MessageDialog`. :raises ValueError: If the return code is not supported by :fun:`wx.MessageBox`. From 9d804590c71789044c9af61d7b39bbb990264abb Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 3 Dec 2024 12:16:38 +1100 Subject: [PATCH 120/209] Docstring for MessageDialog._commands --- source/gui/messageDialog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 56366f09e2c..c53af52e835 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -245,6 +245,7 @@ def __init__( 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) From ff61f9d9b3e15ff51b7defc656158c2854031b55 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 3 Dec 2024 12:45:51 +1100 Subject: [PATCH 121/209] Improved logic for whether dialogs are blocking --- source/gui/messageDialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index c53af52e835..f393c5793ce 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -567,7 +567,7 @@ def ShowModal(self) -> ReturnCode: @property def isBlocking(self) -> bool: """Whether or not the dialog is blocking""" - return self.IsModal() and not self.hasDefaultAction + return self.IsModal() or not self.hasDefaultAction @property def hasDefaultAction(self) -> bool: From 86c472a372234f1bf664ef36a884a84cc3e5810f Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 3 Dec 2024 12:49:22 +1100 Subject: [PATCH 122/209] Added callback field to gui.blockAction._Context, and refactored the dialog check to use same. --- source/gui/blockAction.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/source/gui/blockAction.py b/source/gui/blockAction.py index deaf3997f7e..55270779939 100644 --- a/source/gui/blockAction.py +++ b/source/gui/blockAction.py @@ -3,13 +3,14 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. +from collections.abc import Callable import config from config.configFlags import BrailleMode from dataclasses import dataclass from enum import Enum from functools import wraps import globalVars -from typing import Callable +from typing import Any from speech.priorities import SpeechPriority import ui from utils.security import isLockScreenModeActive, isRunningOnSecureDesktop @@ -17,10 +18,20 @@ import core +def _modalDialogOpenCallback(): + """Focus any open blocking :class:`MessageDialog` instances.""" + # Import late to avoid circular import + from gui.messageDialog import MessageDialog + + if MessageDialog.BlockingInstancesExist(): + MessageDialog.FocusBlockingInstances() + + @dataclass class _Context: blockActionIf: Callable[[], bool] translatedMessage: str + callback: Callable[[], Any] | None = None class Context(_Context, Enum): @@ -40,6 +51,7 @@ class Context(_Context, Enum): # 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(), @@ -75,13 +87,9 @@ def _wrap(func): def funcWrapper(*args, **kwargs): for context in contexts: if context.blockActionIf(): - if context == Context.MODAL_DIALOG_OPEN: - # Import late to avoid circular import - from gui.messageDialog import MessageDialog - - if MessageDialog.BlockingInstancesExist(): - MessageDialog.FocusBlockingInstances() - # We need to delay this message so that, if a dialog is to be focused, the appearance of the dialog doesn't interrupt it. + 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. # 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. core.callLater(1, ui.message, context.translatedMessage, SpeechPriority.NOW) return From 01284dea4e3ef6d9825142da5310ea1db68b29fb Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 3 Dec 2024 17:39:20 +1100 Subject: [PATCH 123/209] Code style and documentation for gui.guiHelper.wxCallOnMain. --- source/gui/guiHelper.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/source/gui/guiHelper.py b/source/gui/guiHelper.py index d063a6706d6..a9437b69f5d 100644 --- a/source/gui/guiHelper.py +++ b/source/gui/guiHelper.py @@ -465,6 +465,8 @@ class SIPABCMeta(wx.siplib.wrappertype, ABCMeta): class _WxCallOnMainResult: + """Container to hold either the return value or exception raised by a function.""" + __slots__ = ("result", "exception") @@ -480,37 +482,33 @@ def wxCallOnMain( ) -> _WxCallOnMain_T: """Call a non-thread-safe wx function in a thread-safe way. - Using this function is prefferable over calling :fun:`wx.CallAfter` directly when you care about the return time value of the function. + Using this function is prefferable 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, and this thread will wait until it returns. - :return: Return value from calling the function with given positional and keyword arguments. + 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 = _WxCallOnMainResult() event = threading.Event() def functionWrapper(): - print("In wrapper.") try: - print("Calling function.") result.result = function(*args, **kwargs) except Exception: - print("Got an exception.") result.exception = sys.exception - print("Set event.") event.set() - print("Out of wrapper.") if wx.IsMainThread(): - print("On main thread, calling immediately.") functionWrapper() else: - print("In background, using call after.") wx.CallAfter(functionWrapper) - print("Waiting...") event.wait() - print("Done waiting.") + try: return result.result except AttributeError: From 1f0cfba87d2e2543c7b3f302675f35dbc903517b Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 3 Dec 2024 17:55:51 +1100 Subject: [PATCH 124/209] Code clean up and documentation improvements to gui.message.messageBox. --- source/gui/message.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/source/gui/message.py b/source/gui/message.py index a27227503ec..25a5cab5ac3 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -77,32 +77,32 @@ def messageBox( style: int = wx.OK | wx.CENTER, parent: Optional[wx.Window] = None, ) -> int: - """Display a message dialog. - Avoid using C{wx.MessageDialog} and C{wx.MessageBox} directly. - @param message: The message text. - @param caption: The caption (title) of the dialog. - @param style: Same as for wx.MessageBox. - @param parent: The parent window. - @return: Same as for wx.MessageBox. + """Display a modal message dialog. - `gui.message.messageBox` is a function which blocks the calling thread, - until a user responds to the modal dialog. + .. warning:: This function is deprecated. + Use :class:`MessageDialog` instead. + + This function blocks the calling thread until the user responds to the modal dialog. This function should be used when an answer is required before proceeding. - Consider using a custom subclass of a wxDialog if an answer is not required - or a default answer can be provided. + Consider using :class:`MessageDialog` or a custom :class:`wx.Dialog` subclass if an answer is not required, or a default answer can be provided. It's possible for multiple message boxes to be open at a time. - Before opening a new messageBox, use `isModalMessageBoxActive` - to check if another messageBox modal response is still pending. + 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. + Because an answer is required to continue after a modal messageBox is opened, some actions such as shutting down are prevented while NVDA is in a possibly uncertain state. + + :param message: The message text. + :param caption: The caption (title) of the dialog. + :param style: Same as for :func:`wx.MessageBox`, defaults to wx.OK | wx.CENTER. + :param parent: The parent window, defaults to None. + :return: Same as for :func:`wx.MessageBox`. """ warnings.warn( DeprecationWarning( "gui.message.messageBox is deprecated. Use gui.message.MessageDialog instead.", ), ) + # Import late to avoid circular import. from gui import mainFrame from gui.messageDialog import _messageBoxShim from gui.guiHelper import wxCallOnMain From e9942ce32aec2df4b4694899aaff057bc32042e6 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 3 Dec 2024 18:11:36 +1100 Subject: [PATCH 125/209] Documentation improvements to gui.nvdaControls.MessageDialog --- source/gui/nvdaControls.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/source/gui/nvdaControls.py b/source/gui/nvdaControls.py index ea72cc4b903..b4f93952406 100644 --- a/source/gui/nvdaControls.py +++ b/source/gui/nvdaControls.py @@ -272,7 +272,14 @@ def __init__(self, *args, **kwargs): class MessageDialog(messageDialog.MessageDialog): - """Adapter around messageDialog.MessageDialog for compatibility.""" + """Provides a more flexible message dialog. + + .. warning:: This class is deprecated. + Use :class:`gui.messageDialog.MessageDialog` instead. + This class is an adapter around that class, and will be removed in 2026.1. + + Consider overriding _addButtons, to set your own buttons and behaviour. + """ # We don't want the new message dialog's guard rails, as they may be incompatible with old code _FAIL_ON_NO_BUTTONS = False From 129fcead88bd5167b04666df034eeb9e868abad7 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 3 Dec 2024 18:13:52 +1100 Subject: [PATCH 126/209] Removed old gui.nvdaControls.MessageDialog code. --- source/gui/nvdaControls.py | 111 ------------------------------------- 1 file changed, 111 deletions(-) diff --git a/source/gui/nvdaControls.py b/source/gui/nvdaControls.py index b4f93952406..057af77e470 100644 --- a/source/gui/nvdaControls.py +++ b/source/gui/nvdaControls.py @@ -28,7 +28,6 @@ guiHelper, ) import winUser -import winsound from collections.abc import Callable @@ -328,116 +327,6 @@ def _addButtons(self, buttonHelper: guiHelper.ButtonHelper) -> None: self.addCancelButton(returnCode=wx.CANCEL) -class LegasyMessageDialog(DPIScaledDialog): - """Provides a more flexible message dialog. Consider overriding _addButtons, to set your own - buttons and behaviour. - """ - - # Dialog types currently supported - DIALOG_TYPE_STANDARD = 1 - DIALOG_TYPE_WARNING = 2 - DIALOG_TYPE_ERROR = 3 - - _DIALOG_TYPE_ICON_ID_MAP = { - # DIALOG_TYPE_STANDARD is not in the map, since we wish to use the default icon provided by wx - DIALOG_TYPE_ERROR: wx.ART_ERROR, - DIALOG_TYPE_WARNING: wx.ART_WARNING, - } - - _DIALOG_TYPE_SOUND_ID_MAP = { - # DIALOG_TYPE_STANDARD is not in the map, since there should be no sound for a standard dialog. - DIALOG_TYPE_ERROR: winsound.MB_ICONHAND, - DIALOG_TYPE_WARNING: winsound.MB_ICONASTERISK, - } - - def _addButtons(self, buttonHelper: guiHelper.ButtonHelper) -> None: - """Adds ok / cancel buttons. Can be overridden to provide alternative functionality.""" - ok = buttonHelper.addButton( - self, - id=wx.ID_OK, - # Translators: An ok button on a message dialog. - label=_("OK"), - ) - ok.SetDefault() - ok.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.OK)) - - cancel = buttonHelper.addButton( - self, - id=wx.ID_CANCEL, - # Translators: A cancel button on a message dialog. - label=_("Cancel"), - ) - 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): """ This implementation of a `gui.nvdaControls.MessageDialog`, provides `Continue` and `Cancel` buttons as its controls. From fc9e35b76965a3a07e17e7f882266e6f9a332a95 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 4 Dec 2024 10:27:55 +1100 Subject: [PATCH 127/209] Added commit references to changes --- user_docs/en/changes.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 5d2e598101a..3f1c13010a9 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -68,7 +68,7 @@ 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 syncronously calling wx functions from non-GUI threads, and getting their return value. +* A new function, `gui.guiHelper.wxCallOnMain`, has been added, which allows safely and syncronously calling wx functions from non-GUI threads, and getting their return value. (#17304) #### API Breaking Changes @@ -92,7 +92,7 @@ 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` function and `gui.nvdaControls.MessageDialog` class are deprecated. -Use `gui.message.MessageDialog` instead. +Use `gui.message.MessageDialog` instead. (#17304) ## 2024.4 From d2eef13aab3cd58d95b00c1322d8e0d613f7d2ea Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 4 Dec 2024 11:50:08 +1100 Subject: [PATCH 128/209] Fixed type hints for MethodCall --- tests/unit/test_messageDialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 6c5b901ce7d..d5fcda7eb87 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -57,7 +57,7 @@ class AddDefaultButtonHelpersArgList(NamedTuple): class MethodCall(NamedTuple): name: str - args: tuple[Any] = tuple() + args: tuple[Any, ...] = tuple() kwargs: dict[str, Any] = dict() From b55c0e5cfd1fadd0f6cffc889ec1afa6053137fe Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 4 Dec 2024 11:53:24 +1100 Subject: [PATCH 129/209] Changed getDialogState to return a dictionary of button IDs and their labels --- tests/unit/test_messageDialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index d5fcda7eb87..aaf1024ea62 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -41,7 +41,7 @@ def getDialogState(dialog: MessageDialog): As this is currently only used to ensure internal state does not change between calls, the order of return should be considered arbitrary. """ - return (dialog.GetMainButtonIds(),) + 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(),) From 90655247891adee12d93851ecb0da54c1fe4e794 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 4 Dec 2024 11:59:22 +1100 Subject: [PATCH 130/209] Fixed return from getDialogState --- tests/unit/test_messageDialog.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index aaf1024ea62..b804cdfe64c 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -41,11 +41,13 @@ def getDialogState(dialog: MessageDialog): 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 + 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, + ) class AddDefaultButtonHelpersArgList(NamedTuple): From 0782453f54820d73f6692c01853782e2ed825267 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 4 Dec 2024 12:03:20 +1100 Subject: [PATCH 131/209] Added tests for setting button labels. --- tests/unit/test_messageDialog.py | 61 ++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index b804cdfe64c..0fc15ca6622 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -242,6 +242,67 @@ def test_subsequent_add(self, _, func1: MethodCall, func2: MethodCall): 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 testSetButtonLabelNonexistantId(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_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( + ( + ( + "noExistantIds", + MethodCall("addYesNoButtons"), + MethodCall("setOkCancelLabels", ("Test 1", "Test 2")), + ), + ( + "ExistantAndNonexistantIds", + MethodCall("addYesNoCancelButtons"), + 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)) + class Test_MessageDialog_DefaultAction(MDTestBase): def test_defaultAction_defaultEscape_OkCancel(self): From c5f55b334d75d889664454403540f4cbb7063f0e Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 4 Dec 2024 13:35:32 +1100 Subject: [PATCH 132/209] Parameterised tests of setIcon and playSound to cover all dialog types. --- tests/unit/test_messageDialog.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 0fc15ca6622..c81a5d54e3c 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -75,14 +75,15 @@ def setUp(self) -> None: class Test_MessageDialog_Icons(MDTestBase): """Test that message dialog icons are set correctly.""" - def test_setIcon_with_type_with_icon(self, mocked_GetIconBundle: MagicMock): + @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() - type = DialogType.ERROR self.dialog._setIcon(type) mocked_GetIconBundle.assert_called_once() - def test_setIcon_with_type_without_icon(self, mocked_GetIconBundle: MagicMock): + @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) @@ -93,16 +94,16 @@ def test_setIcon_with_type_without_icon(self, mocked_GetIconBundle: MagicMock): class Test_MessageDialog_Sounds(MDTestBase): """Test that message dialog sounds are set and played correctly.""" - def test_playSound_with_type_with_Sound(self, mocked_MessageBeep: MagicMock): + @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.""" - type = DialogType.ERROR self.dialog._setSound(type) self.dialog._playSound() mocked_MessageBeep.assert_called_once() - def test_playSound_with_type_without_Sound(self, mocked_MessageBeep: MagicMock): + @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.""" - type = DialogType.STANDARD self.dialog._setSound(type) self.dialog._playSound() mocked_MessageBeep.assert_not_called() From b947d2086dd0adc811fa02eebb017ec30354765f Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 5 Dec 2024 10:58:07 +1100 Subject: [PATCH 133/209] Fixed test name --- tests/unit/test_messageDialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index c81a5d54e3c..200adb63f85 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -250,7 +250,7 @@ def test_setButtonLabelExistantId(self): self.dialog.setButtonLabel(ReturnCode.OK, NEW_LABEL) self.assertEqual(self.dialog.FindWindow(ReturnCode.OK).GetLabel(), NEW_LABEL) - def testSetButtonLabelNonexistantId(self): + 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) From acd1240200fb81c6e6f91f69f0b4600becf1f108 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 5 Dec 2024 11:07:58 +1100 Subject: [PATCH 134/209] Added test for adding buttons with custom overrides. --- tests/unit/test_messageDialog.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 200adb63f85..10dd6c7382c 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -304,6 +304,26 @@ def test_setButtonLabelsBadIds(self, _, setupFunc: MethodCall, setLabelFunc: Met 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) + class Test_MessageDialog_DefaultAction(MDTestBase): def test_defaultAction_defaultEscape_OkCancel(self): From adc2d2386c0c9c84bef51f133bccac69021934a3 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 5 Dec 2024 11:10:37 +1100 Subject: [PATCH 135/209] Removed use of deprecated log.warn --- source/gui/messageDialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index f393c5793ce..37ee7c7b724 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -859,7 +859,7 @@ def getAction() -> tuple[int, _Command]: id, command = getAction() if not command.closesDialog: - log.warn(f"Overriding command for {id=} to close dialog.") + log.debugWarning(f"Overriding command for {id=} to close dialog.") command = command._replace(closesDialog=True) return id, command From 2cfa0f8bb8f392273582959447e7996447c61268 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 5 Dec 2024 11:21:17 +1100 Subject: [PATCH 136/209] Added test for adding buttons with non-unique IDs --- tests/unit/test_messageDialog.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 10dd6c7382c..9a94a281f3e 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -11,6 +11,7 @@ import wx from gui.messageDialog import ( + DefaultButtonSet, MessageDialog, Button, EscapeCode, @@ -324,6 +325,11 @@ def test_addButtonFromButtonWithOverrides(self): 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)) + class Test_MessageDialog_DefaultAction(MDTestBase): def test_defaultAction_defaultEscape_OkCancel(self): From 8816470c0ca380e9201e59950110125cd3d96b8e Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 5 Dec 2024 11:32:13 +1100 Subject: [PATCH 137/209] Fixed use of FindWindowById to FindWindow(id) --- source/gui/messageDialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 37ee7c7b724..0dec78d7480 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -488,7 +488,7 @@ def setDefaultFocus(self, id: ReturnCode) -> Self: :raises KeyError: If no button with id exists. :return: The updated dialog. """ - if (win := self.FindWindowById(id)) is not None: + if (win := self.FindWindow(id)) is not None: self.SetDefaultItem(win) else: raise KeyError(f"Unable to find button with {id=}.") From 371848ac1981a8854faea7726161cb46c1c37f75 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 5 Dec 2024 11:33:24 +1100 Subject: [PATCH 138/209] Added tests for setting default focus. --- tests/unit/test_messageDialog.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 9a94a281f3e..f8e30a2c5a3 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -330,6 +330,16 @@ def test_addButtonsNonuniqueIds(self): with self.assertRaises(KeyError): self.dialog.addButtons((*DefaultButtonSet.OK_CANCEL, *DefaultButtonSet.YES_NO_CANCEL)) + def test_setDefaultFocus_goodId(self): + self.dialog.addOkCancelButtons() + self.dialog.setDefaultFocus(ReturnCode.CANCEL) + self.assertEqual(self.dialog.GetDefaultItem().GetId(), ReturnCode.CANCEL) + + def test_setDefaultFocus_badId(self): + self.dialog.addOkCancelButtons() + with self.assertRaises(KeyError): + self.dialog.setDefaultFocus(ReturnCode.APPLY) + class Test_MessageDialog_DefaultAction(MDTestBase): def test_defaultAction_defaultEscape_OkCancel(self): From 7a28c4bbabda3f6e668e11e2e05bd4b26ece826c Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 5 Dec 2024 11:49:19 +1100 Subject: [PATCH 139/209] Removed unnecessary error case from _getFallbackAction --- source/gui/messageDialog.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 0dec78d7480..d4234c1fe9d 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -799,12 +799,7 @@ def _getFallbackAction(self) -> tuple[int, _Command | None]: else: return affirmativeId, affirmativeAction else: - try: - return escapeId, self._commands[escapeId] - except KeyError: - raise RuntimeError( - f"Escape ID {escapeId} is not associated with a command", - ) + return escapeId, self._commands[escapeId] def _getFallbackActionOrFallback(self) -> tuple[int, _Command]: """Get a command that is guaranteed to close this dialog. From 1d033db199b5f7ffd0b91c1d266d846a3683bfbb Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 5 Dec 2024 12:17:58 +1100 Subject: [PATCH 140/209] Fixed logic for when the default focus is not in command registry --- source/gui/messageDialog.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index d4234c1fe9d..90b9a242a19 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -827,14 +827,13 @@ def getAction() -> tuple[int, _Command]: log.debug("fallback action was not in commands. This indicates a logic error.") # fallback action is unavailable. Try using the default focus instead. - try: - if (defaultFocus := self.GetDefaultItem()) is not None: - id = defaultFocus.GetId() + if (defaultFocus := self.GetDefaultItem()) is not None: + id = defaultFocus.GetId() + # Default focus does not have to be a command, for instance if a custom control has been added and made the default focus. + if (command := self._commands.get(id, None)) is not None: return id, self._commands[id] - except KeyError: - log.debug("Default focus was not in commands. This indicates a logic error.") - # Default focus is unavailable. Try using the first registered command that closes the dialog instead. + # Default focus is unavailable or not a command. Try using the first registered command that closes the dialog instead. firstCommand: tuple[int, _Command] | None = None for id, command in self._commands.items(): if command.closesDialog: From a4a4e7c8b79d2a77b5c0b0b16b90340e9e9592ee Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 5 Dec 2024 12:19:26 +1100 Subject: [PATCH 141/209] Added further fallback action tests --- tests/unit/test_messageDialog.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index f8e30a2c5a3..fe94c5a2817 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -468,6 +468,19 @@ def test_getFallbackActionOrFallback_custom_defaultAction(self): self.assertIsNotNone(command) self.assertTrue(command.closesDialog) + def test_getFallbackActionOrFallback_escapeIdNotACommand(self): + self.dialog.addOkCancelButtons() + super(MessageDialog, self.dialog).SetEscapeId(ReturnCode.CLOSE) + id, command = self.dialog._getFallbackActionOrFallback() + self.assertEqual(id, ReturnCode.OK) + self.assertIsNotNone(command) + self.assertTrue(command.closesDialog) + + def test_getFallbackAction_escapeCode_None(self): + self.dialog.addOkCancelButtons() + self.dialog.SetEscapeId(EscapeCode.NONE) + self.assertEqual(self.dialog._getFallbackAction(), (EscapeCode.NONE, None)) + class Test_MessageBoxShim(unittest.TestCase): def test_messageBoxButtonStylesToMessageDialogButtons(self): From acad2ffe7daf5bdc3f4c2241878ed47d0e7da90c Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 5 Dec 2024 12:42:54 +1100 Subject: [PATCH 142/209] Added more tests for setting button labels --- tests/unit/test_messageDialog.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index fe94c5a2817..338cb50a596 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -18,6 +18,7 @@ ReturnCode, DialogType, _MessageBoxButtonStylesToMessageDialogButtons, + _Command, ) from parameterized import parameterized from typing import Any, Iterable, NamedTuple @@ -258,6 +259,21 @@ def test_setButtonLabelNonexistantId(self): self.assertRaises(KeyError, self.dialog.setButtonLabel, ReturnCode.CANCEL, "test") self.assertEqual(oldState, getDialogState(self.dialog)) + def test_setButtonLabel_notAButton(self): + 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_setButtonLabels_countMismatch(self): + with self.assertRaises(ValueError): + 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" From b5a389505b8fceb2a9ebdeaa918774397ff23e10 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 5 Dec 2024 13:40:35 +1100 Subject: [PATCH 143/209] Refactored MdTestBase into WxTestBase -> MdTestBase --- tests/unit/test_messageDialog.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 338cb50a596..bdd54a75088 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -65,11 +65,18 @@ class MethodCall(NamedTuple): kwargs: dict[str, Any] = dict() -class MDTestBase(unittest.TestCase): - """Base class for test cases testing MessageDialog. Handles wx initialisation.""" +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) From 614e6495ce3ae22c74c6817e7f564f991716b920 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 5 Dec 2024 13:41:45 +1100 Subject: [PATCH 144/209] Added tests for threading --- tests/unit/test_messageDialog.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index bdd54a75088..6e1238bacfa 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -22,6 +22,7 @@ ) from parameterized import parameterized from typing import Any, Iterable, NamedTuple +from concurrent.futures import ThreadPoolExecutor NO_CALLBACK = (EscapeCode.NONE, None) @@ -505,6 +506,33 @@ def test_getFallbackAction_escapeCode_None(self): self.assertEqual(self.dialog._getFallbackAction(), (EscapeCode.NONE, None)) +class test_messageDialogThreading(WxTestBase): + def test_new_onNonmain(self): + with ThreadPoolExecutor(max_workers=1) as tpe: + with self.assertRaises(RuntimeError): + tpe.submit(MessageDialog.__new__, MessageDialog).result() + + def test_init_onNonMain(self): + dlg = MessageDialog.__new__(MessageDialog) + with ThreadPoolExecutor(max_workers=1) as tpe: + with self.assertRaises(RuntimeError): + tpe.submit(dlg.__init__, None, "Test").result() + + def test_show_onNonMain(self): + # self.app = wx.App() + dlg = MessageDialog(None, "Test") + with ThreadPoolExecutor(max_workers=1) as tpe: + with self.assertRaises(RuntimeError): + tpe.submit(dlg.Show).result() + + def test_showModal_onNonMain(self): + # self.app = wx.App() + dlg = MessageDialog(None, "Test") + with ThreadPoolExecutor(max_workers=1) as tpe: + with self.assertRaises(RuntimeError): + tpe.submit(dlg.ShowModal).result() + + class Test_MessageBoxShim(unittest.TestCase): def test_messageBoxButtonStylesToMessageDialogButtons(self): YES, NO, OK, CANCEL, HELP = wx.YES, wx.NO, wx.OK, wx.CANCEL, wx.HELP From b1a34578927ccc37f76e571cde2fd212d96245f8 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 5 Dec 2024 14:32:59 +1100 Subject: [PATCH 145/209] Fixed test case name --- tests/unit/test_messageDialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 6e1238bacfa..32f270cf503 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -506,7 +506,7 @@ def test_getFallbackAction_escapeCode_None(self): self.assertEqual(self.dialog._getFallbackAction(), (EscapeCode.NONE, None)) -class test_messageDialogThreading(WxTestBase): +class Test_MessageDialog_Threading(WxTestBase): def test_new_onNonmain(self): with ThreadPoolExecutor(max_workers=1) as tpe: with self.assertRaises(RuntimeError): From 020a78b59699c6f9923f59d71ee3ce93003c57ad Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 5 Dec 2024 15:02:50 +1100 Subject: [PATCH 146/209] Added unittests for showing and hiding --- tests/unit/test_messageDialog.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 32f270cf503..35f02e3df6a 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -533,6 +533,33 @@ def test_showModal_onNonMain(self): tpe.submit(dlg.ShowModal).result() +@patch.object(wx.Dialog, "Show") +class Test_MessageDialog_Show(MDTestBase): + def test_show_noButtons(self, mocked_show: MagicMock): + with self.assertRaises(RuntimeError): + self.dialog.Show() + mocked_show.assert_not_called() + + def test_show(self, mocked_show: MagicMock): + 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_show_noButtons(self, mocked_showModal: MagicMock, _): + with self.assertRaises(RuntimeError): + self.dialog.ShowModal() + mocked_showModal.assert_not_called() + + def test_show(self, mocked_showModal: MagicMock, _): + self.dialog.addOkButton() + self.dialog.ShowModal() + mocked_showModal.assert_called_once() + + class Test_MessageBoxShim(unittest.TestCase): def test_messageBoxButtonStylesToMessageDialogButtons(self): YES, NO, OK, CANCEL, HELP = wx.YES, wx.NO, wx.OK, wx.CANCEL, wx.HELP From 7559b3fe3561f54c3691eae8438d82ae19de9832 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 5 Dec 2024 16:45:36 +1100 Subject: [PATCH 147/209] Improved modal show tests. --- tests/unit/test_messageDialog.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 35f02e3df6a..d8d113e60ec 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -549,15 +549,24 @@ def test_show(self, mocked_show: MagicMock): @patch("gui.mainFrame") @patch.object(wx.Dialog, "ShowModal") class Test_MessageDialog_ShowModal(MDTestBase): - def test_show_noButtons(self, mocked_showModal: MagicMock, _): + def test_showModal_noButtons(self, mocked_showModal: MagicMock, _): with self.assertRaises(RuntimeError): self.dialog.ShowModal() mocked_showModal.assert_not_called() - def test_show(self, mocked_showModal: MagicMock, _): + def test_showModal(self, mocked_showModal: MagicMock, _): self.dialog.addOkButton() - self.dialog.ShowModal() - mocked_showModal.assert_called_once() + with patch("gui.message._messageBoxCounter") as mocked_messageBoxCounter: + mocked_messageBoxCounter.__iadd__.return_value = ( + mocked_messageBoxCounter.__isub__.return_value + ) = mocked_messageBoxCounter + self.dialog.ShowModal() + print(mocked_messageBoxCounter.mock_calls) + mocked_showModal.assert_called_once() + mocked_messageBoxCounter.__iadd__.assert_called_once() + mocked_messageBoxCounter.__isub__.assert_called_once() + + # raise Exception class Test_MessageBoxShim(unittest.TestCase): From 428a6ed7463e51cb3e0e112789ac9a42e89400aa Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 5 Dec 2024 17:13:07 +1100 Subject: [PATCH 148/209] Added tests for showEvent --- tests/unit/test_messageDialog.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index d8d113e60ec..1b7d09f2d67 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -569,6 +569,15 @@ def test_showModal(self, mocked_showModal: MagicMock, _): # raise Exception +class Test_MessageDialog_EventHandlers(WxTestBase): + def test_defaultFocus(self): + dialog = MessageDialog(None, "Test").addCancelButton(defaultFocus=True) + evt = wx.ShowEvent(dialog.GetId(), True) + with patch.object(wx.Window, "SetFocus") as mocked_setFocus: + dialog._onShowEvt(evt) + mocked_setFocus.assert_called_once() + + class Test_MessageBoxShim(unittest.TestCase): def test_messageBoxButtonStylesToMessageDialogButtons(self): YES, NO, OK, CANCEL, HELP = wx.YES, wx.NO, wx.OK, wx.CANCEL, wx.HELP From f4ed2d12ff69ecb71f9e6b60a59d346c156cda7c Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 10:22:13 +1100 Subject: [PATCH 149/209] Fixed implementation of _getFallbackAction, _getFallbackActionOrFallback, and _executeCommand, and methods that call them, including unit tests. --- source/gui/messageDialog.py | 80 +++++++++++++++++--------------- tests/unit/test_messageDialog.py | 76 +++++++++++++++--------------- 2 files changed, 81 insertions(+), 75 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 90b9a242a19..47cda7c12ea 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -777,7 +777,7 @@ def _realize_layout(self) -> None: if gui._isDebug(): log.debug(f"Layout completed in {time.time() - startTime:.3f} seconds") - def _getFallbackAction(self) -> tuple[int, _Command | None]: + def _getFallbackAction(self) -> _Command | None: """Get the fallback action of this dialog. :raises RuntimeError: If attempting to get the default command from commands fails. @@ -785,23 +785,23 @@ def _getFallbackAction(self) -> tuple[int, _Command | None]: """ escapeId = self.GetEscapeId() if escapeId == EscapeCode.NONE: - return escapeId, None + return None elif escapeId == EscapeCode.DEFAULT: affirmativeAction: _Command | None = None affirmativeId: int = self.GetAffirmativeId() for id, command in self._commands.items(): if id == ReturnCode.CANCEL: - return id, command + return command elif id == affirmativeId: affirmativeAction = command if affirmativeAction is None: - return EscapeCode.NONE, None + return None else: - return affirmativeId, affirmativeAction + return affirmativeAction else: - return escapeId, self._commands[escapeId] + return self._commands[escapeId] - def _getFallbackActionOrFallback(self) -> tuple[int, _Command]: + def _getFallbackActionOrFallback(self) -> _Command: """Get a command that is guaranteed to close this dialog. Commands are returned in the following order of preference: @@ -817,45 +817,54 @@ def _getFallbackActionOrFallback(self) -> tuple[int, _Command]: :return: Id and command of the default command. """ - def getAction() -> tuple[int, _Command]: + def getAction() -> _Command: # Try using the developer-specified fallback action. try: - id, action = self._getFallbackAction() - if action is not None: - return id, action + if (action := self._getFallbackAction()) is not None: + return action except KeyError: log.debug("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: - id = defaultFocus.GetId() # Default focus does not have to be a command, for instance if a custom control has been added and made the default focus. - if (command := self._commands.get(id, None)) is not None: - return id, self._commands[id] + 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. - firstCommand: tuple[int, _Command] | None = None - for id, command in self._commands.items(): - if command.closesDialog: - return id, command - if firstCommand is None: - firstCommand = (id, command) - # No commands that close the dialog have been registered. Use the first command instead. - if firstCommand is not None: - return firstCommand + 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.debug( "No commands have been registered. If the dialog is shown, this indicates a logic error.", ) + # firstCommand: tuple[int, _Command] | None = None + # for id, command in self._commands.items(): + # if command.closesDialog: + # return command + # if firstCommand is None: + # firstCommand = (id, command) + # No commands that close the dialog have been registered. Use the first command instead. + # if firstCommand is not None: + # return firstCommand + # else: + # log.debug( + # "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 EscapeCode.NONE, _Command(callback=None, closesDialog=True, ReturnCode=wx.ID_NONE) + return _Command(callback=None, closesDialog=True, ReturnCode=wx.ID_NONE) - id, command = getAction() + command = getAction() if not command.closesDialog: log.debugWarning(f"Overriding command for {id=} to close dialog.") command = command._replace(closesDialog=True) - return id, command + return command def _setButtonLabels(self, ids: Collection[ReturnCode], labels: Collection[str]): """Set a batch of button labels atomically. @@ -918,19 +927,19 @@ def _onCloseEvent(self, evt: wx.CloseEvent): if not evt.CanVeto(): # We must close the dialog, regardless of state. self.Hide() - self._execute_command(*self._getFallbackActionOrFallback()) + self._execute_command(self._getFallbackActionOrFallback()) self._instances.remove(self) 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. - id, command = self._getFallbackAction() - if id == EscapeCode.NONE or command is None or not command.closesDialog: + command = self._getFallbackAction() + if command is None or not command.closesDialog: evt.Veto() return self.Hide() - self._execute_command(id, command, _canCallClose=False) + self._execute_command(command, _canCallClose=False) self.Hide() if self.IsModal(): self.EndModal(self.GetReturnCode()) @@ -947,27 +956,22 @@ def _onButton(self, evt: wx.CommandEvent): id = evt.GetId() log.debug(f"Got button event on {id=}") try: - self._execute_command(id) + self._execute_command(self._commands[id]) except KeyError: log.debug(f"No command registered for {id=}.") def _execute_command( self, - id: int, - command: _Command | None = None, + command: _Command, *, _canCallClose: bool = True, ): """Execute a command on this dialog. - :param id: ID of the command to execute. - :param command: Command to execute, defaults to None. - If None, the command to execute will be looked up in the dialog's registered commands. + :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. """ - if command is None: - command = self._commands[id] callback, close, returnCode = command close &= _canCallClose if callback is not None: diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 1b7d09f2d67..8525caed75a 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -177,11 +177,13 @@ def test_addDefaultButtonHelpers( self.assertIsInstance(self.dialog.FindWindowById(id), wx.Button) with self.subTest("Test whether the fallback status is as expected."): self.assertEqual(self.dialog.hasDefaultAction, expectedHasFallback) - with self.subTest("Test whether getting the fallback action returns the expected id and action type"): - actualFallbackId, actualFallbackAction = self.dialog._getFallbackAction() - self.assertEqual(actualFallbackId, expectedFallbackId) + 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) @@ -202,8 +204,8 @@ def test_addButton_with_fallbackAction(self): closesDialog=True, ), ) - id, command = self.dialog._getFallbackAction() - self.assertEqual(id, ReturnCode.CUSTOM_1) + command = self.dialog._getFallbackAction() + self.assertEqual(command.ReturnCode, ReturnCode.CUSTOM_1) self.assertTrue(command.closesDialog) def test_addButton_with_non_closing_fallbackAction(self): @@ -216,8 +218,8 @@ def test_addButton_with_non_closing_fallbackAction(self): closesDialog=False, ), ) - id, command = self.dialog._getFallbackAction() - self.assertEqual(id, ReturnCode.CUSTOM_1) + command = self.dialog._getFallbackAction() + self.assertEqual(command.ReturnCode, ReturnCode.CUSTOM_1) self.assertTrue(command.closesDialog) @parameterized.expand( @@ -369,68 +371,68 @@ class Test_MessageDialog_DefaultAction(MDTestBase): def test_defaultAction_defaultEscape_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) - id, command = self.dialog._getFallbackAction() + command = self.dialog._getFallbackAction() with self.subTest("Test getting the fallback action."): - self.assertEqual(id, ReturnCode.CANCEL) + 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((id, command), self.dialog._getFallbackActionOrFallback()) + self.assertEqual(command, self.dialog._getFallbackActionOrFallback()) def test_defaultAction_defaultEscape_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) - id, command = self.dialog._getFallbackAction() + command = self.dialog._getFallbackAction() with self.subTest("Test getting the fallback action."): - self.assertEqual(id, ReturnCode.CANCEL) + 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((id, command), self.dialog._getFallbackActionOrFallback()) + self.assertEqual(command, self.dialog._getFallbackActionOrFallback()) def test_defaultAction_defaultEscape_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) - id, command = self.dialog._getFallbackAction() + command = self.dialog._getFallbackAction() with self.subTest("Test getting the fallback action."): - self.assertEqual(id, ReturnCode.OK) + 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((id, command), self.dialog._getFallbackActionOrFallback()) + self.assertEqual(command, self.dialog._getFallbackActionOrFallback()) def test_defaultAction_defaultEscape_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) - id, command = self.dialog._getFallbackAction() + command = self.dialog._getFallbackAction() with self.subTest("Test getting the fallback action."): - self.assertEqual(id, ReturnCode.OK) + 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((id, command), self.dialog._getFallbackActionOrFallback()) + self.assertEqual(command, self.dialog._getFallbackActionOrFallback()) def test_setDefaultAction_existant_action(self): """Test that setting the fallback action results in the correct action being returned from both getFallbackAction and getFallbackActionOrFallback.""" self.dialog.addYesNoButtons() self.dialog.setDefaultAction(ReturnCode.YES) - id, command = self.dialog._getFallbackAction() + command = self.dialog._getFallbackAction() with self.subTest("Test getting the fallback action."): - self.assertEqual(id, ReturnCode.YES) + 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((id, command), self.dialog._getFallbackActionOrFallback()) + self.assertEqual(command, self.dialog._getFallbackActionOrFallback()) def test_setDefaultAction_nonexistant_action(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.""" @@ -439,19 +441,19 @@ def test_setDefaultAction_nonexistant_action(self): with self.assertRaises(KeyError): self.dialog.setDefaultAction(ReturnCode.APPLY) with self.subTest("Test getting the fallback fallback action."): - self.assertEqual(self.dialog._getFallbackAction(), NO_CALLBACK) + self.assertIsNone(self.dialog._getFallbackAction()) def test_setDefaultAction_nonclosing_action(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 getting the fallback action."): + with self.subTest("Test setting the fallback action."): with self.assertRaises(ValueError): self.dialog.setDefaultAction(ReturnCode.APPLY) def test_getFallbackActionOrFallback_no_controls(self): """Test that getFallbackActionOrFallback returns wx.ID_NONE and a close command with no callback when the dialog has no buttons.""" - id, command = self.dialog._getFallbackActionOrFallback() - self.assertEqual(id, EscapeCode.NONE) + command = self.dialog._getFallbackActionOrFallback() + self.assertEqual(command.ReturnCode, EscapeCode.NONE) self.assertIsNotNone(command) self.assertTrue(command.closesDialog) @@ -459,8 +461,8 @@ def test_getFallbackActionOrFallback_no_defaultFocus_closing_button(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()) - id, command = self.dialog._getFallbackActionOrFallback() - self.assertEqual(id, ReturnCode.CLOSE) + command = self.dialog._getFallbackActionOrFallback() + self.assertEqual(command.ReturnCode, ReturnCode.CLOSE) self.assertIsNotNone(command) self.assertTrue(command.closesDialog) @@ -468,8 +470,8 @@ def test_getFallbackActionOrFallback_no_defaultFocus_no_closing_button(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()) - id, command = self.dialog._getFallbackActionOrFallback() - self.assertEqual(id, ReturnCode.APPLY) + command = self.dialog._getFallbackActionOrFallback() + self.assertEqual(command.ReturnCode, ReturnCode.APPLY) self.assertIsNotNone(command) self.assertTrue(command.closesDialog) @@ -478,8 +480,8 @@ def test_getFallbackActionOrFallback_no_defaultAction(self): self.dialog.addApplyButton().addCloseButton() self.dialog.setDefaultFocus(ReturnCode.CLOSE) self.assertEqual(self.dialog.GetDefaultItem().GetId(), ReturnCode.CLOSE) - id, command = self.dialog._getFallbackActionOrFallback() - self.assertEqual(id, ReturnCode.CLOSE) + command = self.dialog._getFallbackActionOrFallback() + self.assertEqual(command.ReturnCode, ReturnCode.CLOSE) self.assertIsNotNone(command) self.assertTrue(command.closesDialog) @@ -487,23 +489,23 @@ def test_getFallbackActionOrFallback_custom_defaultAction(self): """Test that getFallbackActionOrFallback returns the custom defaultAction if set.""" self.dialog.addApplyButton().addCloseButton() self.dialog.setDefaultAction(ReturnCode.CLOSE) - id, command = self.dialog._getFallbackActionOrFallback() - self.assertEqual(id, ReturnCode.CLOSE) + command = self.dialog._getFallbackActionOrFallback() + self.assertEqual(command.ReturnCode, ReturnCode.CLOSE) self.assertIsNotNone(command) self.assertTrue(command.closesDialog) def test_getFallbackActionOrFallback_escapeIdNotACommand(self): self.dialog.addOkCancelButtons() super(MessageDialog, self.dialog).SetEscapeId(ReturnCode.CLOSE) - id, command = self.dialog._getFallbackActionOrFallback() - self.assertEqual(id, ReturnCode.OK) + command = self.dialog._getFallbackActionOrFallback() + self.assertEqual(command.ReturnCode, ReturnCode.OK) self.assertIsNotNone(command) self.assertTrue(command.closesDialog) def test_getFallbackAction_escapeCode_None(self): self.dialog.addOkCancelButtons() self.dialog.SetEscapeId(EscapeCode.NONE) - self.assertEqual(self.dialog._getFallbackAction(), (EscapeCode.NONE, None)) + self.assertIsNone(self.dialog._getFallbackAction()) class Test_MessageDialog_Threading(WxTestBase): From 30b8c64b5b7826de562a17654b5c5e2c6ed5d3d9 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 12:00:59 +1100 Subject: [PATCH 150/209] Added tests for blocking dialogs. --- tests/unit/test_messageDialog.py | 139 ++++++++++++++++++++++++++++++- 1 file changed, 138 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 8525caed75a..6dbdc913dcf 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -7,7 +7,7 @@ from copy import deepcopy import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, PropertyMock, patch import wx from gui.messageDialog import ( @@ -53,6 +53,12 @@ def getDialogState(dialog: MessageDialog): ) +def mockDialogFactory(isBlocking: bool = False) -> MagicMock: + mock = MagicMock(spec_set=MessageDialog) + type(mock).isBlocking = PropertyMock(return_value=isBlocking) + return mock + + class AddDefaultButtonHelpersArgList(NamedTuple): func: str expectedButtons: Iterable[int] @@ -66,6 +72,12 @@ class MethodCall(NamedTuple): kwargs: dict[str, Any] = dict() +class FocusBlockingInstancesDialogs(NamedTuple): + dialog: MagicMock + expectedRaise: bool + expectedSetFocus: bool + + class WxTestBase(unittest.TestCase): """Base class for test cases which need wx to be initialised.""" @@ -580,6 +592,131 @@ def test_defaultFocus(self): mocked_setFocus.assert_called_once() +class Test_MessageDialog_Blocking(MDTestBase): + def tearDown(self) -> None: + MessageDialog._instances.clear() + super().tearDown() + + @parameterized.expand( + ( + ("noInstances", tuple(), False), + ("nonBlockingInstance", (mockDialogFactory(isBlocking=False),), False), + ("blockingInstance", (mockDialogFactory(isBlocking=True),), True), + ( + "onlyBlockingInstances", + (mockDialogFactory(isBlocking=True), mockDialogFactory(isBlocking=True)), + True, + ), + ( + "onlyNonblockingInstances", + (mockDialogFactory(isBlocking=False), mockDialogFactory(isBlocking=False)), + False, + ), + ( + "blockingFirstNonBlockingSecond", + (mockDialogFactory(isBlocking=True), mockDialogFactory(isBlocking=False)), + True, + ), + ( + "nonblockingFirstblockingSecond", + (mockDialogFactory(isBlocking=False), mockDialogFactory(isBlocking=True)), + True, + ), + ), + ) + def test_blockingInstancesExist( + self, + _, + instances: tuple[MagicMock], + expectedBlockingInstancesExist: bool, + ): + MessageDialog._instances.extend(instances) + print(MessageDialog._instances) + self.assertEqual(MessageDialog.BlockingInstancesExist(), expectedBlockingInstancesExist) + + @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]): + 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() + + class Test_MessageBoxShim(unittest.TestCase): def test_messageBoxButtonStylesToMessageDialogButtons(self): YES, NO, OK, CANCEL, HELP = wx.YES, wx.NO, wx.OK, wx.CANCEL, wx.HELP From 6f2d760ff421ac998f568e55e8ee93ffbf151a62 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 12:57:18 +1100 Subject: [PATCH 151/209] Fixed issue when force-closing non-modal dialog --- source/gui/messageDialog.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 47cda7c12ea..8f9c862292f 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -927,9 +927,10 @@ def _onCloseEvent(self, evt: wx.CloseEvent): if not evt.CanVeto(): # We must close the dialog, regardless of state. self.Hide() - self._execute_command(self._getFallbackActionOrFallback()) + self._execute_command(self._getFallbackActionOrFallback(), _canCallClose=False) self._instances.remove(self) - self.EndModal(self.GetReturnCode()) + if self.IsModal(): + self.EndModal(self.GetReturnCode()) self.Destroy() return if self.GetReturnCode() == 0: From e5054ab4c686d47bb583ffd3e2e095be36be6617 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 14:02:14 +1100 Subject: [PATCH 152/209] Renamed _execute_command to _executeCommand --- source/gui/messageDialog.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 8f9c862292f..7d298c9d02c 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -927,7 +927,7 @@ def _onCloseEvent(self, evt: wx.CloseEvent): if not evt.CanVeto(): # We must close the dialog, regardless of state. self.Hide() - self._execute_command(self._getFallbackActionOrFallback(), _canCallClose=False) + self._executeCommand(self._getFallbackActionOrFallback(), _canCallClose=False) self._instances.remove(self) if self.IsModal(): self.EndModal(self.GetReturnCode()) @@ -940,7 +940,7 @@ def _onCloseEvent(self, evt: wx.CloseEvent): evt.Veto() return self.Hide() - self._execute_command(command, _canCallClose=False) + self._executeCommand(command, _canCallClose=False) self.Hide() if self.IsModal(): self.EndModal(self.GetReturnCode()) @@ -957,11 +957,11 @@ def _onButton(self, evt: wx.CommandEvent): id = evt.GetId() log.debug(f"Got button event on {id=}") try: - self._execute_command(self._commands[id]) + self._executeCommand(self._commands[id]) except KeyError: log.debug(f"No command registered for {id=}.") - def _execute_command( + def _executeCommand( self, command: _Command, *, From ca5e0ad2fc81aa726ef48dc66f8fdfb28cd0b7f8 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 14:04:02 +1100 Subject: [PATCH 153/209] Added tests for _onCloseEvent --- tests/unit/test_messageDialog.py | 55 ++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 6dbdc913dcf..af35713403a 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -7,7 +7,7 @@ from copy import deepcopy import unittest -from unittest.mock import MagicMock, PropertyMock, patch +from unittest.mock import ANY, MagicMock, PropertyMock, patch import wx from gui.messageDialog import ( @@ -583,14 +583,57 @@ def test_showModal(self, mocked_showModal: MagicMock, _): # raise Exception -class Test_MessageDialog_EventHandlers(WxTestBase): - def test_defaultFocus(self): - dialog = MessageDialog(None, "Test").addCancelButton(defaultFocus=True) - evt = wx.ShowEvent(dialog.GetId(), True) +class Test_MessageDialog_EventHandlers(MDTestBase): + def test_onShowEvent_defaultFocus(self): + self.dialog.addOkButton().addCancelButton(defaultFocus=True) + evt = wx.ShowEvent(self.dialog.GetId(), True) with patch.object(wx.Window, "SetFocus") as mocked_setFocus: - dialog._onShowEvt(evt) + self.dialog._onShowEvt(evt) mocked_setFocus.assert_called_once() + def test_onCloseEvent_nonVetoable(self): + evt = wx.CloseEvent(wx.wxEVT_CLOSE_WINDOW, self.dialog.GetId()) + 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_onCloseEvent_noFallbackAction(self): + self.dialog.addYesNoButtons() + self.dialog.SetEscapeId(EscapeCode.NONE) + 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_onCloseEvent_fallbackAction(self): + 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) + class Test_MessageDialog_Blocking(MDTestBase): def tearDown(self) -> None: From d8073b4b56f0e00ae88e1730ed9a88d27501f0c5 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 14:34:51 +1100 Subject: [PATCH 154/209] Added test for closeInstances --- tests/unit/test_messageDialog.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index af35713403a..222060cf06a 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -759,6 +759,17 @@ def test_focusBlockingInstances(self, _, dialogs: tuple[FocusBlockingInstancesDi else: dialog.SetFocus.assert_not_called() + def test_closeNonblockingInstances(self): + 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): From 32308601302f7422d12df239a26c7069ce59a92a Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 15:06:32 +1100 Subject: [PATCH 155/209] Added tests for isBlocking --- tests/unit/test_messageDialog.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 222060cf06a..b58ab693311 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -677,6 +677,23 @@ def test_blockingInstancesExist( print(MessageDialog._instances) self.assertEqual(MessageDialog.BlockingInstancesExist(), expectedBlockingInstancesExist) + @parameterized.expand( + ( + ("modalWithFallback", True, True, True), + ("ModalWithoutFallback", True, False, True), + ("ModelessWithFallback", False, True, False), + ("ModelessWithoutFallback", False, False, True), + ), + ) + def test_isBlocking(self, _, isModal: bool, hasFallback: bool, expectedIsBlocking: bool): + with patch.object(self.dialog, "IsModal", return_value=isModal), patch.object( + type(self.dialog), + "hasDefaultAction", + new_callable=PropertyMock, + return_value=hasFallback, + ): + self.assertEqual(self.dialog.isBlocking, expectedIsBlocking) + @parameterized.expand( ( ( From 05c7540ad7701363c2bbacfc0f47e47c42e04aff Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 15:08:52 +1100 Subject: [PATCH 156/209] Renamed hasDefaultAction to hasFallback to match rename of defaultAction to fallback --- source/gui/messageDialog.py | 6 +++--- tests/unit/test_messageDialog.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 7d298c9d02c..c0ca6bfd5f5 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -334,7 +334,7 @@ def addButton( self.SetDefaultItem(button) if fallbackAction: self.setDefaultAction(buttonId) - self.EnableCloseButton(self.hasDefaultAction) + self.EnableCloseButton(self.hasFallback) self._isLayoutFullyRealized = False return self @@ -567,10 +567,10 @@ def ShowModal(self) -> ReturnCode: @property def isBlocking(self) -> bool: """Whether or not the dialog is blocking""" - return self.IsModal() or not self.hasDefaultAction + return self.IsModal() or not self.hasFallback @property - def hasDefaultAction(self) -> bool: + 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. diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index b58ab693311..f18a0ee74e9 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -188,7 +188,7 @@ def test_addDefaultButtonHelpers( 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.hasDefaultAction, expectedHasFallback) + self.assertEqual(self.dialog.hasFallback, expectedHasFallback) with self.subTest( "Test whether getting the fallback action returns the expected action type and return code", ): @@ -688,7 +688,7 @@ def test_blockingInstancesExist( def test_isBlocking(self, _, isModal: bool, hasFallback: bool, expectedIsBlocking: bool): with patch.object(self.dialog, "IsModal", return_value=isModal), patch.object( type(self.dialog), - "hasDefaultAction", + "hasFallback", new_callable=PropertyMock, return_value=hasFallback, ): From 8b22b04720af63d2c4e7e3ccad724688d74616ef Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 15:40:17 +1100 Subject: [PATCH 157/209] Added tests for executeCommand --- tests/unit/test_messageDialog.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index f18a0ee74e9..c02017ee7e7 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -7,7 +7,7 @@ from copy import deepcopy import unittest -from unittest.mock import ANY, MagicMock, PropertyMock, patch +from unittest.mock import ANY, MagicMock, Mock, PropertyMock, patch, sentinel import wx from gui.messageDialog import ( @@ -634,6 +634,31 @@ def test_onCloseEvent_fallbackAction(self): mocked_executeCommand.assert_called_once_with(ANY, _canCallClose=False) self.assertNotIn(self.dialog, MessageDialog._instances) + @parameterized.expand( + ( + (True, True, True), + (True, False, False), + (False, True, False), + (False, False, False), + ), + ) + def test_executeCommand(self, closesDialog: bool, canCallClose: bool, expectedCloseCalled: bool): + 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: From c419a589be0850bb77a64d4afa078611b7719269 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 15:52:41 +1100 Subject: [PATCH 158/209] Added guidance on dialog types --- source/gui/messageDialog.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index c0ca6bfd5f5..85fd202cae7 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -23,7 +23,7 @@ # TODO: Change to type statement when Python 3.12 or later is in use. -Callback_T: TypeAlias = Callable[[], Any] +_Callback_T: TypeAlias = Callable[[], Any] class _Missing_Type: @@ -72,8 +72,19 @@ class DialogType(Enum): """ 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 @@ -117,7 +128,7 @@ class Button(NamedTuple): label: str """The label to display on the button.""" - callback: Callback_T | None = None + callback: _Callback_T | None = None """The callback to call when the button is clicked.""" defaultFocus: bool = False @@ -197,7 +208,7 @@ class DefaultButtonSet(tuple[DefaultButton], Enum): class _Command(NamedTuple): """Internal representation of a command for a message dialog.""" - callback: Callback_T | None + 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.""" @@ -289,7 +300,7 @@ def addButton( /, label: str, *args, - callback: Callback_T | None = None, + callback: _Callback_T | None = None, defaultFocus: bool = False, fallbackAction: bool = False, closesDialog: bool = True, @@ -345,7 +356,7 @@ def _( /, *args, label: str | _Missing_Type = _MISSING, - callback: Callback_T | None | _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, From c26dfb538e4cdd14bc23db79d5c4d9def64beab9 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 18:00:58 +1100 Subject: [PATCH 159/209] Renamed setDefaultAction to setFallbackAction --- source/gui/messageDialog.py | 4 ++-- tests/unit/test_messageDialog.py | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 85fd202cae7..953d54b02f0 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -344,7 +344,7 @@ def addButton( if defaultFocus: self.SetDefaultItem(button) if fallbackAction: - self.setDefaultAction(buttonId) + self.setFallbackAction(buttonId) self.EnableCloseButton(self.hasFallback) self._isLayoutFullyRealized = False return self @@ -530,7 +530,7 @@ def SetEscapeId(self, id: ReturnCode | EscapeCode) -> Self: super().SetEscapeId(id) return self - def setDefaultAction(self, id: ReturnCode | EscapeCode) -> Self: + def setFallbackAction(self, id: ReturnCode | EscapeCode) -> Self: """See :meth:`MessageDialog.SetEscapeId`.""" return self.SetEscapeId(id) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index c02017ee7e7..294f8912074 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -432,10 +432,10 @@ def test_defaultAction_defaultEscape_CloseOk(self): ): self.assertEqual(command, self.dialog._getFallbackActionOrFallback()) - def test_setDefaultAction_existant_action(self): + def test_setFallbackAction_existant_action(self): """Test that setting the fallback action results in the correct action being returned from both getFallbackAction and getFallbackActionOrFallback.""" self.dialog.addYesNoButtons() - self.dialog.setDefaultAction(ReturnCode.YES) + self.dialog.setFallbackAction(ReturnCode.YES) command = self.dialog._getFallbackAction() with self.subTest("Test getting the fallback action."): self.assertEqual(command.ReturnCode, ReturnCode.YES) @@ -446,21 +446,21 @@ def test_setDefaultAction_existant_action(self): ): self.assertEqual(command, self.dialog._getFallbackActionOrFallback()) - def test_setDefaultAction_nonexistant_action(self): + def test_setFallbackAction_nonexistant_action(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.setDefaultAction(ReturnCode.APPLY) + self.dialog.setFallbackAction(ReturnCode.APPLY) with self.subTest("Test getting the fallback fallback action."): self.assertIsNone(self.dialog._getFallbackAction()) - def test_setDefaultAction_nonclosing_action(self): + def test_setFallbackAction_nonclosing_action(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.setDefaultAction(ReturnCode.APPLY) + self.dialog.setFallbackAction(ReturnCode.APPLY) def test_getFallbackActionOrFallback_no_controls(self): """Test that getFallbackActionOrFallback returns wx.ID_NONE and a close command with no callback when the dialog has no buttons.""" @@ -500,7 +500,7 @@ def test_getFallbackActionOrFallback_no_defaultAction(self): def test_getFallbackActionOrFallback_custom_defaultAction(self): """Test that getFallbackActionOrFallback returns the custom defaultAction if set.""" self.dialog.addApplyButton().addCloseButton() - self.dialog.setDefaultAction(ReturnCode.CLOSE) + self.dialog.setFallbackAction(ReturnCode.CLOSE) command = self.dialog._getFallbackActionOrFallback() self.assertEqual(command.ReturnCode, ReturnCode.CLOSE) self.assertIsNotNone(command) From 3b3be1b8b0473f7fa470edf945ebb1c90053e2c6 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 18:10:38 +1100 Subject: [PATCH 160/209] Doocstring improvements --- source/gui/messageDialog.py | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 953d54b02f0..04defc67cff 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -38,7 +38,7 @@ def __repr(self): class ReturnCode(IntEnum): - """Enumeration of possible returns from c{MessageDialog}.""" + """Enumeration of possible returns from :class:`MessageDialog`.""" OK = wx.ID_OK CANCEL = wx.ID_CANCEL @@ -56,7 +56,7 @@ class ReturnCode(IntEnum): class EscapeCode(IntEnum): - """Enumeration of the behavior of the escape key and programmatic attempts to close a c{MessageDialog}.""" + """Enumeration of the behavior of the escape key and programmatic attempts to close a :class:`MessageDialog`.""" NONE = wx.ID_NONE """The escape key should have no effect, and programatically attempting to close the dialog should fail.""" @@ -134,7 +134,7 @@ class Button(NamedTuple): defaultFocus: bool = False """Whether this button should explicitly be the default focused button. - :note: This only overrides the default focus. + ..note: This only overrides the default focus. If no buttons have this property, the first button will be the default focus. """ @@ -144,21 +144,21 @@ class Button(NamedTuple): 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. + ..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 c{MessageDialog} for information on how these buttons are handled. + ..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. + ..note: If None, the button's ID will be used as the return code when closing a modal dialog with this button. """ @@ -1023,11 +1023,10 @@ def _messageBoxShim(message: str, caption: str, style: int, parent: wx.Window | def _messageDialogReturnCodeToMessageBoxReturnCode(returnCode: ReturnCode) -> int: """Map from an instance of :class:`ReturnCode` to an int as returned by :fun:`wx.MessageBox`. - Note that only YES, NO, OK, CANCEL and HELP returns are supported by :fun:`wx.MessageBox`, and thus by this function. - :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: @@ -1047,10 +1046,9 @@ def _messageDialogReturnCodeToMessageBoxReturnCode(returnCode: ReturnCode) -> in def _messageBoxIconStylesToMessageDialogType(flags: int) -> DialogType: """Map from a bitmask of styles as expected by :fun:`wx.MessageBox` to a :Class:`DialogType`. - Note that 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`. - :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: @@ -1066,17 +1064,15 @@ def _messageBoxIconStylesToMessageDialogType(flags: int) -> DialogType: 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. - Note that :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 that 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. - 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): From 679b2a74d9d02342344e9306f3690628e1e140ac Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 18:23:13 +1100 Subject: [PATCH 161/209] Renamed EscapeCode members to be more self documenting --- source/gui/messageDialog.py | 16 ++++++++-------- tests/unit/test_messageDialog.py | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 04defc67cff..45bf8dc46ac 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -58,9 +58,9 @@ class ReturnCode(IntEnum): class EscapeCode(IntEnum): """Enumeration of the behavior of the escape key and programmatic attempts to close a :class:`MessageDialog`.""" - NONE = wx.ID_NONE + NO_FALLBACK = wx.ID_NONE """The escape key should have no effect, and programatically attempting to close the dialog should fail.""" - DEFAULT = wx.ID_ANY + 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. """ @@ -521,12 +521,12 @@ def SetEscapeId(self, id: ReturnCode | EscapeCode) -> Self: :raises ValueError: If the action with the given id does not close the dialog. :return: The updated dialog instance. """ - if id not in (EscapeCode.DEFAULT, EscapeCode.NONE): + 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.NONE) + self.EnableCloseButton(id != EscapeCode.NO_FALLBACK) super().SetEscapeId(id) return self @@ -587,12 +587,12 @@ def hasFallback(self) -> bool: Assumes that any explicit action (i.e. not EscapeCode.NONE or EscapeCode.DEFAULT) is valid. """ escapeId = self.GetEscapeId() - return escapeId != EscapeCode.NONE and ( + 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.DEFAULT + if escapeId == EscapeCode.CANCEL_OR_AFFIRMATIVE else True ) @@ -795,9 +795,9 @@ def _getFallbackAction(self) -> _Command | None: :return: The id and command of the fallback action. """ escapeId = self.GetEscapeId() - if escapeId == EscapeCode.NONE: + if escapeId == EscapeCode.NO_FALLBACK: return None - elif escapeId == EscapeCode.DEFAULT: + elif escapeId == EscapeCode.CANCEL_OR_AFFIRMATIVE: affirmativeAction: _Command | None = None affirmativeId: int = self.GetAffirmativeId() for id, command in self._commands.items(): diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 294f8912074..0a17b35346c 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -25,7 +25,7 @@ from concurrent.futures import ThreadPoolExecutor -NO_CALLBACK = (EscapeCode.NONE, None) +NO_CALLBACK = (EscapeCode.NO_FALLBACK, None) def dummyCallback1(*a): @@ -465,7 +465,7 @@ def test_setFallbackAction_nonclosing_action(self): def test_getFallbackActionOrFallback_no_controls(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.NONE) + self.assertEqual(command.ReturnCode, EscapeCode.NO_FALLBACK) self.assertIsNotNone(command) self.assertTrue(command.closesDialog) @@ -516,7 +516,7 @@ def test_getFallbackActionOrFallback_escapeIdNotACommand(self): def test_getFallbackAction_escapeCode_None(self): self.dialog.addOkCancelButtons() - self.dialog.SetEscapeId(EscapeCode.NONE) + self.dialog.SetEscapeId(EscapeCode.NO_FALLBACK) self.assertIsNone(self.dialog._getFallbackAction()) @@ -607,7 +607,7 @@ def test_onCloseEvent_nonVetoable(self): def test_onCloseEvent_noFallbackAction(self): self.dialog.addYesNoButtons() - self.dialog.SetEscapeId(EscapeCode.NONE) + 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( From 57ca154242ccf26312326288e4d78d37f0f8e9f8 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 18:26:06 +1100 Subject: [PATCH 162/209] Renamed _realize_layout to _realizeLayout in line with NV Access style --- source/gui/messageDialog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 45bf8dc46ac..1d4bb9eea00 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -544,7 +544,7 @@ def Show(self, show: bool = True) -> bool: if not show: return self.Hide() self._checkShowable() - self._realize_layout() + self._realizeLayout() log.debug("Showing") shown = super().Show(show) if shown: @@ -558,7 +558,7 @@ def ShowModal(self) -> ReturnCode: Attach buttons with :meth:`.addButton`, :meth:`.addButtons`, or any of their more specific helpers. """ self._checkShowable() - self._realize_layout() + self._realizeLayout() # We want to call `gui.message.showScriptModal` 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 @@ -768,7 +768,7 @@ def _checkMainThread(cls, check: bool | None = None): if check and not wx.IsMainThread(): raise RuntimeError("Message dialogs can only be used from the main thread.") - def _realize_layout(self) -> None: + def _realizeLayout(self) -> None: """Perform layout adjustments prior to showing the dialog.""" if self._isLayoutFullyRealized: return From d00e5ea8872772bfe337fe44f5dbc85015a323c2 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 18:46:31 +1100 Subject: [PATCH 163/209] Added a bunch of missing docstrings --- source/gui/messageDialog.py | 2 ++ tests/unit/test_messageDialog.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 1d4bb9eea00..670a5d3268d 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -184,6 +184,8 @@ class DefaultButton(Button, Enum): class DefaultButtonSet(tuple[DefaultButton], Enum): + """Commonly needed button combinations.""" + OK_CANCEL = ( DefaultButton.OK, DefaultButton.CANCEL, diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 0a17b35346c..81ea92e57f7 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -54,6 +54,11 @@ def getDialogState(dialog: MessageDialog): 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 @@ -282,6 +287,7 @@ def test_setButtonLabelNonexistantId(self): self.assertEqual(oldState, getDialogState(self.dialog)) def test_setButtonLabel_notAButton(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( @@ -294,6 +300,7 @@ def test_setButtonLabel_notAButton(self): def test_setButtonLabels_countMismatch(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): @@ -369,11 +376,13 @@ def test_addButtonsNonuniqueIds(self): self.dialog.addButtons((*DefaultButtonSet.OK_CANCEL, *DefaultButtonSet.YES_NO_CANCEL)) def test_setDefaultFocus_goodId(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_setDefaultFocus_badId(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) @@ -507,6 +516,7 @@ def test_getFallbackActionOrFallback_custom_defaultAction(self): self.assertTrue(command.closesDialog) def test_getFallbackActionOrFallback_escapeIdNotACommand(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() @@ -515,6 +525,7 @@ def test_getFallbackActionOrFallback_escapeIdNotACommand(self): self.assertTrue(command.closesDialog) def test_getFallbackAction_escapeCode_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()) @@ -522,11 +533,13 @@ def test_getFallbackAction_escapeCode_None(self): class Test_MessageDialog_Threading(WxTestBase): def test_new_onNonmain(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_init_onNonMain(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): @@ -534,12 +547,14 @@ def test_init_onNonMain(self): def test_show_onNonMain(self): # self.app = wx.App() + """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_showModal_onNonMain(self): + """Test that showing a MessageDialog modally on a non-GUI thread fails.""" # self.app = wx.App() dlg = MessageDialog(None, "Test") with ThreadPoolExecutor(max_workers=1) as tpe: @@ -550,11 +565,13 @@ def test_showModal_onNonMain(self): @patch.object(wx.Dialog, "Show") class Test_MessageDialog_Show(MDTestBase): def test_show_noButtons(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() @@ -564,11 +581,13 @@ def test_show(self, mocked_show: MagicMock): @patch.object(wx.Dialog, "ShowModal") class Test_MessageDialog_ShowModal(MDTestBase): def test_showModal_noButtons(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 = ( @@ -585,6 +604,7 @@ def test_showModal(self, mocked_showModal: MagicMock, _): class Test_MessageDialog_EventHandlers(MDTestBase): def test_onShowEvent_defaultFocus(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: @@ -593,6 +613,7 @@ def test_onShowEvent_defaultFocus(self): def test_onCloseEvent_nonVetoable(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( @@ -606,6 +627,7 @@ def test_onCloseEvent_nonVetoable(self): self.assertNotIn(self.dialog, MessageDialog._instances) def test_onCloseEvent_noFallbackAction(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()) @@ -621,6 +643,7 @@ def test_onCloseEvent_noFallbackAction(self): self.assertIn(self.dialog, MessageDialog._instances) def test_onCloseEvent_fallbackAction(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) @@ -643,6 +666,7 @@ def test_onCloseEvent_fallbackAction(self): ), ) 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) @@ -698,6 +722,7 @@ def test_blockingInstancesExist( instances: tuple[MagicMock], expectedBlockingInstancesExist: bool, ): + """Test that blockingInstancesExist is correct in a number of situations.""" MessageDialog._instances.extend(instances) print(MessageDialog._instances) self.assertEqual(MessageDialog.BlockingInstancesExist(), expectedBlockingInstancesExist) @@ -711,6 +736,7 @@ def test_blockingInstancesExist( ), ) 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", @@ -789,6 +815,7 @@ def test_isBlocking(self, _, isModal: bool, hasFallback: bool, expectedIsBlockin ), ) 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: @@ -802,6 +829,7 @@ def test_focusBlockingInstances(self, _, dialogs: tuple[FocusBlockingInstancesDi 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)) @@ -815,6 +843,7 @@ def test_closeNonblockingInstances(self): 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), From b38630a4c10c5c5278a69d141fb182af9b9e9ceb Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 18:51:06 +1100 Subject: [PATCH 164/209] Removed some commented out code --- source/gui/messageDialog.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 670a5d3268d..624edb0914d 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -856,20 +856,6 @@ def getAction() -> _Command: "No commands have been registered. If the dialog is shown, this indicates a logic error.", ) - # firstCommand: tuple[int, _Command] | None = None - # for id, command in self._commands.items(): - # if command.closesDialog: - # return command - # if firstCommand is None: - # firstCommand = (id, command) - # No commands that close the dialog have been registered. Use the first command instead. - # if firstCommand is not None: - # return firstCommand - # else: - # log.debug( - # "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) From aa207b5d878b9a29d458b63d5bb84554965dabd9 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 19:23:03 +1100 Subject: [PATCH 165/209] Initial docs --- .../dev/developerGuide/developerGuide.md | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) diff --git a/projectDocs/dev/developerGuide/developerGuide.md b/projectDocs/dev/developerGuide/developerGuide.md index 3d7e1f7ab18..2a0a6d78762 100644 --- a/projectDocs/dev/developerGuide/developerGuide.md +++ b/projectDocs/dev/developerGuide/developerGuide.md @@ -1440,3 +1440,207 @@ 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. + +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 +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 +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 read_changelog(): + ... # Do something + +def download_update(): + ... # Do something + +def remind_later(): + ... # Do something + +updateDialog = MessageDialog( + mainFrame, + "An update is available. " + "Would you like to download it now?", + "Update", + buttons=None, +).addButton( + ReturnCode.YES, + label="Yes", + default_focus=True, + callback=download_update +).addButton( + ReturnCode.NO, + default_action=True, + label="Remind me later", + callback=remind_later +).addButton( + ReturnCode.HELP, + closes_dialog=False, + label="What's new", + callback=read_changelog +) + +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 + +It is worth interrogating the fallback action further. +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 ok button if there is no cancel button but there is an ok button[^fn_defaultFallbackOk], or otherwise `None`. +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. + +[^fn_defaultFallbackOk]: In actuality, the value of `dialog.GetAffirmativeId()` is used to find the button to use as fallback if using `EscapeCode.CANCEL_OR_AFFIRMATIVE` and there is no button with `id=ReturnCode.CANCEL`. +You can use `dialog.SetAffirmativeId(id)` to change it, if desired. + +In some cases, the dialog may be forced to close. +If the dialog is shown modally, a fallback action will be used if the default action is `EscapeCode.NO_FALLBACK` or not found. +The order of precedence 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 + +Just like wxPython, most of the methods available on `MessageDialog` and its instances are **not** thread safe. +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 +some_function(arg1, arg2, kw1=value1, kw2=value2) +# on the GUI thread: +wxCallOnMain(some_function, 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[^fn_defaultFocus]. | +| `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[^fn_fallbackAction]. | +| `closesDialog` | `bool` | `True` | Whether the button should close the dialog when pressed[^fn_closesDialog]. | +| `returnCode` | `ReturnCode` or `None` | `None` | Value to return when a modal dialog is closed. If `None`, the button's ID will be used. | + +[^fn_defaultFocus]: 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. + +[^fn_fallbackAction]: `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 `ReturnCode.OK` if there is no button with `id=ReturnCode.OK`), 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. + +[^fn_closesDialog]: 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". | From ec434463a8bbf285fe95f4ba4ab0858fdda9fa54 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 19:33:54 +1100 Subject: [PATCH 166/209] Added docstring to __init__ --- source/gui/messageDialog.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index 624edb0914d..a28bf28ed8b 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -253,6 +253,18 @@ def __init__( 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) From 6b5e084c70c119b4d68ef1e584c0c59b34e90c2a Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 19:53:49 +1100 Subject: [PATCH 167/209] Added documentation of convenience methods to dev docs --- projectDocs/dev/developerGuide/developerGuide.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/projectDocs/dev/developerGuide/developerGuide.md b/projectDocs/dev/developerGuide/developerGuide.md index 2a0a6d78762..5605f559f83 100644 --- a/projectDocs/dev/developerGuide/developerGuide.md +++ b/projectDocs/dev/developerGuide/developerGuide.md @@ -1644,3 +1644,16 @@ The following default button sets are available: | `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". | + +#### 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. +The following convenience class methods are provided: + +| 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` | From ead1ec5630adf6672c408a34cf7f50e590851785 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 20:00:04 +1100 Subject: [PATCH 168/209] Moved DialogType to gui.message --- source/gui/message.py | 19 +++++++++++++++++++ source/gui/messageDialog.py | 19 +------------------ tests/unit/test_messageDialog.py | 2 +- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/source/gui/message.py b/source/gui/message.py index 25a5cab5ac3..cbec22baded 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -5,6 +5,7 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. +from enum import IntEnum import threading from typing import Optional @@ -148,3 +149,21 @@ def displayError(self, parentWindow: wx.Window): style=wx.OK | wx.ICON_ERROR, parent=parentWindow, ) + + +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 diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index a28bf28ed8b..a94632bac62 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -11,6 +11,7 @@ import wx import gui +from gui.message import ReturnCode from .contextHelp import ContextHelpMixin from .dpiScalingHelper import DpiScalingHelperMixinWithoutInit @@ -37,24 +38,6 @@ def __repr(self): """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`.""" diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 81ea92e57f7..62dfb2b926c 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -10,12 +10,12 @@ from unittest.mock import ANY, MagicMock, Mock, PropertyMock, patch, sentinel import wx +from gui.message import ReturnCode from gui.messageDialog import ( DefaultButtonSet, MessageDialog, Button, EscapeCode, - ReturnCode, DialogType, _MessageBoxButtonStylesToMessageDialogButtons, _Command, From 0cf5f156ca5c56d0acb232703c6888004250cc21 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 20:01:37 +1100 Subject: [PATCH 169/209] Moved EscapeCode to gui.message --- source/gui/message.py | 11 +++++++++++ source/gui/messageDialog.py | 15 ++------------- tests/unit/test_messageDialog.py | 3 +-- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/source/gui/message.py b/source/gui/message.py index cbec22baded..80de5ef65d5 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -167,3 +167,14 @@ class ReturnCode(IntEnum): 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. + """ diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index a94632bac62..a9cac22936f 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -3,7 +3,7 @@ # 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 -from enum import Enum, IntEnum, auto +from enum import Enum, auto import time from typing import Any, Literal, NamedTuple, TypeAlias, Self import winsound @@ -11,7 +11,7 @@ import wx import gui -from gui.message import ReturnCode +from gui.message import EscapeCode, ReturnCode from .contextHelp import ContextHelpMixin from .dpiScalingHelper import DpiScalingHelperMixinWithoutInit @@ -38,17 +38,6 @@ def __repr(self): """Sentinel for discriminating between `None` and an actually omitted argument.""" -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. diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 62dfb2b926c..34f74e88d34 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -10,12 +10,11 @@ from unittest.mock import ANY, MagicMock, Mock, PropertyMock, patch, sentinel import wx -from gui.message import ReturnCode +from gui.message import EscapeCode, ReturnCode from gui.messageDialog import ( DefaultButtonSet, MessageDialog, Button, - EscapeCode, DialogType, _MessageBoxButtonStylesToMessageDialogButtons, _Command, From 4f86bfdac512d760e1b9efc275d9d5ea6e922a30 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 20:09:55 +1100 Subject: [PATCH 170/209] Moved DialogType to gui.message --- source/gui/message.py | 64 ++++++++++++++++++++++++++++---- source/gui/messageDialog.py | 53 +------------------------- source/gui/nvdaControls.py | 9 +++-- tests/unit/test_messageDialog.py | 3 +- 4 files changed, 65 insertions(+), 64 deletions(-) diff --git a/source/gui/message.py b/source/gui/message.py index 80de5ef65d5..c5ad1c957d5 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -5,14 +5,14 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. -from enum import IntEnum import threading -from typing import Optional - -import wx import warnings +import winsound +from enum import Enum, IntEnum, auto +from typing import Optional import extensionPoints +import wx _messageBoxCounterLock = threading.Lock() _messageBoxCounter = 0 @@ -104,12 +104,13 @@ def messageBox( ), ) # Import late to avoid circular import. - from gui import mainFrame - from gui.messageDialog import _messageBoxShim - from gui.guiHelper import wxCallOnMain import core from logHandler import log + from gui import mainFrame + from gui.guiHelper import wxCallOnMain + from gui.messageDialog import _messageBoxShim + if not core._hasShutdownBeenTriggered: res = wxCallOnMain(_messageBoxShim, message, caption, style, parent=parent or mainFrame) else: @@ -178,3 +179,52 @@ class EscapeCode(IntEnum): """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 diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py index a9cac22936f..63612b0545f 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -3,7 +3,7 @@ # 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 -from enum import Enum, auto +from enum import Enum import time from typing import Any, Literal, NamedTuple, TypeAlias, Self import winsound @@ -11,7 +11,7 @@ import wx import gui -from gui.message import EscapeCode, ReturnCode +from gui.message import DialogType, EscapeCode, ReturnCode from .contextHelp import ContextHelpMixin from .dpiScalingHelper import DpiScalingHelperMixinWithoutInit @@ -38,55 +38,6 @@ def __repr(self): """Sentinel for discriminating between `None` and an actually omitted argument.""" -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.""" diff --git a/source/gui/nvdaControls.py b/source/gui/nvdaControls.py index 057af77e470..f5ad2a21ab0 100644 --- a/source/gui/nvdaControls.py +++ b/source/gui/nvdaControls.py @@ -23,6 +23,7 @@ FlagValueEnum as FeatureFlagEnumT, ) from gui import messageDialog +import gui.message from .dpiScalingHelper import DpiScalingHelperMixin from . import ( guiHelper, @@ -290,14 +291,14 @@ class MessageDialog(messageDialog.MessageDialog): DIALOG_TYPE_ERROR = 3 @staticmethod - def _legasyDialogTypeToDialogType(dialogType: int) -> messageDialog.DialogType: + def _legasyDialogTypeToDialogType(dialogType: int) -> gui.message.DialogType: match dialogType: case MessageDialog.DIALOG_TYPE_ERROR: - return messageDialog.DialogType.ERROR + return gui.message.DialogType.ERROR case MessageDialog.DIALOG_TYPE_WARNING: - return messageDialog.DialogType.WARNING + return gui.message.DialogType.WARNING case _: - return messageDialog.DialogType.STANDARD + return gui.message.DialogType.STANDARD def __new__(cls, *args, **kwargs): warnings.warn( diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 34f74e88d34..88f99e093f9 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -10,12 +10,11 @@ from unittest.mock import ANY, MagicMock, Mock, PropertyMock, patch, sentinel import wx -from gui.message import EscapeCode, ReturnCode +from gui.message import DialogType, EscapeCode, ReturnCode from gui.messageDialog import ( DefaultButtonSet, MessageDialog, Button, - DialogType, _MessageBoxButtonStylesToMessageDialogButtons, _Command, ) From 6bb8a4c69c65f4c774a6b997343e39cec2b24b7c Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 22:04:46 +1100 Subject: [PATCH 171/209] Moved over the rest of gui.messageDialog to gui.message --- source/documentationUtils.py | 4 +- source/gui/__init__.py | 4 +- source/gui/blockAction.py | 11 +- source/gui/message.py | 1014 +++++++++++++++++++++++++++++- source/gui/messageDialog.py | 1012 ----------------------------- source/gui/nvdaControls.py | 3 +- tests/unit/test_messageDialog.py | 11 +- 7 files changed, 1026 insertions(+), 1033 deletions(-) diff --git a/source/documentationUtils.py b/source/documentationUtils.py index c84b6112c5f..943c71a3320 100644 --- a/source/documentationUtils.py +++ b/source/documentationUtils.py @@ -13,7 +13,6 @@ from logHandler import log import ui import queueHandler -from gui.message import messageBox import wx @@ -65,6 +64,9 @@ def reportNoDocumentation(fileName: str, useMsgBox: bool = False) -> None: f"Documentation not found ({fileName}): possible cause - running from source without building user docs.", ) if useMsgBox: + # Import late to avoid circular impoort. + from gui.message import messageBox + messageBox( noDocMessage, # Translators: the title of an error message dialog diff --git a/source/gui/__init__.py b/source/gui/__init__.py index baef1c32003..2e50f3710f7 100644 --- a/source/gui/__init__.py +++ b/source/gui/__init__.py @@ -31,7 +31,7 @@ # be cautious when removing messageBox, ) -from .messageDialog import MessageDialog +from .message import MessageDialog from . import blockAction from .speechDict import ( DefaultDictionaryDialog, @@ -71,7 +71,7 @@ import winUser import api import NVDAState -from gui.messageDialog import MessageDialog as NMD +from gui.message import MessageDialog as NMD if NVDAState._allowDeprecatedAPI(): diff --git a/source/gui/blockAction.py b/source/gui/blockAction.py index 55270779939..4f0fb3d73a1 100644 --- a/source/gui/blockAction.py +++ b/source/gui/blockAction.py @@ -14,14 +14,19 @@ from speech.priorities import SpeechPriority import ui from utils.security import isLockScreenModeActive, isRunningOnSecureDesktop -from gui.message import isModalMessageBoxActive import core +def _isModalMessageBoxActive() -> bool: + from gui.message import isModalMessageBoxActive + + return isModalMessageBoxActive() + + def _modalDialogOpenCallback(): """Focus any open blocking :class:`MessageDialog` instances.""" # Import late to avoid circular import - from gui.messageDialog import MessageDialog + from gui.message import MessageDialog if MessageDialog.BlockingInstancesExist(): MessageDialog.FocusBlockingInstances() @@ -47,7 +52,7 @@ 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"), diff --git a/source/gui/message.py b/source/gui/message.py index c5ad1c957d5..d1014a70930 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -5,15 +5,27 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. +from collections import deque +from collections.abc import Callable, Collection +from functools import partialmethod, singledispatchmethod import threading +import time import warnings import winsound from enum import Enum, IntEnum, auto -from typing import Optional +from typing import Any, Literal, NamedTuple, Optional, Self, TypeAlias + import extensionPoints import wx +from logHandler import log +from .dpiScalingHelper import DpiScalingHelperMixinWithoutInit +from .guiHelper import SIPABCMeta, wxCallOnMain +from . import guiHelper +import gui + + _messageBoxCounterLock = threading.Lock() _messageBoxCounter = 0 @@ -105,14 +117,9 @@ def messageBox( ) # Import late to avoid circular import. import core - from logHandler import log - - from gui import mainFrame - from gui.guiHelper import wxCallOnMain - from gui.messageDialog import _messageBoxShim if not core._hasShutdownBeenTriggered: - res = wxCallOnMain(_messageBoxShim, message, caption, style, parent=parent or mainFrame) + 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 @@ -152,6 +159,21 @@ def displayError(self, parentWindow: wx.Window): ) +# 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): + 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`.""" @@ -228,3 +250,981 @@ def _windowsSoundId(self) -> int | None: 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. + """ + + _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._onShowEvt, source=self) + self.Bind(wx.EVT_ACTIVATE, self._onDialogActivated, source=self) + self.Bind(wx.EVT_CLOSE, self._onCloseEvent) + self.Bind(wx.EVT_BUTTON, self._onButton) + + # 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) + contentsSizer.addDialogDismissButtons(buttonHelper) + 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) + + # 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 modelessly. + :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("Showing") + shown = super().Show(show) + if shown: + log.debug("Adding 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 `gui.message.showScriptModal` 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 + # Import late to avoid circular import. + from .message import displayDialogAsModal + + log.debug("Adding to instances") + self._instances.append(self) + log.debug("Showing 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 vito 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. + + :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. + + :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. + + :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) + from gui import mainFrame + + if self.Parent == 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. + + :raises RuntimeError: If attempting to get the default command from commands fails. + :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.debug("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.debug( + "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 _onDialogActivated(self, evt: wx.ActivateEvent): + evt.Skip() + + def _onShowEvt(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() + 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) + 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. + command = self._getFallbackAction() + 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 destroy") + self.DestroyLater() + log.debug("Removing from instances") + self._instances.remove(self) + + def _onButton(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 _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/messageDialog.py b/source/gui/messageDialog.py index 63612b0545f..1a5956ecb40 100644 --- a/source/gui/messageDialog.py +++ b/source/gui/messageDialog.py @@ -2,1015 +2,3 @@ # Copyright (C) 2024 NV Access Limited # This file may be used under the terms of the GNU General Public License, version 2 or later. # For more details see: https://www.gnu.org/licenses/gpl-2.0.html - -from enum import Enum -import time -from typing import Any, Literal, NamedTuple, TypeAlias, Self -import winsound - -import wx - -import gui -from gui.message import DialogType, EscapeCode, ReturnCode - -from .contextHelp import ContextHelpMixin -from .dpiScalingHelper import DpiScalingHelperMixinWithoutInit -from .guiHelper import SIPABCMeta, wxCallOnMain -from gui import guiHelper -from functools import partialmethod, singledispatchmethod -from collections import deque -from collections.abc import Collection, Callable -from logHandler import log - - -# 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): - return "MISSING" - - -_MISSING = _Missing_Type() -"""Sentinel for discriminating between `None` and an actually omitted argument.""" - - -class Button(NamedTuple): - """A button to add to a message dialog.""" - - id: ReturnCode - """The ID to use for this button. - - This will be returned after showing the dialog modally. - It is also used to modify the button later. - """ - - label: str - """The label to display on the button.""" - - callback: _Callback_T | None = None - """The callback to call when the button is clicked.""" - - defaultFocus: bool = False - """Whether this button should explicitly be the default focused button. - - ..note: This only overrides the default focus. - If no buttons have this property, the first button will be the default focus. - """ - - fallbackAction: bool = False - """Whether this button is the fallback action. - - The fallback action is called when the user presses escape, the title bar close button, or the system menu close item. - It is also called when programatically closing the dialog, such as when shutting down NVDA. - - ..note: This only sets whether to override the fallback action. - `EscapeCode.DEFAULT` may still result in this button being the fallback action, even if `fallbackAction=False`. - """ - - closesDialog: bool = True - """Whether this button should close the dialog when clicked. - - ..note: Buttons with fallbackAction=True and closesDialog=False are not supported. - See the documentation of :class:`MessageDialog` for information on how these buttons are handled. - """ - - returnCode: ReturnCode | None = None - """Override for the default return code, which is the button's ID. - - ..note: If None, the button's ID will be used as the return code when closing a modal dialog with this button. - """ - - -class DefaultButton(Button, Enum): - """Default buttons for message dialogs.""" - - # Translators: An ok button on a message dialog. - OK = Button(id=ReturnCode.OK, label=_("OK")) - # Translators: A yes button on a message dialog. - YES = Button(id=ReturnCode.YES, label=_("&Yes")) - # Translators: A no button on a message dialog. - NO = Button(id=ReturnCode.NO, label=_("&No")) - # Translators: A cancel button on a message dialog. - CANCEL = Button(id=ReturnCode.CANCEL, label=_("Cancel")) - # Translators: A save button on a message dialog. - SAVE = Button(id=ReturnCode.SAVE, label=_("&Save")) - # Translators: An apply button on a message dialog. - APPLY = Button(id=ReturnCode.APPLY, label=_("&Apply"), closesDialog=False) - # Translators: A close button on a message dialog. - CLOSE = Button(id=ReturnCode.CLOSE, label=_("Close")) - # Translators: A help button on a message dialog. - HELP = Button(id=ReturnCode.HELP, label=_("Help"), closesDialog=False) - - -class DefaultButtonSet(tuple[DefaultButton], Enum): - """Commonly needed button combinations.""" - - OK_CANCEL = ( - DefaultButton.OK, - DefaultButton.CANCEL, - ) - YES_NO = ( - DefaultButton.YES, - DefaultButton.NO, - ) - YES_NO_CANCEL = ( - DefaultButton.YES, - DefaultButton.NO, - DefaultButton.CANCEL, - ) - SAVE_NO_CANCEL = ( - DefaultButton.SAVE, - # Translators: A don't save button on a message dialog. - DefaultButton.NO.value._replace(label=_("Do&n't save")), - DefaultButton.CANCEL, - ) - - -class _Command(NamedTuple): - """Internal representation of a command for a message dialog.""" - - callback: _Callback_T | None - """The callback function to be executed. Defaults to None.""" - closesDialog: bool - """Indicates whether the dialog should be closed after the command is executed. Defaults to True.""" - ReturnCode: ReturnCode - - -class MessageDialog(DpiScalingHelperMixinWithoutInit, ContextHelpMixin, wx.Dialog, metaclass=SIPABCMeta): - """Provides a more flexible message dialog. - - Creating dialogs with this class is extremely flexible. You can create a dialog, passing almost all parameters to the initialiser, and only call `Show` or `ShowModal` on the instance. - You can also call the initialiser with very few arguments, and modify the dialog by calling methods on the created instance. - Mixing and matching both patterns is also allowed. - - When subclassing this class, you can override `_addButtons` and `_addContents` to insert custom buttons or contents that you want your subclass to always have. - """ - - _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._onShowEvt, source=self) - self.Bind(wx.EVT_ACTIVATE, self._onDialogActivated, source=self) - self.Bind(wx.EVT_CLOSE, self._onCloseEvent) - self.Bind(wx.EVT_BUTTON, self._onButton) - - # 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) - contentsSizer.addDialogDismissButtons(buttonHelper) - 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) - - # 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 modelessly. - :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("Showing") - shown = super().Show(show) - if shown: - log.debug("Adding 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 `gui.message.showScriptModal` 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 - # Import late to avoid circular import. - from .message import displayDialogAsModal - - log.debug("Adding to instances") - self._instances.append(self) - log.debug("Showing 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 vito 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. - - :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. - - :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. - - :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) - from gui import mainFrame - - if self.Parent == 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. - - :raises RuntimeError: If attempting to get the default command from commands fails. - :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.debug("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.debug( - "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 _onDialogActivated(self, evt: wx.ActivateEvent): - evt.Skip() - - def _onShowEvt(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() - 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) - 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. - command = self._getFallbackAction() - 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 destroy") - self.DestroyLater() - log.debug("Removing from instances") - self._instances.remove(self) - - def _onButton(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 _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 f5ad2a21ab0..c7b8e7781f5 100644 --- a/source/gui/nvdaControls.py +++ b/source/gui/nvdaControls.py @@ -22,7 +22,6 @@ FeatureFlag, FlagValueEnum as FeatureFlagEnumT, ) -from gui import messageDialog import gui.message from .dpiScalingHelper import DpiScalingHelperMixin from . import ( @@ -271,7 +270,7 @@ def __init__(self, *args, **kwargs): DpiScalingHelperMixin.__init__(self, self.GetHandle()) -class MessageDialog(messageDialog.MessageDialog): +class MessageDialog(gui.message.MessageDialog): """Provides a more flexible message dialog. .. warning:: This class is deprecated. diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 88f99e093f9..38a470c1923 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -10,18 +10,17 @@ from unittest.mock import ANY, MagicMock, Mock, PropertyMock, patch, sentinel import wx -from gui.message import DialogType, EscapeCode, ReturnCode -from gui.messageDialog import ( - DefaultButtonSet, - MessageDialog, - Button, +from gui.message import _Command, DefaultButtonSet, DialogType, EscapeCode, ReturnCode +from gui.message import ( _MessageBoxButtonStylesToMessageDialogButtons, - _Command, ) 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) From 9ebe8cc47b8764bbae53b0b769b5f549436063e8 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 22:19:41 +1100 Subject: [PATCH 172/209] Updated copyright years --- source/documentationUtils.py | 2 +- source/gui/blockAction.py | 2 +- source/gui/message.py | 36 +++++++++++++----------------------- 3 files changed, 15 insertions(+), 25 deletions(-) diff --git a/source/documentationUtils.py b/source/documentationUtils.py index 943c71a3320..6ffe7cf8826 100644 --- a/source/documentationUtils.py +++ b/source/documentationUtils.py @@ -1,6 +1,6 @@ # -*- coding: UTF-8 -*- # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2006-2023 NV Access Limited, Łukasz Golonka +# Copyright (C) 2006-2024 NV Access Limited, Łukasz Golonka # This file may be used under the terms of the GNU General Public License, version 2 or later. # For more details see: https://www.gnu.org/licenses/gpl-2.0.html diff --git a/source/gui/blockAction.py b/source/gui/blockAction.py index 4f0fb3d73a1..4be0ea21a02 100644 --- a/source/gui/blockAction.py +++ b/source/gui/blockAction.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2023 NV Access Limited, Cyrille Bougot +# Copyright (C) 2023-2024 NV Access Limited, Cyrille Bougot # This file is covered by the GNU General Public License. # See the file COPYING for more details. diff --git a/source/gui/message.py b/source/gui/message.py index d1014a70930..2fd33bff4f7 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -1,30 +1,30 @@ # -*- coding: UTF-8 -*- # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2006-2022 NV Access Limited, Peter Vágner, Aleksey Sadovoy, Mesar Hameed, Joseph Lee, +# Copyright (C) 2006-2024 NV Access Limited, Peter Vágner, Aleksey Sadovoy, Mesar Hameed, Joseph Lee, # Thomas Stivers, Babbage B.V., Accessolutions, Julien Cochuyt # This file is covered by the GNU General Public License. # See the file COPYING for more details. -from collections import deque -from collections.abc import Callable, Collection -from functools import partialmethod, singledispatchmethod 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 - +import core import extensionPoints import wx - from logHandler import log -from .dpiScalingHelper import DpiScalingHelperMixinWithoutInit -from .guiHelper import SIPABCMeta, wxCallOnMain -from . import guiHelper + import gui +from . import guiHelper +from .dpiScalingHelper import DpiScalingHelperMixinWithoutInit +from .guiHelper import SIPABCMeta, wxCallOnMain _messageBoxCounterLock = threading.Lock() _messageBoxCounter = 0 @@ -65,19 +65,17 @@ def displayDialogAsModal(dialog: wx.Dialog) -> int: Because an answer is required to continue after a modal messageBox is opened, some actions such as shutting down are prevented while NVDA is in a possibly uncertain state. """ - from gui import mainFrame - global _messageBoxCounter with _messageBoxCounterLock: _messageBoxCounter += 1 try: if not dialog.GetParent(): - mainFrame.prePopup() + gui.mainFrame.prePopup() res = dialog.ShowModal() finally: if not dialog.GetParent(): - mainFrame.postPopup() + gui.mainFrame.postPopup() with _messageBoxCounterLock: _messageBoxCounter -= 1 @@ -115,9 +113,6 @@ def messageBox( "gui.message.messageBox is deprecated. Use gui.message.MessageDialog instead.", ), ) - # Import late to avoid circular import. - import core - if not core._hasShutdownBeenTriggered: res = wxCallOnMain(_messageBoxShim, message, caption, style, parent=parent or gui.mainFrame) else: @@ -711,12 +706,9 @@ def ShowModal(self) -> ReturnCode: self._checkShowable() self._realizeLayout() - # We want to call `gui.message.showScriptModal` 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`. + # 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 - # Import late to avoid circular import. - from .message import displayDialogAsModal - log.debug("Adding to instances") self._instances.append(self) log.debug("Showing modal") @@ -928,9 +920,7 @@ def _realizeLayout(self) -> None: log.debug("Laying out message dialog") self._messageControl.Wrap(self.scaleSize(self.GetSize().Width)) self._mainSizer.Fit(self) - from gui import mainFrame - - if self.Parent == mainFrame: + 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: From 8df3553343fa5bc9cfefc1ac05e5e2bdf4d6f8d9 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 22:36:47 +1100 Subject: [PATCH 173/209] Improvements to tests --- tests/unit/test_messageDialog.py | 56 ++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 38a470c1923..e9ea4e7aae5 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -80,6 +80,12 @@ class FocusBlockingInstancesDialogs(NamedTuple): expectedSetFocus: bool +class SubsequentCallArgList(NamedTuple): + label: str + meth1: MethodCall + meth2: MethodCall + + class WxTestBase(unittest.TestCase): """Base class for test cases which need wx to be initialised.""" @@ -135,7 +141,7 @@ def test_playSoundWithTypeWithoutSound(self, mocked_MessageBeep: MagicMock, type class Test_MessageDialog_Buttons(MDTestBase): @parameterized.expand( - [ + ( AddDefaultButtonHelpersArgList( func="addOkButton", expectedButtons=(wx.ID_OK,), @@ -173,7 +179,7 @@ class Test_MessageDialog_Buttons(MDTestBase): expectedHasFallback=True, expectedFallbackId=wx.ID_CANCEL, ), - ], + ), ) def test_addDefaultButtonHelpers( self, @@ -238,25 +244,25 @@ def test_addButton_with_non_closing_fallbackAction(self): @parameterized.expand( ( - ( + SubsequentCallArgList( "buttons_same_id", - MethodCall("addOkButton", kwargs={"callback": dummyCallback1}), - MethodCall("addOkButton", kwargs={"callback": dummyCallback2}), + meth1=MethodCall("addOkButton", kwargs={"callback": dummyCallback1}), + meth2=MethodCall("addOkButton", kwargs={"callback": dummyCallback2}), ), - ( + SubsequentCallArgList( "Button_then_ButtonSet_containing_same_id", - MethodCall("addOkButton"), - MethodCall("addOkCancelButtons"), + meth1=MethodCall("addOkButton"), + meth2=MethodCall("addOkCancelButtons"), ), - ( + SubsequentCallArgList( "ButtonSet_then_Button_with_id_from_set", - MethodCall("addOkCancelButtons"), - MethodCall("addOkButton"), + meth1=MethodCall("addOkCancelButtons"), + meth2=MethodCall("addOkButton"), ), - ( + SubsequentCallArgList( "ButtonSets_containing_same_id", - MethodCall("addOkCancelButtons"), - MethodCall("addYesNoCancelButtons"), + meth1=MethodCall("addOkCancelButtons"), + meth2=MethodCall("addYesNoCancelButtons"), ), ), ) @@ -321,15 +327,15 @@ def test_setSomeButtonLabels(self): @parameterized.expand( ( - ( + SubsequentCallArgList( "noExistantIds", - MethodCall("addYesNoButtons"), - MethodCall("setOkCancelLabels", ("Test 1", "Test 2")), + meth1=MethodCall("addYesNoButtons"), + meth2=MethodCall("setOkCancelLabels", ("Test 1", "Test 2")), ), - ( + SubsequentCallArgList( "ExistantAndNonexistantIds", - MethodCall("addYesNoCancelButtons"), - MethodCall("setOkCancelLabels", ("Test 1", "Test 2")), + meth1=MethodCall("addYesNoCancelButtons"), + meth2=MethodCall("setOkCancelLabels", ("Test 1", "Test 2")), ), ), ) @@ -656,13 +662,13 @@ def test_onCloseEvent_fallbackAction(self): @parameterized.expand( ( - (True, True, True), - (True, False, False), - (False, True, False), - (False, False, False), + ("closableCanCallClose", True, True, True), + ("ClosableCannotCallClose", True, False, False), + ("UnclosableCanCallClose", False, True, False), + ("UnclosableCannotCallClose", False, False, False), ), ) - def test_executeCommand(self, closesDialog: bool, canCallClose: bool, expectedCloseCalled: bool): + 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() From e9b95d0b0685f9efb4b9cfe801ec11bf709bc031 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 22:43:03 +1100 Subject: [PATCH 174/209] Renamed tests to be more in line with NVDA style --- tests/unit/test_messageDialog.py | 64 ++++++++++++++++---------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index e9ea4e7aae5..480860a5419 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -207,14 +207,14 @@ def test_addDefaultButtonHelpers( else: self.assertIsNone(actualFallbackAction) - def test_addButton_with_defaultFocus(self): + 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_addButton_with_fallbackAction(self): + def test_addButtonWithFallbackAction(self): """Test adding a button with fallback action.""" self.dialog.addButton( Button( @@ -228,7 +228,7 @@ def test_addButton_with_fallbackAction(self): self.assertEqual(command.ReturnCode, ReturnCode.CUSTOM_1) self.assertTrue(command.closesDialog) - def test_addButton_with_non_closing_fallbackAction(self): + def test_addButtonWithNonClosingFallbackAction(self): """Test adding a button with fallback action that does not close the dialog.""" self.dialog.addButton( Button( @@ -266,7 +266,7 @@ def test_addButton_with_non_closing_fallbackAction(self): ), ), ) - def test_subsequent_add(self, _, func1: MethodCall, func2: MethodCall): + 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) @@ -289,7 +289,7 @@ def test_setButtonLabelNonexistantId(self): self.assertRaises(KeyError, self.dialog.setButtonLabel, ReturnCode.CANCEL, "test") self.assertEqual(oldState, getDialogState(self.dialog)) - def test_setButtonLabel_notAButton(self): + 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. @@ -301,7 +301,7 @@ def test_setButtonLabel_notAButton(self): with self.assertRaises(TypeError): self.dialog.setButtonLabel(messageControlId, "test") - def test_setButtonLabels_countMismatch(self): + 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")) @@ -378,13 +378,13 @@ def test_addButtonsNonuniqueIds(self): with self.assertRaises(KeyError): self.dialog.addButtons((*DefaultButtonSet.OK_CANCEL, *DefaultButtonSet.YES_NO_CANCEL)) - def test_setDefaultFocus_goodId(self): + 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_setDefaultFocus_badId(self): + 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): @@ -392,7 +392,7 @@ def test_setDefaultFocus_badId(self): class Test_MessageDialog_DefaultAction(MDTestBase): - def test_defaultAction_defaultEscape_OkCancel(self): + 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() @@ -405,7 +405,7 @@ def test_defaultAction_defaultEscape_OkCancel(self): ): self.assertEqual(command, self.dialog._getFallbackActionOrFallback()) - def test_defaultAction_defaultEscape_CancelOk(self): + 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() @@ -418,7 +418,7 @@ def test_defaultAction_defaultEscape_CancelOk(self): ): self.assertEqual(command, self.dialog._getFallbackActionOrFallback()) - def test_defaultAction_defaultEscape_OkClose(self): + 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() @@ -431,7 +431,7 @@ def test_defaultAction_defaultEscape_OkClose(self): ): self.assertEqual(command, self.dialog._getFallbackActionOrFallback()) - def test_defaultAction_defaultEscape_CloseOk(self): + 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() @@ -444,7 +444,7 @@ def test_defaultAction_defaultEscape_CloseOk(self): ): self.assertEqual(command, self.dialog._getFallbackActionOrFallback()) - def test_setFallbackAction_existant_action(self): + 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) @@ -458,7 +458,7 @@ def test_setFallbackAction_existant_action(self): ): self.assertEqual(command, self.dialog._getFallbackActionOrFallback()) - def test_setFallbackAction_nonexistant_action(self): + 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."): @@ -467,21 +467,21 @@ def test_setFallbackAction_nonexistant_action(self): with self.subTest("Test getting the fallback fallback action."): self.assertIsNone(self.dialog._getFallbackAction()) - def test_setFallbackAction_nonclosing_action(self): + 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_getFallbackActionOrFallback_no_controls(self): + 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_getFallbackActionOrFallback_no_defaultFocus_closing_button(self): + 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()) @@ -490,7 +490,7 @@ def test_getFallbackActionOrFallback_no_defaultFocus_closing_button(self): self.assertIsNotNone(command) self.assertTrue(command.closesDialog) - def test_getFallbackActionOrFallback_no_defaultFocus_no_closing_button(self): + 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()) @@ -499,7 +499,7 @@ def test_getFallbackActionOrFallback_no_defaultFocus_no_closing_button(self): self.assertIsNotNone(command) self.assertTrue(command.closesDialog) - def test_getFallbackActionOrFallback_no_defaultAction(self): + 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) @@ -509,7 +509,7 @@ def test_getFallbackActionOrFallback_no_defaultAction(self): self.assertIsNotNone(command) self.assertTrue(command.closesDialog) - def test_getFallbackActionOrFallback_custom_defaultAction(self): + def test_getFallbackActionOrFallbackCustomDefaultAction(self): """Test that getFallbackActionOrFallback returns the custom defaultAction if set.""" self.dialog.addApplyButton().addCloseButton() self.dialog.setFallbackAction(ReturnCode.CLOSE) @@ -518,7 +518,7 @@ def test_getFallbackActionOrFallback_custom_defaultAction(self): self.assertIsNotNone(command) self.assertTrue(command.closesDialog) - def test_getFallbackActionOrFallback_escapeIdNotACommand(self): + 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) @@ -527,7 +527,7 @@ def test_getFallbackActionOrFallback_escapeIdNotACommand(self): self.assertIsNotNone(command) self.assertTrue(command.closesDialog) - def test_getFallbackAction_escapeCode_None(self): + 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) @@ -535,20 +535,20 @@ def test_getFallbackAction_escapeCode_None(self): class Test_MessageDialog_Threading(WxTestBase): - def test_new_onNonmain(self): + 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_init_onNonMain(self): + 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_show_onNonMain(self): + def test_showOnNonMain(self): # self.app = wx.App() """Test that showing a MessageDialog on a non-GUI thread fails.""" dlg = MessageDialog(None, "Test") @@ -556,7 +556,7 @@ def test_show_onNonMain(self): with self.assertRaises(RuntimeError): tpe.submit(dlg.Show).result() - def test_showModal_onNonMain(self): + def test_showModalOnNonMain(self): """Test that showing a MessageDialog modally on a non-GUI thread fails.""" # self.app = wx.App() dlg = MessageDialog(None, "Test") @@ -567,7 +567,7 @@ def test_showModal_onNonMain(self): @patch.object(wx.Dialog, "Show") class Test_MessageDialog_Show(MDTestBase): - def test_show_noButtons(self, mocked_show: MagicMock): + def test_showNoButtons(self, mocked_show: MagicMock): """Test that showing a MessageDialog with no buttons fails.""" with self.assertRaises(RuntimeError): self.dialog.Show() @@ -583,7 +583,7 @@ def test_show(self, mocked_show: MagicMock): @patch("gui.mainFrame") @patch.object(wx.Dialog, "ShowModal") class Test_MessageDialog_ShowModal(MDTestBase): - def test_showModal_noButtons(self, mocked_showModal: MagicMock, _): + def test_showModalNoButtons(self, mocked_showModal: MagicMock, _): """Test that showing a MessageDialog modally with no buttons fails.""" with self.assertRaises(RuntimeError): self.dialog.ShowModal() @@ -606,7 +606,7 @@ def test_showModal(self, mocked_showModal: MagicMock, _): class Test_MessageDialog_EventHandlers(MDTestBase): - def test_onShowEvent_defaultFocus(self): + 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) @@ -614,7 +614,7 @@ def test_onShowEvent_defaultFocus(self): self.dialog._onShowEvt(evt) mocked_setFocus.assert_called_once() - def test_onCloseEvent_nonVetoable(self): + 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) @@ -629,7 +629,7 @@ def test_onCloseEvent_nonVetoable(self): mocked_executeCommand.assert_called_once_with(ANY, _canCallClose=False) self.assertNotIn(self.dialog, MessageDialog._instances) - def test_onCloseEvent_noFallbackAction(self): + 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) @@ -645,7 +645,7 @@ def test_onCloseEvent_noFallbackAction(self): self.assertTrue(evt.GetVeto()) self.assertIn(self.dialog, MessageDialog._instances) - def test_onCloseEvent_fallbackAction(self): + 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()) From a85058550e54208e5ff62500d958944b50ef3190 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 22:49:33 +1100 Subject: [PATCH 175/209] Removed testing items from NVDA menu. --- source/gui/__init__.py | 36 +----------------------------------- 1 file changed, 1 insertion(+), 35 deletions(-) diff --git a/source/gui/__init__.py b/source/gui/__init__.py index 2e50f3710f7..2d9714aefce 100644 --- a/source/gui/__init__.py +++ b/source/gui/__init__.py @@ -30,8 +30,8 @@ # messageBox is accessed through `gui.messageBox` as opposed to `gui.message.messageBox` throughout NVDA, # be cautious when removing messageBox, + MessageDialog, ) -from .message import MessageDialog from . import blockAction from .speechDict import ( DefaultDictionaryDialog, @@ -71,7 +71,6 @@ import winUser import api import NVDAState -from gui.message import MessageDialog as NMD if NVDAState._allowDeprecatedAPI(): @@ -577,30 +576,6 @@ def onConfigProfilesCommand(self, evt): ProfilesDialog(mainFrame).Show() self.postPopup() - def onModelessOkCancelDialog(self, evt): - self.prePopup() - dlg = ( - NMD( - self, - "This is a modeless dialog with OK and Cancel buttons. Test that:\n" - "- The dialog appears correctly both visually and to NVDA\n" - "- The dialog has the expected buttons\n" - "- Pressing the Ok or Cancel button has the intended effect\n" - "- Pressing Esc has the intended effect\n" - "- Pressing Alt+F4 has the intended effect\n" - "- Using the close icon/system menu close item has the intended effect\n" - "- You are still able to interact with NVDA's GUI\n" - "- Exiting NVDA does not cause errors", - "Non-modal OK/Cancel Dialog", - buttons=None, - ) - .addOkButton(callback=lambda: messageBox("You pressed OK!")) - .addCancelButton(callback=lambda: messageBox("You pressed Cancel!")) - ) - - dlg.ShowModal() - self.postPopup() - class SysTrayIcon(wx.adv.TaskBarIcon): def __init__(self, frame: MainFrame): @@ -707,15 +682,6 @@ def __init__(self, frame: MainFrame): ) self.Bind(wx.EVT_MENU, frame.onExitCommand, item) - dialogMenu = wx.Menu() - item = dialogMenu.Append(wx.ID_ANY, "Ok") - item = dialogMenu.Append(wx.ID_ANY, "Ok and Cancel") - self.Bind(wx.EVT_MENU, frame.onModelessOkCancelDialog, item) - item = dialogMenu.Append(wx.ID_ANY, "Yes and No") - item = dialogMenu.Append(wx.ID_ANY, "Yes, No and Cancel") - - self.menu.AppendSubMenu(dialogMenu, "&Dialog") - self.Bind(wx.adv.EVT_TASKBAR_LEFT_DOWN, self.onActivate) self.Bind(wx.adv.EVT_TASKBAR_RIGHT_DOWN, self.onActivate) From a4b82e62326d800bd74d2ac0882f76666fc4d6d7 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 22:59:27 +1100 Subject: [PATCH 176/209] Updated dev docs with information on importing --- projectDocs/dev/developerGuide/developerGuide.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/projectDocs/dev/developerGuide/developerGuide.md b/projectDocs/dev/developerGuide/developerGuide.md index 5605f559f83..860b363c648 100644 --- a/projectDocs/dev/developerGuide/developerGuide.md +++ b/projectDocs/dev/developerGuide/developerGuide.md @@ -1447,6 +1447,17 @@ Please see the `EventExtensionPoints` class documentation for more information, 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 2ce6839275158154dfd7110f83fe24e8db163139 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 23:06:39 +1100 Subject: [PATCH 177/209] Updated changes --- user_docs/en/changes.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 3f1c13010a9..4907dde3d81 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -69,6 +69,8 @@ Add-ons will need to be re-tested and have their manifest updated. * 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 syncronously 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`. #### API Breaking Changes From 18f8fc9d4ef9d414f418bd3fb0c34aa0dc9af1d0 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 6 Dec 2024 23:17:42 +1100 Subject: [PATCH 178/209] Deleted old messageDialog.py file --- source/gui/messageDialog.py | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 source/gui/messageDialog.py diff --git a/source/gui/messageDialog.py b/source/gui/messageDialog.py deleted file mode 100644 index 1a5956ecb40..00000000000 --- a/source/gui/messageDialog.py +++ /dev/null @@ -1,4 +0,0 @@ -# A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2024 NV Access Limited -# This file may be used under the terms of the GNU General Public License, version 2 or later. -# For more details see: https://www.gnu.org/licenses/gpl-2.0.html From be3f22391d59eceab65ae79fb19463df1158f35b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 6 Dec 2024 12:21:38 +0000 Subject: [PATCH 179/209] Pre-commit auto-fix --- tests/unit/test_messageDialog.py | 61 ++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 480860a5419..13e7d130088 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -619,11 +619,14 @@ def test_onCloseEventNonVetoable(self): """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: + 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) @@ -635,10 +638,13 @@ def test_onCloseEventNoFallbackAction(self): 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: + 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() @@ -650,11 +656,14 @@ def test_onCloseEventFallbackAction(self): 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: + 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) @@ -673,10 +682,13 @@ def test_executeCommand(self, _, closesDialog: bool, canCallClose: bool, expecte 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: + 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: @@ -740,11 +752,14 @@ def test_blockingInstancesExist( ) 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, + 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) From 1bb02f7fde4dae2b361c39bf520172b60af5d7b5 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 11 Dec 2024 10:40:35 +1100 Subject: [PATCH 180/209] Made delay before block message a constant --- source/gui/blockAction.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/source/gui/blockAction.py b/source/gui/blockAction.py index 4be0ea21a02..0fec543ddc2 100644 --- a/source/gui/blockAction.py +++ b/source/gui/blockAction.py @@ -16,6 +16,11 @@ 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: from gui.message import isModalMessageBoxActive @@ -95,8 +100,12 @@ def funcWrapper(*args, **kwargs): 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. - # 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. - core.callLater(1, ui.message, context.translatedMessage, SpeechPriority.NOW) + core.callLater( + _DELAY_BEFORE_MESSAGE_MS, + ui.message, + context.translatedMessage, + SpeechPriority.NOW, + ) return return func(*args, **kwargs) From 81be523889ac3847c811c162b1b38fde3cb13590 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 11 Dec 2024 10:42:32 +1100 Subject: [PATCH 181/209] Fixed misspelling --- source/gui/nvdaControls.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/gui/nvdaControls.py b/source/gui/nvdaControls.py index c7b8e7781f5..838c61b52ce 100644 --- a/source/gui/nvdaControls.py +++ b/source/gui/nvdaControls.py @@ -290,7 +290,7 @@ class MessageDialog(gui.message.MessageDialog): DIALOG_TYPE_ERROR = 3 @staticmethod - def _legasyDialogTypeToDialogType(dialogType: int) -> gui.message.DialogType: + def _legacyDialogTypeToDialogType(dialogType: int) -> gui.message.DialogType: match dialogType: case MessageDialog.DIALOG_TYPE_ERROR: return gui.message.DialogType.ERROR @@ -317,7 +317,7 @@ def __init__( parent, message=message, title=title, - dialogType=self._legasyDialogTypeToDialogType(dialogType), + dialogType=self._legacyDialogTypeToDialogType(dialogType), buttons=None, ) From 581dc332aeb6d1072bfce08eac191e4b25335d29 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 11 Dec 2024 10:44:30 +1100 Subject: [PATCH 182/209] Added explicit import to first example in developer guide for clarity --- projectDocs/dev/developerGuide/developerGuide.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/projectDocs/dev/developerGuide/developerGuide.md b/projectDocs/dev/developerGuide/developerGuide.md index 6a3b461d90b..284c53e2dbf 100644 --- a/projectDocs/dev/developerGuide/developerGuide.md +++ b/projectDocs/dev/developerGuide/developerGuide.md @@ -1466,6 +1466,9 @@ While you are unlikely to need all of them, they are enumerated below: 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!", From 3dc120637db2b1b02e6c72f5c0f0541f0b0f0033 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 11 Dec 2024 10:47:03 +1100 Subject: [PATCH 183/209] Made thread safety warning more prominant --- projectDocs/dev/developerGuide/developerGuide.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/projectDocs/dev/developerGuide/developerGuide.md b/projectDocs/dev/developerGuide/developerGuide.md index 284c53e2dbf..7c245620a91 100644 --- a/projectDocs/dev/developerGuide/developerGuide.md +++ b/projectDocs/dev/developerGuide/developerGuide.md @@ -1575,7 +1575,9 @@ The order of precedence is as follows: #### A note on threading -Just like wxPython, most of the methods available on `MessageDialog` and its instances are **not** thread safe. +**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. From e256e4f66b0825571dabf51de8129e3ba65b9b21 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 11 Dec 2024 10:50:08 +1100 Subject: [PATCH 184/209] Added note on custom button IDs --- projectDocs/dev/developerGuide/developerGuide.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/projectDocs/dev/developerGuide/developerGuide.md b/projectDocs/dev/developerGuide/developerGuide.md index 7c245620a91..7c3cea136c4 100644 --- a/projectDocs/dev/developerGuide/developerGuide.md +++ b/projectDocs/dev/developerGuide/developerGuide.md @@ -1666,6 +1666,8 @@ The following default button sets are available: | `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. From 7db3eb1993283af387d548c430802f9b48190987 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 11 Dec 2024 10:55:21 +1100 Subject: [PATCH 185/209] Fixed outdated docstring --- source/gui/message.py | 1 - 1 file changed, 1 deletion(-) diff --git a/source/gui/message.py b/source/gui/message.py index 2fd33bff4f7..a5edf814e01 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -932,7 +932,6 @@ def _realizeLayout(self) -> None: def _getFallbackAction(self) -> _Command | None: """Get the fallback action of this dialog. - :raises RuntimeError: If attempting to get the default command from commands fails. :return: The id and command of the fallback action. """ escapeId = self.GetEscapeId() From c29ecf6e9cd42759246a342463c9ebd1e394505e Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 11 Dec 2024 10:57:15 +1100 Subject: [PATCH 186/209] Added error checking to _onCloseEvent --- source/gui/message.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/source/gui/message.py b/source/gui/message.py index a5edf814e01..a2c7ba54610 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -1072,7 +1072,11 @@ def _onCloseEvent(self, evt: wx.CloseEvent): return if self.GetReturnCode() == 0: # No button has been pressed, so this must be a close event from elsewhere. - command = self._getFallbackAction() + try: + command = self._getFallbackAction() + except KeyError: + log.debug("Unable to get fallback action from commands. This indicates incorrect usage.") + command = None if command is None or not command.closesDialog: evt.Veto() return From 20f0e59da5f2c14a64b288675f146e7417cd6bf6 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 11 Dec 2024 11:00:16 +1100 Subject: [PATCH 187/209] Renamed event handlers to be more consistant --- source/gui/message.py | 12 ++++++------ source/visionEnhancementProviders/screenCurtain.py | 8 ++++---- tests/unit/test_messageDialog.py | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/source/gui/message.py b/source/gui/message.py index a2c7ba54610..618879e5226 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -410,10 +410,10 @@ def __init__( self._setSound(dialogType) # Bind event listeners. - self.Bind(wx.EVT_SHOW, self._onShowEvt, source=self) - self.Bind(wx.EVT_ACTIVATE, self._onDialogActivated, source=self) + 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._onButton) + self.Bind(wx.EVT_BUTTON, self._onButtonEvent) # Scafold the dialog. mainSizer = self._mainSizer = wx.BoxSizer(wx.VERTICAL) @@ -1042,10 +1042,10 @@ def _playSound(self) -> None: if self._soundID is not None: winsound.MessageBeep(self._soundID) - def _onDialogActivated(self, evt: wx.ActivateEvent): + def _onActivateEvent(self, evt: wx.ActivateEvent): evt.Skip() - def _onShowEvt(self, evt: wx.ShowEvent): + 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. @@ -1090,7 +1090,7 @@ def _onCloseEvent(self, evt: wx.CloseEvent): log.debug("Removing from instances") self._instances.remove(self) - def _onButton(self, evt: wx.CommandEvent): + def _onButtonEvent(self, evt: wx.CommandEvent): """Event handler for button presses. Responsible for executing commands associated with buttons. diff --git a/source/visionEnhancementProviders/screenCurtain.py b/source/visionEnhancementProviders/screenCurtain.py index f1f4b8174e9..5b3032a0925 100644 --- a/source/visionEnhancementProviders/screenCurtain.py +++ b/source/visionEnhancementProviders/screenCurtain.py @@ -192,19 +192,19 @@ def _exitDialog(self, result: int): settingsStorage._saveSpecificSettings(settingsStorage, settingsStorage.supportedSettings) self.EndModal(result) - def _onDialogActivated(self, evt): + def _onActivateEvent(self, evt): # focus is normally set to the first child, however, we want people to easily be able to cancel this # dialog - super()._onDialogActivated(evt) + super()._onActivateEvent(evt) self.noButton.SetFocus() - def _onShowEvt(self, evt): + def _onShowEvent(self, evt): """When no other dialogs have been opened first, focus lands in the wrong place (on the checkbox), so we correct it after the dialog is opened. """ if evt.IsShown(): self.noButton.SetFocus() - super()._onShowEvt(evt) + super()._onShowEvent(evt) class ScreenCurtainGuiPanel( diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 13e7d130088..07b6fda3d3b 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -611,7 +611,7 @@ def test_onShowEventDefaultFocus(self): self.dialog.addOkButton().addCancelButton(defaultFocus=True) evt = wx.ShowEvent(self.dialog.GetId(), True) with patch.object(wx.Window, "SetFocus") as mocked_setFocus: - self.dialog._onShowEvt(evt) + self.dialog._onShowEvent(evt) mocked_setFocus.assert_called_once() def test_onCloseEventNonVetoable(self): From 6c68ce6642cef7775183dc2d13d216c79952f853 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 11 Dec 2024 11:11:26 +1100 Subject: [PATCH 188/209] Update requirements.txt Co-authored-by: Sean Budd --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 81bbb8aae60..f1e6c221d53 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,6 +27,7 @@ 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 From c296eb9c61a157d12ca65467a55ff6ef240d17da Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 11 Dec 2024 11:14:37 +1100 Subject: [PATCH 189/209] Update source/gui/blockAction.py Co-authored-by: Sean Budd --- source/gui/blockAction.py | 1 + 1 file changed, 1 insertion(+) diff --git a/source/gui/blockAction.py b/source/gui/blockAction.py index 4be0ea21a02..5ad372d6b63 100644 --- a/source/gui/blockAction.py +++ b/source/gui/blockAction.py @@ -18,6 +18,7 @@ def _isModalMessageBoxActive() -> bool: + """Avoid circular import of isModalMessageBoxActive""" from gui.message import isModalMessageBoxActive return isModalMessageBoxActive() From 39fbd5b396cb7a8f11d8ecac5c8d8b1f3e27b8a7 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 11 Dec 2024 11:38:15 +1100 Subject: [PATCH 190/209] Rewrote wxCallOnMain to use nonlocal rather than helper class --- source/gui/guiHelper.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/source/gui/guiHelper.py b/source/gui/guiHelper.py index a9437b69f5d..dc387abdcce 100644 --- a/source/gui/guiHelper.py +++ b/source/gui/guiHelper.py @@ -49,6 +49,7 @@ def __init__(self, parent): import threading import weakref from typing import ( + Any, Generic, Optional, ParamSpec, @@ -464,12 +465,6 @@ class SIPABCMeta(wx.siplib.wrappertype, ABCMeta): pass -class _WxCallOnMainResult: - """Container to hold either the return value or exception raised by a function.""" - - __slots__ = ("result", "exception") - - # 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") @@ -493,14 +488,16 @@ def wxCallOnMain( :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 = _WxCallOnMainResult() + result: Any = None + exception: BaseException | None = None event = threading.Event() def functionWrapper(): + nonlocal result, exception try: - result.result = function(*args, **kwargs) + result = function(*args, **kwargs) except Exception: - result.exception = sys.exception + exception = sys.exception() event.set() if wx.IsMainThread(): @@ -509,8 +506,7 @@ def functionWrapper(): wx.CallAfter(functionWrapper) event.wait() - try: - return result.result - except AttributeError: - # If result is undefined, exception must be defined. - raise result.exception # type: ignore + if exception is not None: + raise exception + else: + return result From c5d775e201582e4c26660f85ac2a5d79406e80d4 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 11 Dec 2024 11:39:14 +1100 Subject: [PATCH 191/209] Update source/gui/guiHelper.py Co-authored-by: Sean Budd --- source/gui/guiHelper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/gui/guiHelper.py b/source/gui/guiHelper.py index a9437b69f5d..5158a110357 100644 --- a/source/gui/guiHelper.py +++ b/source/gui/guiHelper.py @@ -482,7 +482,7 @@ def wxCallOnMain( ) -> _WxCallOnMain_T: """Call a non-thread-safe wx function in a thread-safe way. - Using this function is prefferable over calling :fun:`wx.CallAfter` directly when you care about the return time or return value of the function. + 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. From a59912a07d47071eb42607c53612bee0344532a6 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 11 Dec 2024 11:41:14 +1100 Subject: [PATCH 192/209] Update source/gui/guiHelper.py Co-authored-by: Sean Budd --- source/gui/guiHelper.py | 1 + 1 file changed, 1 insertion(+) diff --git a/source/gui/guiHelper.py b/source/gui/guiHelper.py index 5158a110357..439459486a3 100644 --- a/source/gui/guiHelper.py +++ b/source/gui/guiHelper.py @@ -481,6 +481,7 @@ def wxCallOnMain( **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. From 85cbef9c1fb2798ab14146389832f9c4f429baac Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 11 Dec 2024 11:41:49 +1100 Subject: [PATCH 193/209] Update user_docs/en/changes.md Co-authored-by: Sean Budd --- user_docs/en/changes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 3f1afdc47f8..5d48c5c9dad 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -110,7 +110,7 @@ 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 syncronously calling wx functions from non-GUI threads, and getting their return value. (#17304) +* 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) From 21614a99e22f4f77f6173a460c93815e2496cad8 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 11 Dec 2024 12:33:19 +1100 Subject: [PATCH 194/209] Made dialog destruction and deletion more robust --- source/gui/message.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/source/gui/message.py b/source/gui/message.py index 618879e5226..616b3dc5e9b 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -414,6 +414,7 @@ def __init__( 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) @@ -421,7 +422,6 @@ def __init__( messageControl = self._messageControl = wx.StaticText(self) contentsSizer.addItem(messageControl) buttonHelper = self._buttonHelper = guiHelper.ButtonHelper(wx.HORIZONTAL) - contentsSizer.addDialogDismissButtons(buttonHelper) mainSizer.Add( contentsSizer.sizer, border=guiHelper.BORDER_FOR_DIALOGS, @@ -435,6 +435,7 @@ def __init__( self._addButtons(buttonHelper) if buttons is not None: self.addButtons(buttons) + contentsSizer.addDialogDismissButtons(buttonHelper) # endregion @@ -1054,6 +1055,7 @@ def _onShowEvent(self, evt: wx.ShowEvent): self._playSound() if (defaultItem := self.GetDefaultItem()) is not None: defaultItem.SetFocus() + self.Raise() evt.Skip() def _onCloseEvent(self, evt: wx.CloseEvent): @@ -1102,6 +1104,16 @@ def _onButtonEvent(self, evt: wx.CommandEvent): 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: + self._instances.remove(self) + + def __del__(self): + if self in self._instances: + self._instances.remove(self) + super().__del__(self) + def _executeCommand( self, command: _Command, From 8ae47bc6423495d0cfd9010fae94931417953899 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 11 Dec 2024 12:40:40 +1100 Subject: [PATCH 195/209] Fixed incorrect call to warn --- source/gui/message.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/source/gui/message.py b/source/gui/message.py index 616b3dc5e9b..820899887f2 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -109,9 +109,8 @@ def messageBox( :return: Same as for :func:`wx.MessageBox`. """ warnings.warn( - DeprecationWarning( - "gui.message.messageBox is deprecated. Use gui.message.MessageDialog instead.", - ), + "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) From 81cc995f6e6154997b42dfba9bbfc4d8266c19f2 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 11 Dec 2024 12:43:35 +1100 Subject: [PATCH 196/209] Made runScriptModal safer, and marked it for removal --- source/gui/__init__.py | 26 ++++++++++++++++---------- user_docs/en/changes.md | 2 +- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/source/gui/__init__.py b/source/gui/__init__.py index 2d9714aefce..e8431f74c3b 100644 --- a/source/gui/__init__.py +++ b/source/gui/__init__.py @@ -5,8 +5,10 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. +from collections.abc import Callable import os import ctypes +import warnings import wx import wx.adv @@ -31,6 +33,7 @@ # be cautious when removing messageBox, MessageDialog, + displayDialogAsModal, ) from . import blockAction from .speechDict import ( @@ -879,21 +882,24 @@ def showGui(): wx.CallAfter(mainFrame.showGui) -def runScriptModalDialog(dialog, callback=None): +def runScriptModalDialog(dialog: wx.Dialog, callback: Callable[[int], Any] | None = None): """Run a modal dialog from a script. - This will not block the caller, - but will instead call C{callback} (if provided) with the result from the dialog. + This will not block the caller, but will instead call callback (if provided) with the result from the dialog. The dialog will be destroyed once the callback has returned. - @param dialog: The dialog to show. - @type dialog: C{wx.Dialog} - @param callback: The optional callable to call with the result from the dialog. - @type callback: callable + + This function is deprecated. + Use :class:`message.MessageDialog` instead. + + :param dialog: The dialog to show. + :param callback: The optional callable to call with the result from the dialog. """ + warnings.warn( + "showScriptModalDialog is deprecated. Use an instance of message.MessageDialog and wx.CallAfter instead.", + DeprecationWarning, + ) def run(): - mainFrame.prePopup() - res = dialog.ShowModal() - mainFrame.postPopup() + res = displayDialogAsModal(dialog) if callback: callback(res) dialog.Destroy() diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 3f1afdc47f8..e3dbf035c01 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -143,7 +143,7 @@ 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` function and `gui.nvdaControls.MessageDialog` class are deprecated. +* The `gui.message.messageBox` and `gui.runScriptModalDialog` functions, and `gui.nvdaControls.MessageDialog` class are deprecated. Use `gui.message.MessageDialog` instead. (#17304) ## 2024.4.1 From 41c01f5fcc589471ee9ab7bbafbc7d881da9da61 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 12 Dec 2024 14:32:14 +1100 Subject: [PATCH 197/209] Added type hints to _onActivateEvent and _onShowEvent for the screen curtain warning dialog --- source/visionEnhancementProviders/screenCurtain.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/visionEnhancementProviders/screenCurtain.py b/source/visionEnhancementProviders/screenCurtain.py index 5b3032a0925..769a0e4977d 100644 --- a/source/visionEnhancementProviders/screenCurtain.py +++ b/source/visionEnhancementProviders/screenCurtain.py @@ -192,13 +192,13 @@ def _exitDialog(self, result: int): settingsStorage._saveSpecificSettings(settingsStorage, settingsStorage.supportedSettings) self.EndModal(result) - def _onActivateEvent(self, evt): + def _onActivateEvent(self, evt: wx.ActivateEvent): # focus is normally set to the first child, however, we want people to easily be able to cancel this # dialog super()._onActivateEvent(evt) self.noButton.SetFocus() - def _onShowEvent(self, evt): + def _onShowEvent(self, evt: wx.ShowEvent): """When no other dialogs have been opened first, focus lands in the wrong place (on the checkbox), so we correct it after the dialog is opened. """ From 8990d9c798fcd490ab640f81ac162b017d95d9a8 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 12 Dec 2024 15:52:33 +1100 Subject: [PATCH 198/209] Apply suggestions from code review Mostly stylistic fixes. Co-authored-by: Sean Budd --- source/gui/message.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/source/gui/message.py b/source/gui/message.py index 820899887f2..83c2a4b5784 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -160,7 +160,7 @@ def displayError(self, parentWindow: wx.Window): class _Missing_Type: """Sentinel class to provide a nice repr.""" - def __repr(self): + def __repr__(self) -> str: return "MISSING" @@ -191,6 +191,7 @@ class EscapeCode(IntEnum): 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. @@ -343,8 +344,10 @@ class _Command(NamedTuple): 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 @@ -458,7 +461,7 @@ def addButton( :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 modelessly. + 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. @@ -746,7 +749,7 @@ def hasFallback(self) -> bool: def CloseInstances(cls) -> None: """Close all dialogs with a fallback action. - This does not force-close all instances, so instances may vito being closed. + This does not force-close all instances, so instances may veto being closed. """ for instance in cls._instances: if not instance.isBlocking: @@ -865,7 +868,7 @@ def _addButtons(self, buttonHelper: guiHelper.ButtonHelper) -> None: """ def _addContents(self, contentsSizer: guiHelper.BoxSizerHelper) -> None: - """Adds additional contents to the dialog, before the buttons. + """Adds additional contents to the dialog, before the buttons. Subclasses may implement this method. """ @@ -974,7 +977,7 @@ def getAction() -> _Command: if (action := self._getFallbackAction()) is not None: return action except KeyError: - log.debug("fallback action was not in commands. This indicates a logic error.") + 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: From 08bbbd167a8b27874d5e0f1843d198a6a1c5fe21 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 12 Dec 2024 16:19:19 +1100 Subject: [PATCH 199/209] Changed some methods to use lower camel case --- source/gui/blockAction.py | 2 +- source/gui/message.py | 16 ++++++------ tests/unit/test_messageDialog.py | 44 ++++++++++++++++---------------- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/source/gui/blockAction.py b/source/gui/blockAction.py index 148110b9018..a7fa1e6094e 100644 --- a/source/gui/blockAction.py +++ b/source/gui/blockAction.py @@ -34,7 +34,7 @@ def _modalDialogOpenCallback(): # Import late to avoid circular import from gui.message import MessageDialog - if MessageDialog.BlockingInstancesExist(): + if MessageDialog.blockingInstancesExist(): MessageDialog.FocusBlockingInstances() diff --git a/source/gui/message.py b/source/gui/message.py index 83c2a4b5784..73eadfcc760 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -348,7 +348,7 @@ class _Command(NamedTuple): closesDialog: bool """Indicates whether the dialog should be closed after the command is executed. Defaults to True.""" - ReturnCode: ReturnCode + returnCode: ReturnCode class MessageDialog(DpiScalingHelperMixinWithoutInit, wx.Dialog, metaclass=SIPABCMeta): @@ -488,7 +488,7 @@ def addButton( self._commands[buttonId] = _Command( callback=callback, closesDialog=closesDialog, - ReturnCode=buttonId if returnCode is None else returnCode, + returnCode=buttonId if returnCode is None else returnCode, ) if defaultFocus: self.SetDefaultItem(button) @@ -746,7 +746,7 @@ def hasFallback(self) -> bool: # region Public class methods @classmethod - def CloseInstances(cls) -> None: + def closeInstances(cls) -> None: """Close all dialogs with a fallback action. This does not force-close all instances, so instances may veto being closed. @@ -756,12 +756,12 @@ def CloseInstances(cls) -> None: instance.Close() @classmethod - def BlockingInstancesExist(cls) -> bool: + 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: + def focusBlockingInstances(cls) -> None: """Raise and focus open modal dialogs without a fallback action.""" lastDialog: MessageDialog | None = None for dialog in cls._instances: @@ -998,7 +998,7 @@ def getAction() -> _Command: ) # No commands have been registered. Create one of our own. - return _Command(callback=None, closesDialog=True, ReturnCode=wx.ID_NONE) + return _Command(callback=None, closesDialog=True, returnCode=wx.ID_NONE) command = getAction() if not command.closesDialog: @@ -1159,7 +1159,7 @@ def _messageBoxShim(message: str, caption: str, style: int, parent: wx.Window | message=message, title=caption, dialogType=_messageBoxIconStylesToMessageDialogType(style), - buttons=_MessageBoxButtonStylesToMessageDialogButtons(style), + buttons=_messageBoxButtonStylesToMessageDialogButtons(style), ) return _messageDialogReturnCodeToMessageBoxReturnCode(dialog.ShowModal()) @@ -1205,7 +1205,7 @@ def _messageBoxIconStylesToMessageDialogType(flags: int) -> DialogType: return DialogType.STANDARD -def _MessageBoxButtonStylesToMessageDialogButtons(flags: int) -> tuple[Button, ...]: +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. diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 07b6fda3d3b..b1d48a09a58 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -12,7 +12,7 @@ import wx from gui.message import _Command, DefaultButtonSet, DialogType, EscapeCode, ReturnCode from gui.message import ( - _MessageBoxButtonStylesToMessageDialogButtons, + _messageBoxButtonStylesToMessageDialogButtons, ) from parameterized import parameterized from typing import Any, Iterable, NamedTuple @@ -203,7 +203,7 @@ def test_addDefaultButtonHelpers( actualFallbackAction = self.dialog._getFallbackAction() if expectedHasFallback: self.assertIsNotNone(actualFallbackAction) - self.assertEqual(actualFallbackAction.ReturnCode, expectedFallbackId) + self.assertEqual(actualFallbackAction.returnCode, expectedFallbackId) else: self.assertIsNone(actualFallbackAction) @@ -225,7 +225,7 @@ def test_addButtonWithFallbackAction(self): ), ) command = self.dialog._getFallbackAction() - self.assertEqual(command.ReturnCode, ReturnCode.CUSTOM_1) + self.assertEqual(command.returnCode, ReturnCode.CUSTOM_1) self.assertTrue(command.closesDialog) def test_addButtonWithNonClosingFallbackAction(self): @@ -239,7 +239,7 @@ def test_addButtonWithNonClosingFallbackAction(self): ), ) command = self.dialog._getFallbackAction() - self.assertEqual(command.ReturnCode, ReturnCode.CUSTOM_1) + self.assertEqual(command.returnCode, ReturnCode.CUSTOM_1) self.assertTrue(command.closesDialog) @parameterized.expand( @@ -296,7 +296,7 @@ def test_setButtonLabelNotAButton(self): self.dialog._commands[messageControlId] = _Command( closesDialog=True, callback=None, - ReturnCode=ReturnCode.APPLY, + returnCode=ReturnCode.APPLY, ) with self.assertRaises(TypeError): self.dialog.setButtonLabel(messageControlId, "test") @@ -370,7 +370,7 @@ def test_addButtonFromButtonWithOverrides(self): 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._commands[ReturnCode.APPLY].returnCode, RETURN_CODE) self.assertEqual(self.dialog.GetEscapeId(), ReturnCode.APPLY) def test_addButtonsNonuniqueIds(self): @@ -397,7 +397,7 @@ def test_defaultActionDefaultEscape_OkCancel(self): 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.returnCode, ReturnCode.CANCEL) self.assertEqual(command.callback, dummyCallback2) self.assertTrue(command.closesDialog) with self.subTest( @@ -410,7 +410,7 @@ def test_defaultActionDefaultEscape_CancelOk(self): 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.returnCode, ReturnCode.CANCEL) self.assertEqual(command.callback, dummyCallback2) self.assertTrue(command.closesDialog) with self.subTest( @@ -423,7 +423,7 @@ def test_defaultActionDefaultEscape_OkClose(self): 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.returnCode, ReturnCode.OK) self.assertEqual(command.callback, dummyCallback1) self.assertTrue(command.closesDialog) with self.subTest( @@ -436,7 +436,7 @@ def test_defaultActionDefaultEscape_CloseOk(self): 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.returnCode, ReturnCode.OK) self.assertEqual(command.callback, dummyCallback1) self.assertTrue(command.closesDialog) with self.subTest( @@ -450,7 +450,7 @@ def test_setFallbackActionExistantAction(self): self.dialog.setFallbackAction(ReturnCode.YES) command = self.dialog._getFallbackAction() with self.subTest("Test getting the fallback action."): - self.assertEqual(command.ReturnCode, ReturnCode.YES) + self.assertEqual(command.returnCode, ReturnCode.YES) self.assertIsNone(command.callback) self.assertTrue(command.closesDialog) with self.subTest( @@ -477,7 +477,7 @@ def test_setFallbackActionNonclosingAction(self): 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.assertEqual(command.returnCode, EscapeCode.NO_FALLBACK) self.assertIsNotNone(command) self.assertTrue(command.closesDialog) @@ -486,7 +486,7 @@ def test_getFallbackActionOrFallbackNoDefaultFocusClosingButton(self): self.dialog.addApplyButton(closesDialog=False).addCloseButton() self.assertIsNone(self.dialog.GetDefaultItem()) command = self.dialog._getFallbackActionOrFallback() - self.assertEqual(command.ReturnCode, ReturnCode.CLOSE) + self.assertEqual(command.returnCode, ReturnCode.CLOSE) self.assertIsNotNone(command) self.assertTrue(command.closesDialog) @@ -495,7 +495,7 @@ def test_getFallbackActionOrFallbackNoDefaultFocusNoClosingButton(self): self.dialog.addApplyButton(closesDialog=False).addCloseButton(closesDialog=False) self.assertIsNone(self.dialog.GetDefaultItem()) command = self.dialog._getFallbackActionOrFallback() - self.assertEqual(command.ReturnCode, ReturnCode.APPLY) + self.assertEqual(command.returnCode, ReturnCode.APPLY) self.assertIsNotNone(command) self.assertTrue(command.closesDialog) @@ -505,7 +505,7 @@ def test_getFallbackActionOrFallbackNoDefaultAction(self): self.dialog.setDefaultFocus(ReturnCode.CLOSE) self.assertEqual(self.dialog.GetDefaultItem().GetId(), ReturnCode.CLOSE) command = self.dialog._getFallbackActionOrFallback() - self.assertEqual(command.ReturnCode, ReturnCode.CLOSE) + self.assertEqual(command.returnCode, ReturnCode.CLOSE) self.assertIsNotNone(command) self.assertTrue(command.closesDialog) @@ -514,7 +514,7 @@ def test_getFallbackActionOrFallbackCustomDefaultAction(self): self.dialog.addApplyButton().addCloseButton() self.dialog.setFallbackAction(ReturnCode.CLOSE) command = self.dialog._getFallbackActionOrFallback() - self.assertEqual(command.ReturnCode, ReturnCode.CLOSE) + self.assertEqual(command.returnCode, ReturnCode.CLOSE) self.assertIsNotNone(command) self.assertTrue(command.closesDialog) @@ -523,7 +523,7 @@ def test_getFallbackActionOrFallbackEscapeIdNotACommand(self): self.dialog.addOkCancelButtons() super(MessageDialog, self.dialog).SetEscapeId(ReturnCode.CLOSE) command = self.dialog._getFallbackActionOrFallback() - self.assertEqual(command.ReturnCode, ReturnCode.OK) + self.assertEqual(command.returnCode, ReturnCode.OK) self.assertIsNotNone(command) self.assertTrue(command.closesDialog) @@ -681,7 +681,7 @@ def test_executeCommand(self, _, closesDialog: bool, canCallClose: bool, expecte """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) + command = _Command(callback=callback, closesDialog=closesDialog, returnCode=returnCode) with ( patch.object(self.dialog, "Close") as mocked_close, patch.object( @@ -740,7 +740,7 @@ def test_blockingInstancesExist( """Test that blockingInstancesExist is correct in a number of situations.""" MessageDialog._instances.extend(instances) print(MessageDialog._instances) - self.assertEqual(MessageDialog.BlockingInstancesExist(), expectedBlockingInstancesExist) + self.assertEqual(MessageDialog.blockingInstancesExist(), expectedBlockingInstancesExist) @parameterized.expand( ( @@ -835,7 +835,7 @@ def test_isBlocking(self, _, isModal: bool, hasFallback: bool, expectedIsBlockin 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() + MessageDialog.focusBlockingInstances() for dialog, expectedRaise, expectedSetFocus in dialogs: if expectedRaise: dialog.Raise.assert_called_once() @@ -851,7 +851,7 @@ def test_closeNonblockingInstances(self): 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() + MessageDialog.closeInstances() bd1.Close.assert_not_called() bd2.Close.assert_not_called() nd1.Close.assert_called() @@ -899,5 +899,5 @@ def test_messageBoxButtonStylesToMessageDialogButtons(self): with self.subTest(flags=input): self.assertCountEqual( expectedOutput, - (button.id for button in _MessageBoxButtonStylesToMessageDialogButtons(input)), + (button.id for button in _messageBoxButtonStylesToMessageDialogButtons(input)), ) From db53c83862f8b97404052d8239a62318382d7d49 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 12 Dec 2024 16:25:45 +1100 Subject: [PATCH 200/209] Improved logging --- source/gui/message.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/source/gui/message.py b/source/gui/message.py index 73eadfcc760..41fa88a0a61 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -694,10 +694,10 @@ def Show(self, show: bool = True) -> bool: return self.Hide() self._checkShowable() self._realizeLayout() - log.debug("Showing") + log.debug(f"Showing {self!r} as non-modal.") shown = super().Show(show) if shown: - log.debug("Adding to instances") + log.debug(f"Adding {self!r} to instances.") self._instances.append(self) return shown @@ -712,9 +712,9 @@ def ShowModal(self) -> ReturnCode: # 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("Adding to instances") + log.debug(f"Adding {self!r} to instances.") self._instances.append(self) - log.debug("Showing modal") + log.debug(f"Showing {self!r} as modal") ret = displayDialogAsModal(self) # Restore our implementation of ShowModal. @@ -993,7 +993,7 @@ def getAction() -> _Command: # No commands that close the dialog have been registered. Use the first command instead. return next(iter(self._commands.values())) else: - log.debug( + log.error( "No commands have been registered. If the dialog is shown, this indicates a logic error.", ) @@ -1069,6 +1069,7 @@ def _onCloseEvent(self, evt: wx.CloseEvent): # 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()) @@ -1079,7 +1080,7 @@ def _onCloseEvent(self, evt: wx.CloseEvent): try: command = self._getFallbackAction() except KeyError: - log.debug("Unable to get fallback action from commands. This indicates incorrect usage.") + 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() @@ -1089,9 +1090,9 @@ def _onCloseEvent(self, evt: wx.CloseEvent): self.Hide() if self.IsModal(): self.EndModal(self.GetReturnCode()) - log.debug("Queueing destroy") + log.debug("Queueing {self!r} for destruction") self.DestroyLater() - log.debug("Removing from instances") + log.debug(f"Removing {self!r} from instances.") self._instances.remove(self) def _onButtonEvent(self, evt: wx.CommandEvent): @@ -1109,10 +1110,13 @@ def _onButtonEvent(self, evt: wx.CommandEvent): 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 __del__(self): + """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) super().__del__(self) From ca09d42837b5c807914cf6802413395e5b6e8ab6 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 12 Dec 2024 16:33:39 +1100 Subject: [PATCH 201/209] Apply suggestions from code review Co-authored-by: Sean Budd --- .../dev/developerGuide/developerGuide.md | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/projectDocs/dev/developerGuide/developerGuide.md b/projectDocs/dev/developerGuide/developerGuide.md index 7c3cea136c4..44996fc828d 100644 --- a/projectDocs/dev/developerGuide/developerGuide.md +++ b/projectDocs/dev/developerGuide/developerGuide.md @@ -1450,7 +1450,8 @@ Please see the `EventExtensionPoints` class documentation for more information, ### 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. +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: @@ -1471,7 +1472,7 @@ from gui import mainFrame MessageDialog( mainFrame, - "Hello world!", + _("Hello world!"), ).Show() ``` @@ -1501,7 +1502,7 @@ match saveDialog.ShowModal(): For non-modal dialogs, the easiest way to respond to the user pressing a button is via callback methods. ```py -def read_changelog(): +def readChangelog(): ... # Do something def download_update(): @@ -1519,16 +1520,16 @@ updateDialog = MessageDialog( ).addButton( ReturnCode.YES, label="Yes", - default_focus=True, + defaultFocus=True, callback=download_update ).addButton( ReturnCode.NO, - default_action=True, + defaultAction=True, label="Remind me later", callback=remind_later ).addButton( ReturnCode.HELP, - closes_dialog=False, + closesDialog=False, label="What's new", callback=read_changelog ) @@ -1544,7 +1545,6 @@ You can set many of the parameters to `addButton` later, too: #### Fallback actions -It is worth interrogating the fallback action further. 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: @@ -1559,7 +1559,7 @@ This means that the fallback action will be the cancel button if there is one, t 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. -[^fn_defaultFallbackOk]: In actuality, the value of `dialog.GetAffirmativeId()` is used to find the button to use as fallback if using `EscapeCode.CANCEL_OR_AFFIRMATIVE` and there is no button with `id=ReturnCode.CANCEL`. +The value of `dialog.GetAffirmativeId()` is used to find the button to use as fallback if using `EscapeCode.CANCEL_OR_AFFIRMATIVE` and there is no button with `id=ReturnCode.CANCEL`. You can use `dialog.SetAffirmativeId(id)` to change it, if desired. In some cases, the dialog may be forced to close. @@ -1588,9 +1588,9 @@ It blocks the calling thread until the passed callable returns or raises an exce ```py # To call -some_function(arg1, arg2, kw1=value1, kw2=value2) +someFunction(arg1, arg2, kw1=value1, kw2=value2) # on the GUI thread: -wxCallOnMain(some_function, arg1, arg2, kw=value1, kw2=value2) +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. From da2fe2909a8812d1f322cf502912b6f8bb28187d Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Thu, 12 Dec 2024 16:53:57 +1100 Subject: [PATCH 202/209] Removed footnotes from the developer guide --- .../dev/developerGuide/developerGuide.md | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/projectDocs/dev/developerGuide/developerGuide.md b/projectDocs/dev/developerGuide/developerGuide.md index 44996fc828d..6bdec12c488 100644 --- a/projectDocs/dev/developerGuide/developerGuide.md +++ b/projectDocs/dev/developerGuide/developerGuide.md @@ -1555,13 +1555,11 @@ This can happen for several reasons: * 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 ok button if there is no cancel button but there is an ok button[^fn_defaultFallbackOk], or otherwise `None`. +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. -The value of `dialog.GetAffirmativeId()` is used to find the button to use as fallback if using `EscapeCode.CANCEL_OR_AFFIRMATIVE` and there is no button with `id=ReturnCode.CANCEL`. -You can use `dialog.SetAffirmativeId(id)` to change it, if desired. - In some cases, the dialog may be forced to close. If the dialog is shown modally, a fallback action will be used if the default action is `EscapeCode.NO_FALLBACK` or not found. The order of precedence is as follows: @@ -1617,26 +1615,26 @@ Its fields are as follows: | `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[^fn_defaultFocus]. | -| `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[^fn_fallbackAction]. | -| `closesDialog` | `bool` | `True` | Whether the button should close the dialog when pressed[^fn_closesDialog]. | +| `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. | -[^fn_defaultFocus]: Setting `defaultFocus` only overrides the default focus: +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. + * 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. -[^fn_fallbackAction]: `fallbackAction` only sets whether to override the fallback action: +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 `ReturnCode.OK` if there is no button with `id=ReturnCode.OK`), 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. + * 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 `ReturnCode.OK` if there is no button with `id=ReturnCode.OK`), 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. -[^fn_closesDialog]: Buttons with `fallbackAction=True` and `closesDialog=False` are not supported: +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. + * 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. From 451f3705dccd7175b0a4bad2bd740876486036a3 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 13 Dec 2024 10:59:35 +1100 Subject: [PATCH 203/209] Removed __del__ that was failing --- source/gui/message.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/source/gui/message.py b/source/gui/message.py index 41fa88a0a61..499f10fe021 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -1113,13 +1113,6 @@ def _onDestroyEvent(self, evt: wx.WindowDestroyEvent): log.debug(f"Removing {self!r} from instances.") self._instances.remove(self) - def __del__(self): - """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) - super().__del__(self) - def _executeCommand( self, command: _Command, From fcafc8a73813cda1e304b312dba9535c8fc7d2e1 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 13 Dec 2024 11:09:36 +1100 Subject: [PATCH 204/209] Corrects to dev guide --- .../dev/developerGuide/developerGuide.md | 40 +++++++++---------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/projectDocs/dev/developerGuide/developerGuide.md b/projectDocs/dev/developerGuide/developerGuide.md index 6bdec12c488..839924d7018 100644 --- a/projectDocs/dev/developerGuide/developerGuide.md +++ b/projectDocs/dev/developerGuide/developerGuide.md @@ -1483,10 +1483,12 @@ If you want the dialog to be modal (that is, to block the user from performing o 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?", + _("Would you like to save your changes before exiting?"), + _("Save changes?"), buttons=DefaultButtonSet.SAVE_NO_CANCEL ) @@ -1505,10 +1507,10 @@ For non-modal dialogs, the easiest way to respond to the user pressing a button def readChangelog(): ... # Do something -def download_update(): +def downloadUpdate(): ... # Do something -def remind_later(): +def remindLater(): ... # Do something updateDialog = MessageDialog( @@ -1517,21 +1519,15 @@ updateDialog = MessageDialog( "Would you like to download it now?", "Update", buttons=None, -).addButton( - ReturnCode.YES, - label="Yes", - defaultFocus=True, - callback=download_update -).addButton( - ReturnCode.NO, - defaultAction=True, - label="Remind me later", - callback=remind_later -).addButton( - ReturnCode.HELP, - closesDialog=False, - label="What's new", - callback=read_changelog +).addYesButton( + callback=downloadUpdate +).addNoButton( + label=_("Remind me later"), + fallbackAction=True, + callback=remindLater +).addHelpButton( + label=_("What's new"), + callback=readChangelog ) updateDialog.Show() @@ -1561,8 +1557,8 @@ The fallback action can also be set to `EscapeCode.NO_FALLBACK` to disable closi 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 fallback action will be used if the default action is `EscapeCode.NO_FALLBACK` or not found. -The order of precedence is as follows: +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. @@ -1627,7 +1623,7 @@ Its fields are as follows: 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 `ReturnCode.OK` if there is no button with `id=ReturnCode.OK`), even if it is added with `fallbackAction=False`. + * 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. From 0c7a6c7d52df929ab3cbf51533310392a86945aa Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 13 Dec 2024 11:09:36 +1100 Subject: [PATCH 205/209] Corrections to dev guide --- .../dev/developerGuide/developerGuide.md | 40 +++++++++---------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/projectDocs/dev/developerGuide/developerGuide.md b/projectDocs/dev/developerGuide/developerGuide.md index 6bdec12c488..17a8e99f7f9 100644 --- a/projectDocs/dev/developerGuide/developerGuide.md +++ b/projectDocs/dev/developerGuide/developerGuide.md @@ -1483,10 +1483,12 @@ If you want the dialog to be modal (that is, to block the user from performing o 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?", + _("Would you like to save your changes before exiting?"), + _("Save changes?"), buttons=DefaultButtonSet.SAVE_NO_CANCEL ) @@ -1505,10 +1507,10 @@ For non-modal dialogs, the easiest way to respond to the user pressing a button def readChangelog(): ... # Do something -def download_update(): +def downloadUpdate(): ... # Do something -def remind_later(): +def remindLater(): ... # Do something updateDialog = MessageDialog( @@ -1517,21 +1519,15 @@ updateDialog = MessageDialog( "Would you like to download it now?", "Update", buttons=None, -).addButton( - ReturnCode.YES, - label="Yes", - defaultFocus=True, - callback=download_update -).addButton( - ReturnCode.NO, - defaultAction=True, - label="Remind me later", - callback=remind_later -).addButton( - ReturnCode.HELP, - closesDialog=False, - label="What's new", - callback=read_changelog +).addYesButton( + callback=downloadUpdate +).addNoButton( + label=_("Remi&nd me later"), + fallbackAction=True, + callback=remindLater +).addHelpButton( + label=_("W&hat's new"), + callback=readChangelog ) updateDialog.Show() @@ -1561,8 +1557,8 @@ The fallback action can also be set to `EscapeCode.NO_FALLBACK` to disable closi 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 fallback action will be used if the default action is `EscapeCode.NO_FALLBACK` or not found. -The order of precedence is as follows: +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. @@ -1627,7 +1623,7 @@ Its fields are as follows: 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 `ReturnCode.OK` if there is no button with `id=ReturnCode.OK`), even if it is added with `fallbackAction=False`. + * 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. From 8b02b72018ef911b5fdc0486821aaa550396da4c Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 13 Dec 2024 14:38:41 +1100 Subject: [PATCH 206/209] Apply suggestions from code review Co-authored-by: Sean Budd --- tests/unit/test_messageDialog.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index b1d48a09a58..456be0de0e6 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -549,7 +549,6 @@ def test_initOnNonMain(self): tpe.submit(dlg.__init__, None, "Test").result() def test_showOnNonMain(self): - # self.app = wx.App() """Test that showing a MessageDialog on a non-GUI thread fails.""" dlg = MessageDialog(None, "Test") with ThreadPoolExecutor(max_workers=1) as tpe: @@ -558,7 +557,6 @@ def test_showOnNonMain(self): def test_showModalOnNonMain(self): """Test that showing a MessageDialog modally on a non-GUI thread fails.""" - # self.app = wx.App() dlg = MessageDialog(None, "Test") with ThreadPoolExecutor(max_workers=1) as tpe: with self.assertRaises(RuntimeError): @@ -597,12 +595,10 @@ def test_showModal(self, mocked_showModal: MagicMock, _): mocked_messageBoxCounter.__isub__.return_value ) = mocked_messageBoxCounter self.dialog.ShowModal() - print(mocked_messageBoxCounter.mock_calls) mocked_showModal.assert_called_once() mocked_messageBoxCounter.__iadd__.assert_called_once() mocked_messageBoxCounter.__isub__.assert_called_once() - # raise Exception class Test_MessageDialog_EventHandlers(MDTestBase): @@ -739,7 +735,6 @@ def test_blockingInstancesExist( ): """Test that blockingInstancesExist is correct in a number of situations.""" MessageDialog._instances.extend(instances) - print(MessageDialog._instances) self.assertEqual(MessageDialog.blockingInstancesExist(), expectedBlockingInstancesExist) @parameterized.expand( From 0dce0e97e975ca5945db06f7dacb49be24396809 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 13 Dec 2024 15:04:41 +1100 Subject: [PATCH 207/209] Made several parameterized argument lists namedtuples for readability --- tests/unit/test_messageDialog.py | 131 ++++++++++++++++++++++++------- 1 file changed, 101 insertions(+), 30 deletions(-) diff --git a/tests/unit/test_messageDialog.py b/tests/unit/test_messageDialog.py index 456be0de0e6..efe5f0a68f8 100644 --- a/tests/unit/test_messageDialog.py +++ b/tests/unit/test_messageDialog.py @@ -86,6 +86,26 @@ class SubsequentCallArgList(NamedTuple): 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.""" @@ -600,7 +620,6 @@ def test_showModal(self, mocked_showModal: MagicMock, _): mocked_messageBoxCounter.__isub__.assert_called_once() - class Test_MessageDialog_EventHandlers(MDTestBase): def test_onShowEventDefaultFocus(self): """Test that _onShowEvent correctly focuses the default focus.""" @@ -667,10 +686,30 @@ def test_onCloseEventFallbackAction(self): @parameterized.expand( ( - ("closableCanCallClose", True, True, True), - ("ClosableCannotCallClose", True, False, False), - ("UnclosableCanCallClose", False, True, False), - ("UnclosableCannotCallClose", False, False, False), + 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): @@ -702,35 +741,47 @@ def tearDown(self) -> None: @parameterized.expand( ( - ("noInstances", tuple(), False), - ("nonBlockingInstance", (mockDialogFactory(isBlocking=False),), False), - ("blockingInstance", (mockDialogFactory(isBlocking=True),), True), - ( - "onlyBlockingInstances", - (mockDialogFactory(isBlocking=True), mockDialogFactory(isBlocking=True)), - True, + BlockingInstancesExistArgList( + label="noInstances", + instances=tuple(), + expectedBlockingInstancesExist=False, ), - ( - "onlyNonblockingInstances", - (mockDialogFactory(isBlocking=False), mockDialogFactory(isBlocking=False)), - False, + BlockingInstancesExistArgList( + label="nonBlockingInstance", + instances=(mockDialogFactory(isBlocking=False),), + expectedBlockingInstancesExist=False, ), - ( - "blockingFirstNonBlockingSecond", - (mockDialogFactory(isBlocking=True), mockDialogFactory(isBlocking=False)), - True, + BlockingInstancesExistArgList( + label="blockingInstance", + instances=(mockDialogFactory(isBlocking=True),), + expectedBlockingInstancesExist=True, ), - ( - "nonblockingFirstblockingSecond", - (mockDialogFactory(isBlocking=False), mockDialogFactory(isBlocking=True)), - 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], + instances: tuple[MagicMock, ...], expectedBlockingInstancesExist: bool, ): """Test that blockingInstancesExist is correct in a number of situations.""" @@ -739,10 +790,30 @@ def test_blockingInstancesExist( @parameterized.expand( ( - ("modalWithFallback", True, True, True), - ("ModalWithoutFallback", True, False, True), - ("ModelessWithFallback", False, True, False), - ("ModelessWithoutFallback", False, False, True), + 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): @@ -827,7 +898,7 @@ def test_isBlocking(self, _, isModal: bool, hasFallback: bool, expectedIsBlockin ), ), ) - def test_focusBlockingInstances(self, _, dialogs: tuple[FocusBlockingInstancesDialogs]): + 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() From f52a07e8b569d21ae9a82ba2a2ef5ba308415c6a Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 18 Dec 2024 17:19:38 +1100 Subject: [PATCH 208/209] Fixed up admonissions --- source/gui/message.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/source/gui/message.py b/source/gui/message.py index 499f10fe021..9ac71743cf4 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -266,8 +266,8 @@ class Button(NamedTuple): 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. + .. note:: This only overrides the default focus. + If no buttons have this property, the first button will be the default focus. """ fallbackAction: bool = False @@ -276,21 +276,21 @@ class Button(NamedTuple): 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`. + .. 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. + .. 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. + .. note:: If None, the button's ID will be used as the return code when closing a modal dialog with this button. """ @@ -1167,7 +1167,7 @@ def _messageDialogReturnCodeToMessageBoxReturnCode(returnCode: ReturnCode) -> in :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. + .. 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: @@ -1189,7 +1189,7 @@ def _messageBoxIconStylesToMessageDialogType(flags: int) -> 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`. + .. 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: @@ -1209,9 +1209,9 @@ def _messageBoxButtonStylesToMessageDialogButtons(flags: int) -> tuple[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. + .. 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. + .. 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. """ From f437723806b9350bf0b91fbc2954ca38df950fe7 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 18 Dec 2024 17:28:09 +1100 Subject: [PATCH 209/209] Added notes on thread safety --- projectDocs/dev/developerGuide/developerGuide.md | 5 +++-- source/gui/message.py | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/projectDocs/dev/developerGuide/developerGuide.md b/projectDocs/dev/developerGuide/developerGuide.md index dcc93437ddc..be23ea20c4f 100644 --- a/projectDocs/dev/developerGuide/developerGuide.md +++ b/projectDocs/dev/developerGuide/developerGuide.md @@ -1666,8 +1666,9 @@ If none of the standard `ReturnCode` values are suitable for your button, you ma 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. -The following convenience class methods are provided: +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 | |---|---|---| diff --git a/source/gui/message.py b/source/gui/message.py index 9ac71743cf4..d38c2c746df 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -359,6 +359,8 @@ class MessageDialog(DpiScalingHelperMixinWithoutInit, wx.Dialog, metaclass=SIPAB 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() @@ -782,6 +784,8 @@ def alert( ): """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. @@ -808,6 +812,8 @@ def confirm( ) -> 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. @@ -838,6 +844,8 @@ def ask( ) -> 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.