From 3ae2042a35f34330b645e5e19c5711592c952c05 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Thu, 31 Aug 2023 14:25:58 +1000 Subject: [PATCH 01/22] Add-on store: Handle bulk install --- source/_addonStore/network.py | 8 +- source/gui/_addonStoreGui/controls/actions.py | 84 ++++++++++++++++--- .../gui/_addonStoreGui/controls/addonList.py | 53 +++++++++--- source/gui/_addonStoreGui/controls/details.py | 4 +- .../_addonStoreGui/controls/storeDialog.py | 4 +- .../gui/_addonStoreGui/viewModels/action.py | 67 +++++++++++++++ .../_addonStoreGui/viewModels/addonList.py | 6 ++ source/gui/_addonStoreGui/viewModels/store.py | 8 ++ 8 files changed, 205 insertions(+), 29 deletions(-) diff --git a/source/_addonStore/network.py b/source/_addonStore/network.py index 013e6f2980a..1f516cb33d1 100644 --- a/source/_addonStore/network.py +++ b/source/_addonStore/network.py @@ -75,7 +75,7 @@ def __init__(self): ] = {} self.complete: Dict[AddonStoreModel, os.PathLike] = {} # Path to downloaded file self._executor = ThreadPoolExecutor( - max_workers=1, + max_workers=10, thread_name_prefix="AddonDownloader", ) @@ -122,8 +122,10 @@ def _done(self, downloadAddonFuture: Future): else: cacheFilePath: Optional[os.PathLike] = downloadAddonFuture.result() - del self._pending[downloadAddonFuture] - del self.progress[addonData] + # If canceled after our previous isCancelled check, + # then _pending and progress will be empty. + self._pending.pop(downloadAddonFuture, None) + self.progress.pop(addonData, None) self.complete[addonData] = cacheFilePath onComplete(addonData, cacheFilePath) diff --git a/source/gui/_addonStoreGui/controls/actions.py b/source/gui/_addonStoreGui/controls/actions.py index 354bc64407f..8d1254aefd9 100644 --- a/source/gui/_addonStoreGui/controls/actions.py +++ b/source/gui/_addonStoreGui/controls/actions.py @@ -6,22 +6,33 @@ import functools from typing import ( Dict, + Generic, + Iterable, List, + TypeVar, ) +from typing_extensions import Protocol import wx +from _addonStore.models.status import _StatusFilterKey from logHandler import log -from ..viewModels.action import AddonActionVM +from ..viewModels.action import AddonActionVM, BulkAddonActionVM +from ..viewModels.addonList import AddonListItemVM from ..viewModels.store import AddonStoreVM -class _ActionsContextMenu: - def __init__(self, storeVM: AddonStoreVM): - self._storeVM = storeVM - self._actionMenuItemMap: Dict[AddonActionVM, wx.MenuItem] = {} - self._contextMenu = wx.Menu() +AddonActionT = TypeVar("AddonActionT", AddonActionVM, BulkAddonActionVM) + + +class _ActionsContextMenuP(Generic[AddonActionT], Protocol): + _actions: List[AddonActionT] + _actionMenuItemMap: Dict[AddonActionT, wx.MenuItem] + _contextMenu: wx.Menu + + def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: AddonActionT): + ... def popupContextMenuFromPosition( self, @@ -31,14 +42,9 @@ def popupContextMenuFromPosition( self._populateContextMenu() targetWindow.PopupMenu(self._contextMenu, pos=position) - def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: AddonActionVM): - selectedAddon = actionVM.listItemVM - log.debug(f"action selected: actionVM: {actionVM}, selectedAddon: {selectedAddon}") - actionVM.actionHandler(selectedAddon) - def _populateContextMenu(self): prevActionIndex = -1 - for action in self._storeVM.actionVMList: + for action in self._actions: menuItem = self._actionMenuItemMap.get(action) menuItems: List[wx.MenuItem] = list(self._contextMenu.GetMenuItems()) isMenuItemInContextMenu = menuItem is not None and menuItem in menuItems @@ -71,3 +77,57 @@ def _populateContextMenu(self): # Remove the menu item from the context menu. self._contextMenu.RemoveItem(menuItem) del self._actionMenuItemMap[action] + + menuItems: List[wx.MenuItem] = list(self._contextMenu.GetMenuItems()) + for menuItem in menuItems: + if menuItem not in self._actionMenuItemMap.values(): + # The menu item is not in the action menu item map. + # It should be removed from the context menu. + self._contextMenu.RemoveItem(menuItem) + + +class _MonoActionsContextMenu(_ActionsContextMenuP[AddonActionVM]): + """Context menu for actions for a single add-on""" + def __init__(self, storeVM: AddonStoreVM): + self._storeVM = storeVM + self._actionMenuItemMap = {} + self._contextMenu = wx.Menu() + + def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: AddonActionVM): + selectedAddon = actionVM.listItemVM + log.debug(f"action selected: actionVM: {actionVM.displayName}, selectedAddon: {selectedAddon}") + actionVM.actionHandler(selectedAddon) + + @property + def _actions(self) -> List[AddonActionVM]: + return self._storeVM.actionVMList + + +class _BulkActionsContextMenu(_ActionsContextMenuP[BulkAddonActionVM]): + """Context menu for actions for a group of add-ons""" + def __init__(self, storeVM: AddonStoreVM): + self._storeVM = storeVM + self._actionMenuItemMap = {} + self._contextMenu = wx.Menu() + self._selectedAddons: Iterable[AddonListItemVM] = tuple() + + def _updateSelectedAddons(self, selectedAddons: Iterable[AddonListItemVM]): + # Reset the action menu as self._actions depends on the selected add-ons + self._actionMenuItemMap = {} + self._selectedAddons = selectedAddons + + def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: BulkAddonActionVM): + log.debug(f"Performing bulk action for actionVM: {actionVM.displayName}") + actionVM.actionHandler(self._selectedAddons) + + @property + def _actions(self) -> List[BulkAddonActionVM]: + return [ + BulkAddonActionVM( + # Translators: Label for an action that installs the selected add-ons + displayName=pgettext("addonStore", "&Install selected add-ons"), + actionHandler=self._storeVM.getAddons, + validCheck=lambda aVMs: self._storeVM._filteredStatusKey == _StatusFilterKey.AVAILABLE, + listItemVMs=self._selectedAddons + ), + ] diff --git a/source/gui/_addonStoreGui/controls/addonList.py b/source/gui/_addonStoreGui/controls/addonList.py index 09375d5e357..1e3d54b958e 100644 --- a/source/gui/_addonStoreGui/controls/addonList.py +++ b/source/gui/_addonStoreGui/controls/addonList.py @@ -4,6 +4,7 @@ # See the file COPYING for more details. from typing import ( + List, Optional, ) @@ -16,8 +17,12 @@ from gui.dpiScalingHelper import DpiScalingHelperMixinWithoutInit from logHandler import log -from .actions import _ActionsContextMenu -from ..viewModels.addonList import AddonListVM +from .actions import ( + _ActionsContextMenuP, + _BulkActionsContextMenu, + _MonoActionsContextMenu, +) +from ..viewModels.addonList import AddonListItemVM, AddonListVM class AddonVirtualList( @@ -28,14 +33,13 @@ def __init__( self, parent: wx.Window, addonsListVM: AddonListVM, - actionsContextMenu: _ActionsContextMenu, + actionsContextMenu: _MonoActionsContextMenu, ): super().__init__( parent, style=( wx.LC_REPORT # Single or multicolumn report view, with optional header. | wx.LC_VIRTUAL # The application provides items text on demand. May only be used with LC_REPORT. - | wx.LC_SINGLE_SEL # Single selection (default is multiple). | wx.LC_HRULES # Draws light horizontal rules between rows in report mode. | wx.LC_VRULES # Draws light vertical rules between columns in report mode. ), @@ -43,6 +47,7 @@ def __init__( ) self._addonsListVM = addonsListVM self._actionsContextMenu = actionsContextMenu + self._bulkActionsContextMenu = _BulkActionsContextMenu(self._addonsListVM._storeVM) self.SetMinSize(self.scaleSize((500, 500))) @@ -75,6 +80,25 @@ def _getListSelectionPosition(self) -> Optional[wx.Position]: itemRect: wx.Rect = self.GetItemRect(firstSelectedIndex) return itemRect.GetBottomLeft() + def _updateBulkContextMenuSelection(self): + numSelected = self.GetSelectedItemCount() + prevSelectedIndex = self.GetFirstSelected() + selectedAddons: List[AddonListItemVM] = [] + for _ in range(numSelected): + addon = self._addonsListVM.getAddonAtIndex(prevSelectedIndex) + selectedAddons.append(addon) + prevSelectedIndex = self.GetNextSelected(prevSelectedIndex) + + self._bulkActionsContextMenu._updateSelectedAddons(selectedAddons) + + @property + def _contextMenu(self) -> _ActionsContextMenuP: + numSelected = self.GetSelectedItemCount() + if numSelected > 1: + self._updateBulkContextMenuSelection() + return self._bulkActionsContextMenu + return self._actionsContextMenu + def _popupContextMenuFromList(self, evt: wx.ContextMenuEvent): listSelectionPosition = self._getListSelectionPosition() if listSelectionPosition is None: @@ -83,29 +107,38 @@ def _popupContextMenuFromList(self, evt: wx.ContextMenuEvent): if eventPosition == wx.DefaultPosition: # keyboard triggered context menu (due to "applications" key) # don't have position set. It must be fetched from the selected item. - self._actionsContextMenu.popupContextMenuFromPosition(self, listSelectionPosition) + self._contextMenu.popupContextMenuFromPosition(self, listSelectionPosition) else: # Mouse (right click) triggered context menu. # In this case the menu is positioned better with GetPopupMenuSelectionFromUser. - self._actionsContextMenu.popupContextMenuFromPosition(self) + self._contextMenu.popupContextMenuFromPosition(self) def _itemDataUpdated(self, index: int): log.debug(f"index: {index}") self.RefreshItem(index) def OnItemSelected(self, evt: wx.ListEvent): - newIndex = evt.GetIndex() + newIndex = self.GetFirstSelected() log.debug(f"item selected: {newIndex}") self._addonsListVM.setSelection(index=newIndex) def OnItemActivated(self, evt: wx.ListEvent): position = self._getListSelectionPosition() - self._actionsContextMenu.popupContextMenuFromPosition(self, position) + self._contextMenu.popupContextMenuFromPosition(self, position) log.debug(f"item activated: {evt.GetIndex()}") def OnItemDeselected(self, evt: wx.ListEvent): - log.debug(f"item deselected") - self._addonsListVM.setSelection(None) + # Call this later, as the list control doesn't update its selection until after this event, + # and we need an accurate selection count. + wx.CallAfter(self._OnItemDeselected, evt) + + def _OnItemDeselected(self, evt: wx.ListEvent): + if self.GetSelectedItemCount() == 0: + log.debug("item deselected") + self._addonsListVM.setSelection(None) + else: + log.debug("updating selection due to item deselected") + self.OnItemSelected(evt) def OnGetItemText(self, itemIndex: int, colIndex: int) -> str: dataItem = self._addonsListVM.getAddonFieldText( diff --git a/source/gui/_addonStoreGui/controls/details.py b/source/gui/_addonStoreGui/controls/details.py index 182a0141a28..22974126942 100644 --- a/source/gui/_addonStoreGui/controls/details.py +++ b/source/gui/_addonStoreGui/controls/details.py @@ -15,7 +15,7 @@ from ..viewModels.addonList import AddonDetailsVM, AddonListField -from .actions import _ActionsContextMenu +from .actions import _MonoActionsContextMenu _fontFaceName = "Segoe UI" _fontFaceName_semiBold = "Segoe UI Semibold" @@ -49,7 +49,7 @@ def __init__( self, parent: wx.Window, detailsVM: AddonDetailsVM, - actionsContextMenu: _ActionsContextMenu, + actionsContextMenu: _MonoActionsContextMenu, ): self._detailsVM: AddonDetailsVM = detailsVM self._actionsContextMenu = actionsContextMenu diff --git a/source/gui/_addonStoreGui/controls/storeDialog.py b/source/gui/_addonStoreGui/controls/storeDialog.py index 5e527163779..7f2b8a90b96 100644 --- a/source/gui/_addonStoreGui/controls/storeDialog.py +++ b/source/gui/_addonStoreGui/controls/storeDialog.py @@ -33,7 +33,7 @@ from logHandler import log from ..viewModels.store import AddonStoreVM -from .actions import _ActionsContextMenu +from .actions import _MonoActionsContextMenu from .addonList import AddonVirtualList from .details import AddonDetails from .messageDialogs import _SafetyWarningDialog @@ -50,7 +50,7 @@ class AddonStoreDialog(SettingsDialog): def __init__(self, parent: wx.Window, storeVM: AddonStoreVM): self._storeVM = storeVM self._storeVM.onDisplayableError.register(self.handleDisplayableError) - self._actionsContextMenu = _ActionsContextMenu(self._storeVM) + self._actionsContextMenu = _MonoActionsContextMenu(self._storeVM) super().__init__(parent, resizeable=True, buttons={wx.CLOSE}) if config.conf["addonStore"]["showWarning"]: displayDialogAsModal(_SafetyWarningDialog(parent)) diff --git a/source/gui/_addonStoreGui/viewModels/action.py b/source/gui/_addonStoreGui/viewModels/action.py index 9ddddc55cde..f463048caa6 100644 --- a/source/gui/_addonStoreGui/viewModels/action.py +++ b/source/gui/_addonStoreGui/viewModels/action.py @@ -5,6 +5,7 @@ from typing import ( Callable, + Iterable, Optional, TYPE_CHECKING, ) @@ -77,3 +78,69 @@ def listItemVM(self, listItemVM: Optional["AddonListItemVM"]): listItemVM.updated.register(self._listItemChanged) self._listItemVM = listItemVM self._notify() + + +class BulkAddonActionVM: + """ + Actions/behaviour that can be embedded within other views/viewModels + that can apply to a group of L{AddonListItemVM}. + Use the L{BulkAddonActionVM.updated} extensionPoint.Action to be notified about changes. + E.G.: + - Updates within the AddonListItemVM (perhaps changing the action validity) + - Entirely changing the AddonListItemVM action will be applied to, the validity can be checked for the new + item. + """ + def __init__( + self, + displayName: str, + actionHandler: Callable[[Iterable["AddonListItemVM"], ], None], + validCheck: Callable[[Iterable["AddonListItemVM"], ], bool], + listItemVMs: Iterable["AddonListItemVM"], + ): + """ + @param displayName: Translated string, to be displayed to the user. Should describe the action / behaviour. + @param actionHandler: Call when the action is triggered. + @param validCheck: Is the action valid for the current listItemVMs + @param listItemVMs: The listItemVMs this action will be applied to. L{updated} notifies of modification. + """ + self.displayName = displayName + self.actionHandler = actionHandler + self._validCheck = validCheck + self._listItemVMs = listItemVMs + for listItemVM in listItemVMs: + listItemVM.updated.register(self._listItemChanged) + self.updated = extensionPoints.Action() + """Notify of changes to the action""" + + def _listItemChanged(self, addonListItemVM: "AddonListItemVM"): + """Something inside the AddonListItemVM has changed""" + assert addonListItemVM in self._listItemVMs, f"{addonListItemVM} {list(self._listItemVMs)}" + self._notify() + + def _notify(self): + # ensure calling on the main thread. + from core import callLater + callLater(delay=0, callable=self.updated.notify, addonActionVM=self) + + @property + def isValid(self) -> bool: + return self._validCheck(self._listItemVMs) + + @property + def listItemVMs(self) -> Iterable["AddonListItemVM"]: + return self._listItemVMs + + @listItemVMs.setter + def listItemVMs(self, newListItemVMs: Iterable["AddonListItemVM"]): + if self._listItemVMs == newListItemVMs: + return + for oldListItemVM in self._listItemVMs: + if oldListItemVM not in newListItemVMs: + oldListItemVM.updated.unregister(self._listItemChanged) + + for newListItemVM in newListItemVMs: + if newListItemVM not in self._listItemVMs: + newListItemVM.updated.register(self._listItemChanged) + + self._listItemVMs = newListItemVMs + self._notify() diff --git a/source/gui/_addonStoreGui/viewModels/addonList.py b/source/gui/_addonStoreGui/viewModels/addonList.py index 2ee5c12dc64..22fa70d6dec 100644 --- a/source/gui/_addonStoreGui/viewModels/addonList.py +++ b/source/gui/_addonStoreGui/viewModels/addonList.py @@ -253,6 +253,12 @@ def getSelectedIndex(self) -> Optional[int]: return self._addonsFilteredOrdered.index(self.selectedAddonId) return None + def getAddonAtIndex(self, index: int) -> AddonListItemVM: + self._validate(selectionIndex=index) + selectedAddonId = self._addonsFilteredOrdered[index] + selectedItemVM: Optional[AddonListItemVM] = self._addons[selectedAddonId] + return selectedItemVM + def setSelection(self, index: Optional[int]) -> Optional[AddonListItemVM]: self._validate(selectionIndex=index) self.selectedAddonId = None diff --git a/source/gui/_addonStoreGui/viewModels/store.py b/source/gui/_addonStoreGui/viewModels/store.py index bf2a9976e27..e36544c8852 100644 --- a/source/gui/_addonStoreGui/viewModels/store.py +++ b/source/gui/_addonStoreGui/viewModels/store.py @@ -13,6 +13,7 @@ ) import os from typing import ( + Iterable, List, Optional, cast, @@ -314,6 +315,13 @@ def getAddon(self, listItemVM: AddonListItemVM) -> None: log.debug(f"{listItemVM.Id} status: {listItemVM.status}") self._downloader.download(listItemVM.model, self._downloadComplete, self.onDisplayableError) + def getAddons(self, listItemVMs: Iterable[AddonListItemVM]) -> None: + for aVM in listItemVMs: + if not aVM.model.isCompatible and aVM.model.canOverrideCompatibility: + self.installOverrideIncompatibilityForAddon(aVM) + else: + self.getAddon(aVM) + def _downloadComplete(self, addonDetails: AddonStoreModel, fileDownloaded: Optional[PathLike]): listItemVM: Optional[AddonListItemVM] = self.listVM._addons[addonDetails.listItemVMId] if listItemVM is None: From 0f70756569a33dba1cf7baeb7d0e4d9823b5de7a Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Thu, 31 Aug 2023 14:28:33 +1000 Subject: [PATCH 02/22] Fix up download complete --- source/gui/_addonStoreGui/viewModels/store.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/gui/_addonStoreGui/viewModels/store.py b/source/gui/_addonStoreGui/viewModels/store.py index e36544c8852..a6c1d9e2a67 100644 --- a/source/gui/_addonStoreGui/viewModels/store.py +++ b/source/gui/_addonStoreGui/viewModels/store.py @@ -323,7 +323,7 @@ def getAddons(self, listItemVMs: Iterable[AddonListItemVM]) -> None: self.getAddon(aVM) def _downloadComplete(self, addonDetails: AddonStoreModel, fileDownloaded: Optional[PathLike]): - listItemVM: Optional[AddonListItemVM] = self.listVM._addons[addonDetails.listItemVMId] + listItemVM: Optional[AddonListItemVM] = self.listVM._addons.get(addonDetails.listItemVMId) if listItemVM is None: log.error(f"No list item VM for addon with id: {addonDetails.addonId}") return From 6cfd6e25caebb8d12f3d69d38a63bafb5ae8c240 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Thu, 31 Aug 2023 14:33:18 +1000 Subject: [PATCH 03/22] update docs --- user_docs/en/userGuide.t2t | 3 +++ 1 file changed, 3 insertions(+) diff --git a/user_docs/en/userGuide.t2t b/user_docs/en/userGuide.t2t index 7bc90680999..a531a8f3155 100644 --- a/user_docs/en/userGuide.t2t +++ b/user_docs/en/userGuide.t2t @@ -2704,6 +2704,9 @@ To install an add-on you have obtained outside of the Add-on Store, press the "I This will allow you to browse for an add-on package (``.nvda-addon`` file) somewhere on your computer or on a network. Once you open the add-on package, the installation process will begin. +You can also install multiple add-ons at once. +This can be done by selecting multiple add-ons in the available add-ons tab, then activating the context menu on the selection and choosing the install action. + If NVDA is installed and running on your system, you can also open an add-on file directly from the browser or file system to begin the installation process. When an add-on is being installed from an external source, NVDA will ask you to confirm the installation. From 1f2a70373ea084f72e8155989fa55ae7f8c4f8e9 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Thu, 31 Aug 2023 14:45:54 +1000 Subject: [PATCH 04/22] add message when no context actions available --- source/gui/_addonStoreGui/controls/actions.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/source/gui/_addonStoreGui/controls/actions.py b/source/gui/_addonStoreGui/controls/actions.py index 8d1254aefd9..d730c4db878 100644 --- a/source/gui/_addonStoreGui/controls/actions.py +++ b/source/gui/_addonStoreGui/controls/actions.py @@ -17,6 +17,7 @@ from _addonStore.models.status import _StatusFilterKey from logHandler import log +import ui from ..viewModels.action import AddonActionVM, BulkAddonActionVM from ..viewModels.addonList import AddonListItemVM @@ -116,6 +117,17 @@ def _updateSelectedAddons(self, selectedAddons: Iterable[AddonListItemVM]): self._actionMenuItemMap = {} self._selectedAddons = selectedAddons + def popupContextMenuFromPosition( + self, + targetWindow: wx.Window, + position: wx.Position = wx.DefaultPosition + ): + super().popupContextMenuFromPosition(targetWindow, position) + if self._contextMenu.GetMenuItemCount() == 0: + # Translators: a message displayed when activating the context menu on multiple selected add-ons, + # but no actions are available for the add-ons. + ui.message(pgettext("addonStore", "No actions available for the selected add-ons")) + def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: BulkAddonActionVM): log.debug(f"Performing bulk action for actionVM: {actionVM.displayName}") actionVM.actionHandler(self._selectedAddons) From 6f5acc53b88d255a3c4775b28a76f5b83548b699 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Thu, 31 Aug 2023 16:05:18 +1000 Subject: [PATCH 05/22] make code generic --- source/gui/_addonStoreGui/controls/actions.py | 4 +- .../gui/_addonStoreGui/viewModels/action.py | 151 ++++++++++-------- source/gui/_addonStoreGui/viewModels/store.py | 26 +-- 3 files changed, 96 insertions(+), 85 deletions(-) diff --git a/source/gui/_addonStoreGui/controls/actions.py b/source/gui/_addonStoreGui/controls/actions.py index d730c4db878..81e200ea5da 100644 --- a/source/gui/_addonStoreGui/controls/actions.py +++ b/source/gui/_addonStoreGui/controls/actions.py @@ -95,7 +95,7 @@ def __init__(self, storeVM: AddonStoreVM): self._contextMenu = wx.Menu() def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: AddonActionVM): - selectedAddon = actionVM.listItemVM + selectedAddon = actionVM.actionTarget log.debug(f"action selected: actionVM: {actionVM.displayName}, selectedAddon: {selectedAddon}") actionVM.actionHandler(selectedAddon) @@ -140,6 +140,6 @@ def _actions(self) -> List[BulkAddonActionVM]: displayName=pgettext("addonStore", "&Install selected add-ons"), actionHandler=self._storeVM.getAddons, validCheck=lambda aVMs: self._storeVM._filteredStatusKey == _StatusFilterKey.AVAILABLE, - listItemVMs=self._selectedAddons + actionTarget=self._selectedAddons ), ] diff --git a/source/gui/_addonStoreGui/viewModels/action.py b/source/gui/_addonStoreGui/viewModels/action.py index f463048caa6..b6ea5ca14b2 100644 --- a/source/gui/_addonStoreGui/viewModels/action.py +++ b/source/gui/_addonStoreGui/viewModels/action.py @@ -3,10 +3,13 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. +from abc import ABC, abstractmethod from typing import ( Callable, + Generic, Iterable, Optional, + TypeVar, TYPE_CHECKING, ) @@ -16,7 +19,49 @@ from .addonList import AddonListItemVM -class AddonActionVM: +ActionTargetT = TypeVar("ActionTargetT", Optional["AddonListItemVM"], Iterable["AddonListItemVM"]) + + +class _AddonAction(Generic[ActionTargetT], ABC): + def __init__( + self, + displayName: str, + actionHandler: Callable[[ActionTargetT, ], None], + validCheck: Callable[[ActionTargetT, ], bool], + actionTarget: ActionTargetT, + ): + """ + @param displayName: Translated string, to be displayed to the user. Should describe the action / behaviour. + @param actionHandler: Call when the action is triggered. + @param validCheck: Is the action valid for the current target + @param actionTarget: The target this action will be applied to. L{updated} notifies of modification. + """ + self.displayName = displayName + self.actionHandler = actionHandler + self._validCheck = validCheck + self._actionTarget = actionTarget + self.updated = extensionPoints.Action() + """Notify of changes to the action""" + + @abstractmethod + def _listItemChanged(self, addonListItemVM: "AddonListItemVM"): + ... + + @property + def isValid(self) -> bool: + return self._validCheck(self._actionTarget) + + @property + def actionTarget(self) -> ActionTargetT: + return self._actionTarget + + def _notify(self): + # ensure calling on the main thread. + from core import callLater + callLater(delay=0, callable=self.updated.notify, addonActionVM=self) + + +class AddonActionVM(_AddonAction[Optional["AddonListItemVM"]]): """ Actions/behaviour that can be embedded within other views/viewModels that can apply to a single L{AddonListItemVM}. Use the L{AddonActionVM.updated} extensionPoint.Action to be notified about changes. @@ -28,59 +73,42 @@ class AddonActionVM: def __init__( self, displayName: str, - actionHandler: Callable[["AddonListItemVM", ], None], + actionHandler: Callable[[Optional["AddonListItemVM"], ], None], validCheck: Callable[["AddonListItemVM", ], bool], - listItemVM: Optional["AddonListItemVM"], + actionTarget: Optional["AddonListItemVM"], ): """ @param displayName: Translated string, to be displayed to the user. Should describe the action / behaviour. @param actionHandler: Call when the action is triggered. @param validCheck: Is the action valid for the current listItemVM - @param listItemVM: The listItemVM this action will be applied to. L{updated} notifies of modification. + @param actionTarget: The listItemVM this action will be applied to. L{updated} notifies of modification. """ - self.displayName: str = displayName - self.actionHandler: Callable[["AddonListItemVM", ], None] = actionHandler - self._validCheck: Callable[["AddonListItemVM", ], bool] = validCheck - self._listItemVM: Optional["AddonListItemVM"] = listItemVM - if listItemVM: - listItemVM.updated.register(self._listItemChanged) - self.updated = extensionPoints.Action() - """Notify of changes to the action""" + def _validCheck(listItemVM: Optional["AddonListItemVM"]) -> bool: + # Handle the None case so that each validCheck doesn't have to. + return listItemVM is not None and validCheck(listItemVM) - def _listItemChanged(self, addonListItemVM: "AddonListItemVM"): + super().__init__(displayName, actionHandler, _validCheck, actionTarget) + if actionTarget: + actionTarget.updated.register(self._listItemChanged) + + def _listItemChanged(self, addonListItemVM: Optional["AddonListItemVM"]): """Something inside the AddonListItemVM has changed""" - assert self._listItemVM == addonListItemVM + assert self._actionTarget == addonListItemVM self._notify() - def _notify(self): - # ensure calling on the main thread. - from core import callLater - callLater(delay=0, callable=self.updated.notify, addonActionVM=self) - - @property - def isValid(self) -> bool: - return ( - self._listItemVM is not None - and self._validCheck(self._listItemVM) - ) - - @property - def listItemVM(self) -> Optional["AddonListItemVM"]: - return self._listItemVM - - @listItemVM.setter - def listItemVM(self, listItemVM: Optional["AddonListItemVM"]): - if self._listItemVM == listItemVM: + @_AddonAction.actionTarget.setter + def actionTarget(self, newActionTarget: Optional["AddonListItemVM"]): + if self._actionTarget == newActionTarget: return - if self._listItemVM: - self._listItemVM.updated.unregister(self._listItemChanged) - if listItemVM: - listItemVM.updated.register(self._listItemChanged) - self._listItemVM = listItemVM + if self._actionTarget: + self._actionTarget.updated.unregister(self._listItemChanged) + if newActionTarget: + newActionTarget.updated.register(self._listItemChanged) + self._actionTarget = newActionTarget self._notify() -class BulkAddonActionVM: +class BulkAddonActionVM(_AddonAction[Iterable["AddonListItemVM"]]): """ Actions/behaviour that can be embedded within other views/viewModels that can apply to a group of L{AddonListItemVM}. @@ -95,52 +123,35 @@ def __init__( displayName: str, actionHandler: Callable[[Iterable["AddonListItemVM"], ], None], validCheck: Callable[[Iterable["AddonListItemVM"], ], bool], - listItemVMs: Iterable["AddonListItemVM"], + actionTarget: Iterable["AddonListItemVM"], ): """ @param displayName: Translated string, to be displayed to the user. Should describe the action / behaviour. @param actionHandler: Call when the action is triggered. @param validCheck: Is the action valid for the current listItemVMs - @param listItemVMs: The listItemVMs this action will be applied to. L{updated} notifies of modification. + @param actionTarget: The listItemVMs this action will be applied to. L{updated} notifies of modification. """ - self.displayName = displayName - self.actionHandler = actionHandler - self._validCheck = validCheck - self._listItemVMs = listItemVMs - for listItemVM in listItemVMs: + super().__init__(displayName, actionHandler, validCheck, actionTarget) + for listItemVM in self._actionTarget: listItemVM.updated.register(self._listItemChanged) - self.updated = extensionPoints.Action() - """Notify of changes to the action""" def _listItemChanged(self, addonListItemVM: "AddonListItemVM"): """Something inside the AddonListItemVM has changed""" - assert addonListItemVM in self._listItemVMs, f"{addonListItemVM} {list(self._listItemVMs)}" + assert addonListItemVM in self._actionTarget self._notify() - def _notify(self): - # ensure calling on the main thread. - from core import callLater - callLater(delay=0, callable=self.updated.notify, addonActionVM=self) - - @property - def isValid(self) -> bool: - return self._validCheck(self._listItemVMs) - - @property - def listItemVMs(self) -> Iterable["AddonListItemVM"]: - return self._listItemVMs - - @listItemVMs.setter - def listItemVMs(self, newListItemVMs: Iterable["AddonListItemVM"]): - if self._listItemVMs == newListItemVMs: + @_AddonAction.actionTarget.setter + def actionTarget(self, newActionTarget: Iterable["AddonListItemVM"]): + if self._actionTarget == newActionTarget: return - for oldListItemVM in self._listItemVMs: - if oldListItemVM not in newListItemVMs: + + for oldListItemVM in self._actionTarget: + if oldListItemVM not in newActionTarget: oldListItemVM.updated.unregister(self._listItemChanged) - for newListItemVM in newListItemVMs: - if newListItemVM not in self._listItemVMs: + for newListItemVM in newActionTarget: + if newListItemVM not in self._actionTarget: newListItemVM.updated.register(self._listItemChanged) - self._listItemVMs = newListItemVMs + self._actionTarget = newActionTarget self._notify() diff --git a/source/gui/_addonStoreGui/viewModels/store.py b/source/gui/_addonStoreGui/viewModels/store.py index a6c1d9e2a67..408f8b8a355 100644 --- a/source/gui/_addonStoreGui/viewModels/store.py +++ b/source/gui/_addonStoreGui/viewModels/store.py @@ -109,7 +109,7 @@ def _onSelectedItemChanged(self): log.debug(f"Setting selection: {selectedVM}") self.detailsVM.listItem = selectedVM for action in self.actionVMList: - action.listItemVM = selectedVM + action.actionTarget = selectedVM def _makeActionsList(self): selectedListItem: Optional[AddonListItemVM] = self.listVM.getSelection() @@ -119,7 +119,7 @@ def _makeActionsList(self): displayName=pgettext("addonStore", "&Install"), actionHandler=self.getAddon, validCheck=lambda aVM: aVM.status == AvailableAddonStatus.AVAILABLE, - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that installs the selected addon @@ -129,14 +129,14 @@ def _makeActionsList(self): aVM.status == AvailableAddonStatus.INCOMPATIBLE and aVM.model.canOverrideCompatibility ), - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that updates the selected addon displayName=pgettext("addonStore", "&Update"), actionHandler=self.getAddon, validCheck=lambda aVM: aVM.status == AvailableAddonStatus.UPDATE, - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that replaces the selected addon with @@ -144,7 +144,7 @@ def _makeActionsList(self): displayName=pgettext("addonStore", "Re&place"), actionHandler=self.replaceAddon, validCheck=lambda aVM: aVM.status == AvailableAddonStatus.REPLACE_SIDE_LOAD, - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that disables the selected addon @@ -157,7 +157,7 @@ def _makeActionsList(self): AvailableAddonStatus.PENDING_INCOMPATIBLE_DISABLED, AvailableAddonStatus.PENDING_REMOVE, ), - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that enables the selected addon @@ -167,7 +167,7 @@ def _makeActionsList(self): aVM.status == AvailableAddonStatus.DISABLED or aVM.status == AvailableAddonStatus.PENDING_DISABLE ), - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that enables the selected addon @@ -180,7 +180,7 @@ def _makeActionsList(self): ) and aVM.model.canOverrideCompatibility ), - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that removes the selected addon @@ -196,7 +196,7 @@ def _makeActionsList(self): _StatusFilterKey.INCOMPATIBLE, ) ), - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that opens help for the selected addon @@ -213,14 +213,14 @@ def _makeActionsList(self): and aVM.model._addonHandlerModel is not None and aVM.model._addonHandlerModel.getDocFilePath() is not None ), - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that opens the homepage for the selected addon displayName=pgettext("addonStore", "Ho&mepage"), actionHandler=lambda aVM: startfile(aVM.model.homepage), validCheck=lambda aVM: aVM.model.homepage is not None, - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that opens the license for the selected addon @@ -230,14 +230,14 @@ def _makeActionsList(self): isinstance(aVM.model, _AddonStoreModel) and aVM.model.licenseURL is not None ), - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that opens the source code for the selected addon displayName=pgettext("addonStore", "Source &Code"), actionHandler=lambda aVM: startfile(cast(_AddonStoreModel, aVM.model).sourceURL), validCheck=lambda aVM: isinstance(aVM.model, _AddonStoreModel), - listItemVM=selectedListItem + actionTarget=selectedListItem ), ] From cb9d00ffd7203b1641b6e30c890b47898d8a5ace Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Thu, 31 Aug 2023 16:12:57 +1000 Subject: [PATCH 06/22] make code generic --- source/gui/_addonStoreGui/viewModels/action.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/source/gui/_addonStoreGui/viewModels/action.py b/source/gui/_addonStoreGui/viewModels/action.py index b6ea5ca14b2..56eefc60b18 100644 --- a/source/gui/_addonStoreGui/viewModels/action.py +++ b/source/gui/_addonStoreGui/viewModels/action.py @@ -14,6 +14,7 @@ ) import extensionPoints +from logHandler import log if TYPE_CHECKING: from .addonList import AddonListItemVM @@ -73,7 +74,7 @@ class AddonActionVM(_AddonAction[Optional["AddonListItemVM"]]): def __init__( self, displayName: str, - actionHandler: Callable[[Optional["AddonListItemVM"], ], None], + actionHandler: Callable[["AddonListItemVM", ], None], validCheck: Callable[["AddonListItemVM", ], bool], actionTarget: Optional["AddonListItemVM"], ): @@ -87,7 +88,13 @@ def _validCheck(listItemVM: Optional["AddonListItemVM"]) -> bool: # Handle the None case so that each validCheck doesn't have to. return listItemVM is not None and validCheck(listItemVM) - super().__init__(displayName, actionHandler, _validCheck, actionTarget) + def _actionHandler(listItemVM: Optional["AddonListItemVM"]): + # Handle the None case so that each validCheck doesn't have to. + if listItemVM is not None: + log.warning(f"Action triggered for invalid None listItemVM: {self.displayName}") + actionHandler(listItemVM) + + super().__init__(displayName, _actionHandler, _validCheck, actionTarget) if actionTarget: actionTarget.updated.register(self._listItemChanged) From 871fc41f4c2ea3f8d425c644e6a03acc57b437a5 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Thu, 31 Aug 2023 16:15:58 +1000 Subject: [PATCH 07/22] fixp typo --- source/gui/_addonStoreGui/viewModels/action.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/gui/_addonStoreGui/viewModels/action.py b/source/gui/_addonStoreGui/viewModels/action.py index 56eefc60b18..1ed8a81920b 100644 --- a/source/gui/_addonStoreGui/viewModels/action.py +++ b/source/gui/_addonStoreGui/viewModels/action.py @@ -89,7 +89,7 @@ def _validCheck(listItemVM: Optional["AddonListItemVM"]) -> bool: return listItemVM is not None and validCheck(listItemVM) def _actionHandler(listItemVM: Optional["AddonListItemVM"]): - # Handle the None case so that each validCheck doesn't have to. + # Handle the None case so that each actionHandler doesn't have to. if listItemVM is not None: log.warning(f"Action triggered for invalid None listItemVM: {self.displayName}") actionHandler(listItemVM) From ffda1ecf209e678abd184c6ea5a4d0b24d142bad Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Thu, 31 Aug 2023 16:16:48 +1000 Subject: [PATCH 08/22] fixp typo --- source/gui/_addonStoreGui/viewModels/action.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/source/gui/_addonStoreGui/viewModels/action.py b/source/gui/_addonStoreGui/viewModels/action.py index 1ed8a81920b..7c373ce5374 100644 --- a/source/gui/_addonStoreGui/viewModels/action.py +++ b/source/gui/_addonStoreGui/viewModels/action.py @@ -91,8 +91,9 @@ def _validCheck(listItemVM: Optional["AddonListItemVM"]) -> bool: def _actionHandler(listItemVM: Optional["AddonListItemVM"]): # Handle the None case so that each actionHandler doesn't have to. if listItemVM is not None: - log.warning(f"Action triggered for invalid None listItemVM: {self.displayName}") actionHandler(listItemVM) + else: + log.warning(f"Action triggered for invalid None listItemVM: {self.displayName}") super().__init__(displayName, _actionHandler, _validCheck, actionTarget) if actionTarget: From d4a69b291b9f1ba8f6c346d8e786a9c68d7679f7 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Thu, 31 Aug 2023 16:42:07 +1000 Subject: [PATCH 09/22] move para --- user_docs/en/userGuide.t2t | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/user_docs/en/userGuide.t2t b/user_docs/en/userGuide.t2t index a531a8f3155..ec8929486e3 100644 --- a/user_docs/en/userGuide.t2t +++ b/user_docs/en/userGuide.t2t @@ -2700,13 +2700,13 @@ You can install and update add-ons by [browsing Available add-ons #AddonStoreBro Select an add-on from the "Available add-ons" or "Updatable add-ons" tab. Then use the update, install, or replace action to start the installation. +You can also install multiple add-ons at once. +This can be done by selecting multiple add-ons in the available add-ons tab, then activating the context menu on the selection and choosing the install action. + To install an add-on you have obtained outside of the Add-on Store, press the "Install from external source" button. This will allow you to browse for an add-on package (``.nvda-addon`` file) somewhere on your computer or on a network. Once you open the add-on package, the installation process will begin. -You can also install multiple add-ons at once. -This can be done by selecting multiple add-ons in the available add-ons tab, then activating the context menu on the selection and choosing the install action. - If NVDA is installed and running on your system, you can also open an add-on file directly from the browser or file system to begin the installation process. When an add-on is being installed from an external source, NVDA will ask you to confirm the installation. From f176a63bc766fab4543e82c2c948304aa267b2b7 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Thu, 31 Aug 2023 17:05:30 +1000 Subject: [PATCH 10/22] simplify fix/type error --- source/gui/_addonStoreGui/viewModels/addonList.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/source/gui/_addonStoreGui/viewModels/addonList.py b/source/gui/_addonStoreGui/viewModels/addonList.py index 22fa70d6dec..5e285cfc5d0 100644 --- a/source/gui/_addonStoreGui/viewModels/addonList.py +++ b/source/gui/_addonStoreGui/viewModels/addonList.py @@ -256,8 +256,7 @@ def getSelectedIndex(self) -> Optional[int]: def getAddonAtIndex(self, index: int) -> AddonListItemVM: self._validate(selectionIndex=index) selectedAddonId = self._addonsFilteredOrdered[index] - selectedItemVM: Optional[AddonListItemVM] = self._addons[selectedAddonId] - return selectedItemVM + return self._addons[selectedAddonId] def setSelection(self, index: Optional[int]) -> Optional[AddonListItemVM]: self._validate(selectionIndex=index) From 03645895101b750673de5a0093d5bf2faa28f5b3 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Thu, 31 Aug 2023 17:14:51 +1000 Subject: [PATCH 11/22] use batch not bulk, more popular terminology --- source/gui/_addonStoreGui/controls/actions.py | 14 +++++++------- source/gui/_addonStoreGui/controls/addonList.py | 12 ++++++------ source/gui/_addonStoreGui/viewModels/action.py | 4 ++-- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/source/gui/_addonStoreGui/controls/actions.py b/source/gui/_addonStoreGui/controls/actions.py index 81e200ea5da..6ffa30b43f6 100644 --- a/source/gui/_addonStoreGui/controls/actions.py +++ b/source/gui/_addonStoreGui/controls/actions.py @@ -19,12 +19,12 @@ from logHandler import log import ui -from ..viewModels.action import AddonActionVM, BulkAddonActionVM +from ..viewModels.action import AddonActionVM, BatchAddonActionVM from ..viewModels.addonList import AddonListItemVM from ..viewModels.store import AddonStoreVM -AddonActionT = TypeVar("AddonActionT", AddonActionVM, BulkAddonActionVM) +AddonActionT = TypeVar("AddonActionT", AddonActionVM, BatchAddonActionVM) class _ActionsContextMenuP(Generic[AddonActionT], Protocol): @@ -104,7 +104,7 @@ def _actions(self) -> List[AddonActionVM]: return self._storeVM.actionVMList -class _BulkActionsContextMenu(_ActionsContextMenuP[BulkAddonActionVM]): +class _BatchActionsContextMenu(_ActionsContextMenuP[BatchAddonActionVM]): """Context menu for actions for a group of add-ons""" def __init__(self, storeVM: AddonStoreVM): self._storeVM = storeVM @@ -128,14 +128,14 @@ def popupContextMenuFromPosition( # but no actions are available for the add-ons. ui.message(pgettext("addonStore", "No actions available for the selected add-ons")) - def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: BulkAddonActionVM): - log.debug(f"Performing bulk action for actionVM: {actionVM.displayName}") + def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: BatchAddonActionVM): + log.debug(f"Performing batch action for actionVM: {actionVM.displayName}") actionVM.actionHandler(self._selectedAddons) @property - def _actions(self) -> List[BulkAddonActionVM]: + def _actions(self) -> List[BatchAddonActionVM]: return [ - BulkAddonActionVM( + BatchAddonActionVM( # Translators: Label for an action that installs the selected add-ons displayName=pgettext("addonStore", "&Install selected add-ons"), actionHandler=self._storeVM.getAddons, diff --git a/source/gui/_addonStoreGui/controls/addonList.py b/source/gui/_addonStoreGui/controls/addonList.py index 1e3d54b958e..107464d32ba 100644 --- a/source/gui/_addonStoreGui/controls/addonList.py +++ b/source/gui/_addonStoreGui/controls/addonList.py @@ -19,7 +19,7 @@ from .actions import ( _ActionsContextMenuP, - _BulkActionsContextMenu, + _BatchActionsContextMenu, _MonoActionsContextMenu, ) from ..viewModels.addonList import AddonListItemVM, AddonListVM @@ -47,7 +47,7 @@ def __init__( ) self._addonsListVM = addonsListVM self._actionsContextMenu = actionsContextMenu - self._bulkActionsContextMenu = _BulkActionsContextMenu(self._addonsListVM._storeVM) + self._batchActionsContextMenu = _BatchActionsContextMenu(self._addonsListVM._storeVM) self.SetMinSize(self.scaleSize((500, 500))) @@ -80,7 +80,7 @@ def _getListSelectionPosition(self) -> Optional[wx.Position]: itemRect: wx.Rect = self.GetItemRect(firstSelectedIndex) return itemRect.GetBottomLeft() - def _updateBulkContextMenuSelection(self): + def _updateBatchContextMenuSelection(self): numSelected = self.GetSelectedItemCount() prevSelectedIndex = self.GetFirstSelected() selectedAddons: List[AddonListItemVM] = [] @@ -89,14 +89,14 @@ def _updateBulkContextMenuSelection(self): selectedAddons.append(addon) prevSelectedIndex = self.GetNextSelected(prevSelectedIndex) - self._bulkActionsContextMenu._updateSelectedAddons(selectedAddons) + self._batchActionsContextMenu._updateSelectedAddons(selectedAddons) @property def _contextMenu(self) -> _ActionsContextMenuP: numSelected = self.GetSelectedItemCount() if numSelected > 1: - self._updateBulkContextMenuSelection() - return self._bulkActionsContextMenu + self._updateBatchContextMenuSelection() + return self._batchActionsContextMenu return self._actionsContextMenu def _popupContextMenuFromList(self, evt: wx.ContextMenuEvent): diff --git a/source/gui/_addonStoreGui/viewModels/action.py b/source/gui/_addonStoreGui/viewModels/action.py index 7c373ce5374..2a9189a0bf4 100644 --- a/source/gui/_addonStoreGui/viewModels/action.py +++ b/source/gui/_addonStoreGui/viewModels/action.py @@ -116,11 +116,11 @@ def actionTarget(self, newActionTarget: Optional["AddonListItemVM"]): self._notify() -class BulkAddonActionVM(_AddonAction[Iterable["AddonListItemVM"]]): +class BatchAddonActionVM(_AddonAction[Iterable["AddonListItemVM"]]): """ Actions/behaviour that can be embedded within other views/viewModels that can apply to a group of L{AddonListItemVM}. - Use the L{BulkAddonActionVM.updated} extensionPoint.Action to be notified about changes. + Use the L{BatchAddonActionVM.updated} extensionPoint.Action to be notified about changes. E.G.: - Updates within the AddonListItemVM (perhaps changing the action validity) - Entirely changing the AddonListItemVM action will be applied to, the validity can be checked for the new From d583b69527b1e49707c09e043367b2e8e14c0ca1 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Thu, 31 Aug 2023 17:15:01 +1000 Subject: [PATCH 12/22] add manual test plan --- tests/manual/addonStore.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/manual/addonStore.md b/tests/manual/addonStore.md index a282caee65d..17ea1db4228 100644 --- a/tests/manual/addonStore.md +++ b/tests/manual/addonStore.md @@ -45,11 +45,21 @@ Add-ons can be filtered by display name, publisher and description. ### Install add-on from add-on store 1. Open the add-on store. +1. Navigate to the available add-ons tab. 1. Select an add-on. -1. Navigate to and press the install button for the add-on. +1. Using the context menu, install the add-on. 1. Exit the dialog 1. Restart NVDA as prompted. -1. Confirm the add-on is listed in the add-ons store. +1. Confirm the add-ons are listed in the installed add-ons tab of the add-ons store. + +### Batch install add-ons from add-on store +1. Open the add-on store. +1. Navigate to the available add-ons tab. +1. Select multiple add-ons using `shift` and `ctrl`. +1. Using the context menu, install the add-ons. +1. Exit the dialog +1. Restart NVDA as prompted. +1. Confirm the add-ons are listed in the installed add-ons tab of the add-ons store. ### Install add-on from external source in add-on store 1. Open the add-on store. From e1a5f88554875515a4d51d31dab149eed930225d Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Thu, 31 Aug 2023 17:20:27 +1000 Subject: [PATCH 13/22] simplify fix/type error --- source/gui/_addonStoreGui/controls/actions.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/source/gui/_addonStoreGui/controls/actions.py b/source/gui/_addonStoreGui/controls/actions.py index 6ffa30b43f6..b6991dfa90c 100644 --- a/source/gui/_addonStoreGui/controls/actions.py +++ b/source/gui/_addonStoreGui/controls/actions.py @@ -3,6 +3,7 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. +from abc import ABC, abstractmethod import functools from typing import ( Dict, @@ -11,7 +12,6 @@ List, TypeVar, ) -from typing_extensions import Protocol import wx @@ -27,11 +27,12 @@ AddonActionT = TypeVar("AddonActionT", AddonActionVM, BatchAddonActionVM) -class _ActionsContextMenuP(Generic[AddonActionT], Protocol): +class _ActionsContextMenuP(Generic[AddonActionT], ABC): _actions: List[AddonActionT] _actionMenuItemMap: Dict[AddonActionT, wx.MenuItem] _contextMenu: wx.Menu + @abstractmethod def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: AddonActionT): ... From 424a3afbe7d235234094e83d38243a548db210ea Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Wed, 6 Sep 2023 11:05:39 +1000 Subject: [PATCH 14/22] Fix background downloading of add-ons --- source/_addonStore/dataManager.py | 9 +-- source/_addonStore/models/addon.py | 14 ++++- source/_addonStore/models/status.py | 11 ++-- source/_addonStore/network.py | 63 ++++++++++++------- .../_addonStoreGui/viewModels/addonList.py | 13 ++-- source/gui/_addonStoreGui/viewModels/store.py | 46 ++++++++++---- 6 files changed, 110 insertions(+), 46 deletions(-) diff --git a/source/_addonStore/dataManager.py b/source/_addonStore/dataManager.py index 7fa5a35f29b..99530859871 100644 --- a/source/_addonStore/dataManager.py +++ b/source/_addonStore/dataManager.py @@ -49,7 +49,7 @@ if TYPE_CHECKING: from addonHandler import Addon as AddonHandlerModel # noqa: F401 # AddonGUICollectionT must only be imported when TYPE_CHECKING - from .models.addon import AddonGUICollectionT # noqa: F401 + from .models.addon import AddonGUICollectionT, _AddonStoreModel # noqa: F401 from gui._addonStoreGui.viewModels.addonList import AddonListItemVM # noqa: F401 from gui.message import DisplayableError # noqa: F401 @@ -69,7 +69,8 @@ def initialize(): class _DataManager: _cacheLatestFilename: str = "_cachedLatestAddons.json" _cacheCompatibleFilename: str = "_cachedCompatibleAddons.json" - _downloadsPendingInstall: Set[Tuple["AddonListItemVM", os.PathLike]] = set() + _downloadsPendingInstall: Set[Tuple["AddonListItemVM[_AddonStoreModel]", os.PathLike]] = set() + _downloadsPendingCompletion: Set["AddonListItemVM[_AddonStoreModel]"] = set() def __init__(self): self._lang = languageHandler.getLanguage() @@ -123,7 +124,7 @@ def _getCacheHash(self) -> Optional[str]: cacheHash = response.json() return cacheHash - def _cacheCompatibleAddons(self, addonData: str, cacheHash: str): + def _cacheCompatibleAddons(self, addonData: str, cacheHash: Optional[str]): if not NVDAState.shouldWriteToDisk(): return if not addonData or not cacheHash: @@ -137,7 +138,7 @@ def _cacheCompatibleAddons(self, addonData: str, cacheHash: str): with open(self._cacheCompatibleFile, 'w', encoding='utf-8') as cacheFile: json.dump(cacheData, cacheFile, ensure_ascii=False) - def _cacheLatestAddons(self, addonData: str, cacheHash: str): + def _cacheLatestAddons(self, addonData: str, cacheHash: Optional[str]): if not NVDAState.shouldWriteToDisk(): return if not addonData or not cacheHash: diff --git a/source/_addonStore/models/addon.py b/source/_addonStore/models/addon.py index 329f68cc43f..2a00d374e7d 100644 --- a/source/_addonStore/models/addon.py +++ b/source/_addonStore/models/addon.py @@ -98,6 +98,7 @@ def listItemVMId(self) -> str: return f"{self.addonId}-{self.channel}" def asdict(self) -> Dict[str, Any]: + assert dataclasses.is_dataclass(self) jsonData = dataclasses.asdict(self) for field in jsonData: # dataclasses.asdict parses NamedTuples to JSON arrays, @@ -110,6 +111,15 @@ def asdict(self) -> Dict[str, Any]: class _AddonStoreModel(_AddonGUIModel): + addonId: str + displayName: str + description: str + addonVersionName: str + channel: Channel + homepage: Optional[str] + minNVDAVersion: MajorMinorPatch + lastTestedVersion: MajorMinorPatch + legacy: bool publisher: str license: str licenseURL: Optional[str] @@ -145,6 +155,7 @@ def cachedDownloadPath(self) -> str: def isPendingInstall(self) -> bool: """True if this addon has not yet been fully installed.""" from ..dataManager import addonDataManager + assert addonDataManager nameInDownloadsPendingInstall = filter( lambda m: m[0].model.name == self.name, # add-ons which have been downloaded but @@ -240,6 +251,7 @@ class InstalledAddonStoreModel(_AddonManifestModel, _AddonStoreModel): @property def manifest(self) -> "AddonManifest": from ..dataManager import addonDataManager + assert addonDataManager return addonDataManager._installedAddonsCache.installedAddons[self.name].manifest @@ -273,7 +285,7 @@ class AddonStoreModel(_AddonStoreModel): @dataclasses.dataclass class CachedAddonsModel: cachedAddonData: "AddonGUICollectionT" - cacheHash: str + cacheHash: Optional[str] cachedLanguage: str # AddonApiVersionT or the string .network._LATEST_API_VER nvdaAPIVersion: Union[addonAPIVersion.AddonApiVersionT, str] diff --git a/source/_addonStore/models/status.py b/source/_addonStore/models/status.py index b7a253e5f44..8b7df756d9b 100644 --- a/source/_addonStore/models/status.py +++ b/source/_addonStore/models/status.py @@ -8,7 +8,6 @@ from pathlib import Path from typing import ( Dict, - Optional, OrderedDict, Set, TYPE_CHECKING, @@ -51,6 +50,7 @@ class AvailableAddonStatus(DisplayStringEnum): """ Values to represent the status of add-ons within the NVDA add-on store. Although related, these are independent of the states in L{addonHandler} """ + UNKNOWN = enum.auto() PENDING_REMOVE = enum.auto() AVAILABLE = enum.auto() UPDATE = enum.auto() @@ -120,6 +120,8 @@ def _displayStringLabels(self) -> Dict["AvailableAddonStatus", str]: self.ENABLED: pgettext("addonStore", "Enabled"), # Translators: Status for addons shown in the add-on store dialog self.RUNNING: pgettext("addonStore", "Enabled"), + # Translators: Status for addons shown in the add-on store dialog + self.UNKNOWN: pgettext("addonStore", "Unknown status"), } @@ -144,7 +146,7 @@ class AddonStateCategory(str, enum.Enum): """Add-ons that are blocked from running because they are incompatible""" -def getStatus(model: "_AddonGUIModel") -> Optional[AvailableAddonStatus]: +def getStatus(model: "_AddonGUIModel") -> AvailableAddonStatus: from addonHandler import ( state as addonHandlerState, ) @@ -213,8 +215,8 @@ def getStatus(model: "_AddonGUIModel") -> Optional[AvailableAddonStatus]: if addonHandlerModel.isEnabled: return AvailableAddonStatus.ENABLED - log.debugWarning(f"Add-on in unknown state: {model.addonId}") - return None + log.error(f"Add-on in unknown state: {model.addonId}") + return AvailableAddonStatus.UNKNOWN _addonStoreStateToAddonHandlerState: OrderedDict[ @@ -341,6 +343,7 @@ def displayStringWithAccelerator(self) -> str: AvailableAddonStatus.PENDING_INCOMPATIBLE_ENABLED, AvailableAddonStatus.INCOMPATIBLE_DISABLED, AvailableAddonStatus.INCOMPATIBLE_ENABLED, + AvailableAddonStatus.UNKNOWN, }, }) """A dictionary where the keys are a status to filter by, diff --git a/source/_addonStore/network.py b/source/_addonStore/network.py index 013e6f2980a..2920e0ce863 100644 --- a/source/_addonStore/network.py +++ b/source/_addonStore/network.py @@ -3,6 +3,10 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. +# Needed for type hinting Future +# Can be removed in a future version of python (3.8+) +from __future__ import annotations + from concurrent.futures import ( Future, ThreadPoolExecutor, @@ -28,7 +32,6 @@ from utils.security import sha256_checksum from .models.addon import ( - AddonStoreModel, _AddonGUIModel, _AddonStoreModel, ) @@ -37,6 +40,7 @@ if TYPE_CHECKING: from gui.message import DisplayableError + from gui._addonStoreGui.viewModels.addonList import AddonListItemVM _BASE_URL = "https://nvaccess.org/addonStore" @@ -61,21 +65,28 @@ def _getCacheHashURL() -> str: class AddonFileDownloader: - OnCompleteT = Callable[[AddonStoreModel, Optional[os.PathLike]], None] + OnCompleteT = Callable[ + ["AddonListItemVM[_AddonStoreModel]", Optional[os.PathLike]], + None + ] def __init__(self): - self.progress: Dict[AddonStoreModel, int] = {} # Number of chunks received + self.progress: Dict["AddonListItemVM[_AddonStoreModel]", int] = {} # Number of chunks received self._pending: Dict[ - Future, + Future[Optional[os.PathLike]], Tuple[ - AddonStoreModel, + "AddonListItemVM[_AddonStoreModel]", AddonFileDownloader.OnCompleteT, "DisplayableError.OnDisplayableErrorT" ] ] = {} - self.complete: Dict[AddonStoreModel, os.PathLike] = {} # Path to downloaded file + self.complete: Dict[ + "AddonListItemVM[_AddonStoreModel]", + # Path to downloaded file + Optional[os.PathLike] + ] = {} self._executor = ThreadPoolExecutor( - max_workers=1, + max_workers=10, thread_name_prefix="AddonDownloader", ) @@ -85,20 +96,21 @@ def __init__(self): def download( self, - addonData: AddonStoreModel, + addonData: "AddonListItemVM[_AddonStoreModel]", onComplete: OnCompleteT, onDisplayableError: "DisplayableError.OnDisplayableErrorT", ): self.progress[addonData] = 0 - f: Future = self._executor.submit( + assert self._executor + f: Future[Optional[os.PathLike]] = self._executor.submit( self._download, addonData, ) self._pending[f] = addonData, onComplete, onDisplayableError f.add_done_callback(self._done) - def _done(self, downloadAddonFuture: Future): + def _done(self, downloadAddonFuture: Future[Optional[os.PathLike]]): isCancelled = downloadAddonFuture not in self._pending - addonId = "CANCELLED" if isCancelled else self._pending[downloadAddonFuture][0].addonId + addonId = "CANCELLED" if isCancelled else self._pending[downloadAddonFuture][0].model.addonId log.debug(f"Done called for {addonId}") if isCancelled: log.debug("Download was cancelled, not calling onComplete") @@ -108,6 +120,7 @@ def _done(self, downloadAddonFuture: Future): return addonData, onComplete, onDisplayableError = self._pending[downloadAddonFuture] downloadAddonFutureException = downloadAddonFuture.exception() + cacheFilePath: Optional[os.PathLike] if downloadAddonFutureException: cacheFilePath = None from gui.message import DisplayableError @@ -120,10 +133,12 @@ def _done(self, downloadAddonFuture: Future): displayableError=downloadAddonFutureException ) else: - cacheFilePath: Optional[os.PathLike] = downloadAddonFuture.result() + cacheFilePath = downloadAddonFuture.result() - del self._pending[downloadAddonFuture] - del self.progress[addonData] + # If canceled after our previous isCancelled check, + # then _pending and progress will be empty. + self._pending.pop(downloadAddonFuture, None) + self.progress.pop(addonData, None) self.complete[addonData] = cacheFilePath onComplete(addonData, cacheFilePath) @@ -131,12 +146,17 @@ def cancelAll(self): log.debug("Cancelling all") for f in self._pending.keys(): f.cancel() + assert self._executor self._executor.shutdown(wait=False) self._executor = None self.progress.clear() self._pending.clear() - def _downloadAddonToPath(self, addonData: AddonStoreModel, downloadFilePath: str) -> bool: + def _downloadAddonToPath( + self, + addonData: "AddonListItemVM[_AddonStoreModel]", + downloadFilePath: str + ) -> bool: """ @return: True if the add-on is downloaded successfully, False if the download is cancelled @@ -144,7 +164,7 @@ def _downloadAddonToPath(self, addonData: AddonStoreModel, downloadFilePath: str if not NVDAState.shouldWriteToDisk(): return False - with requests.get(addonData.URL, stream=True) as r: + with requests.get(addonData.model.URL, stream=True) as r: with open(downloadFilePath, 'wb') as fd: # Most add-ons are small. This value was chosen quite arbitrarily, but with the intention to allow # interrupting the download. This is particularly important on a slow connection, to provide @@ -159,15 +179,16 @@ def _downloadAddonToPath(self, addonData: AddonStoreModel, downloadFilePath: str if addonData in self.progress: # Removed when the download should be cancelled. self.progress[addonData] += 1 else: - log.debug(f"Cancelled download: {addonData.addonId}") + log.debug(f"Cancelled download: {addonData.model.addonId}") return False # The download was cancelled return True - def _download(self, addonData: AddonStoreModel) -> Optional[os.PathLike]: + def _download(self, listItem: "AddonListItemVM[_AddonStoreModel]") -> Optional[os.PathLike]: from gui.message import DisplayableError # Translators: A title for a dialog notifying a user of an add-on download failure. _addonDownloadFailureMessageTitle = pgettext("addonStore", "Add-on download failure") + addonData = listItem.model log.debug(f"starting download: {addonData.addonId}") cacheFilePath = addonData.cachedDownloadPath if os.path.exists(cacheFilePath): @@ -175,11 +196,11 @@ def _download(self, addonData: AddonStoreModel) -> Optional[os.PathLike]: os.remove(cacheFilePath) inProgressFilePath = addonData.tempDownloadPath - if addonData not in self.progress: + if listItem not in self.progress: log.debug("the download was cancelled before it started.") return None # The download was cancelled try: - if not self._downloadAddonToPath(addonData, inProgressFilePath): + if not self._downloadAddonToPath(listItem, inProgressFilePath): return None # The download was cancelled except requests.exceptions.RequestException as e: log.debugWarning(f"Unable to download addon file: {e}") @@ -218,7 +239,7 @@ def _download(self, addonData: AddonStoreModel) -> Optional[os.PathLike]: return cast(os.PathLike, cacheFilePath) @staticmethod - def _checkChecksum(addonFilePath: str, addonData: _AddonStoreModel) -> Optional[os.PathLike]: + def _checkChecksum(addonFilePath: str, addonData: _AddonStoreModel) -> bool: with open(addonFilePath, "rb") as f: sha256Addon = sha256_checksum(f) return sha256Addon.casefold() == addonData.sha256.casefold() diff --git a/source/gui/_addonStoreGui/viewModels/addonList.py b/source/gui/_addonStoreGui/viewModels/addonList.py index 2ee5c12dc64..a962d8a2b27 100644 --- a/source/gui/_addonStoreGui/viewModels/addonList.py +++ b/source/gui/_addonStoreGui/viewModels/addonList.py @@ -12,9 +12,11 @@ from locale import strxfrm from typing import ( FrozenSet, + Generic, List, Optional, TYPE_CHECKING, + TypeVar, ) from requests.structures import CaseInsensitiveDict @@ -92,18 +94,21 @@ class AddonListField(_AddonListFieldData, Enum): ) -class AddonListItemVM: +_AddonModelT = TypeVar("_AddonModelT", bound=_AddonGUIModel) + + +class AddonListItemVM(Generic[_AddonModelT]): def __init__( self, - model: _AddonGUIModel, + model: _AddonModelT, status: AvailableAddonStatus = AvailableAddonStatus.AVAILABLE ): - self._model: _AddonGUIModel = model # read-only + self._model: _AddonModelT = model # read-only self._status: AvailableAddonStatus = status # modifications triggers L{updated.notify} self.updated = extensionPoints.Action() # Notify of changes to VM, argument: addonListItemVM @property - def model(self) -> _AddonGUIModel: + def model(self) -> _AddonModelT: return self._model @property diff --git a/source/gui/_addonStoreGui/viewModels/store.py b/source/gui/_addonStoreGui/viewModels/store.py index bf2a9976e27..0076065d568 100644 --- a/source/gui/_addonStoreGui/viewModels/store.py +++ b/source/gui/_addonStoreGui/viewModels/store.py @@ -13,6 +13,7 @@ ) import os from typing import ( + Iterable, List, Optional, cast, @@ -62,6 +63,7 @@ class AddonStoreVM: def __init__(self): + assert addonDataManager self._installedAddons = addonDataManager._installedAddonsCache.installedAddonGUICollection self._availableAddons = _createAddonGUICollection() self.hasError = extensionPoints.Action() @@ -224,7 +226,12 @@ def _makeActionsList(self): AddonActionVM( # Translators: Label for an action that opens the license for the selected addon displayName=pgettext("addonStore", "&License"), - actionHandler=lambda aVM: startfile(cast(_AddonStoreModel, aVM.model).licenseURL), + actionHandler=lambda aVM: startfile( + cast( + str, + cast(_AddonStoreModel, aVM.model).licenseURL + ) + ), validCheck=lambda aVM: ( isinstance(aVM.model, _AddonStoreModel) and aVM.model.licenseURL is not None @@ -246,9 +253,12 @@ def helpAddon(self, listItemVM: AddonListItemVM) -> None: assert path is not None startfile(path) - def removeAddon(self, listItemVM: AddonListItemVM) -> None: + def removeAddon(self, listItemVM: AddonListItemVM[_AddonGUIModel]) -> None: + assert addonDataManager + assert listItemVM.model if _shouldProceedToRemoveAddonDialog(listItemVM.model): addonDataManager._deleteCacheInstalledAddon(listItemVM.model.name) + assert listItemVM.model._addonHandlerModel is not None listItemVM.model._addonHandlerModel.requestRemove() self.refresh() listItemVM.status = getStatus(listItemVM.model) @@ -278,11 +288,11 @@ def _handleEnableDisable(self, listItemVM: AddonListItemVM, shouldEnable: bool) try: listItemVM.model._addonHandlerModel.enable(shouldEnable) except addonHandler.AddonError: - log.debug(exc_info=True) if shouldEnable: errorMessage = self._enableErrorMessage else: errorMessage = self._disableErrorMessage + log.debug(errorMessage, exc_info=True) displayableError = DisplayableError( displayMessage=errorMessage.format(addon=listItemVM.model.displayName) ) @@ -309,16 +319,26 @@ def replaceAddon(self, listItemVM: AddonListItemVM) -> None: if _shouldProceedWhenInstalledAddonVersionUnknown(mainFrame, listItemVM.model): self.getAddon(listItemVM) - def getAddon(self, listItemVM: AddonListItemVM) -> None: + def getAddon(self, listItemVM: AddonListItemVM[_AddonStoreModel]) -> None: + assert addonDataManager + addonDataManager._downloadsPendingCompletion.add(listItemVM) listItemVM.status = AvailableAddonStatus.DOWNLOADING log.debug(f"{listItemVM.Id} status: {listItemVM.status}") - self._downloader.download(listItemVM.model, self._downloadComplete, self.onDisplayableError) + self._downloader.download(listItemVM, self._downloadComplete, self.onDisplayableError) - def _downloadComplete(self, addonDetails: AddonStoreModel, fileDownloaded: Optional[PathLike]): - listItemVM: Optional[AddonListItemVM] = self.listVM._addons[addonDetails.listItemVMId] - if listItemVM is None: - log.error(f"No list item VM for addon with id: {addonDetails.addonId}") - return + def getAddons(self, listItemVMs: Iterable[AddonListItemVM[_AddonStoreModel]]) -> None: + for aVM in listItemVMs: + if not aVM.model.isCompatible and aVM.model.canOverrideCompatibility: + self.installOverrideIncompatibilityForAddon(aVM) + else: + self.getAddon(aVM) + + def _downloadComplete( + self, + listItemVM: AddonListItemVM[_AddonStoreModel], + fileDownloaded: Optional[PathLike] + ): + addonDataManager._downloadsPendingCompletion.remove(listItemVM) if fileDownloaded is None: # Download may have been cancelled or otherwise failed @@ -329,6 +349,7 @@ def _downloadComplete(self, addonDetails: AddonStoreModel, fileDownloaded: Optio listItemVM.status = AvailableAddonStatus.DOWNLOAD_SUCCESS log.debug(f"Queuing add-on for install on dialog exit: {listItemVM.Id}") # Add-ons can have "installTasks", which often call the GUI assuming they are on the main thread. + assert addonDataManager addonDataManager._downloadsPendingInstall.add((listItemVM, fileDownloaded)) def installPending(self): @@ -406,8 +427,9 @@ def _getAvailableAddonsInBG(self): log.debug("completed refresh") def cancelDownloads(self): - for a in self._downloader.progress.keys(): - self.listVM._addons[a.listItemVMId].status = AvailableAddonStatus.AVAILABLE + while addonDataManager._downloadsPendingCompletion: + listItem = addonDataManager._downloadsPendingCompletion.pop() + listItem.status = AvailableAddonStatus.AVAILABLE self._downloader.cancelAll() def _filterByEnabledKey(self, model: _AddonGUIModel) -> bool: From e0f4bebcb3174eb6aea32c98d0dc9a9f1d0fd5fa Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Wed, 6 Sep 2023 11:13:14 +1000 Subject: [PATCH 15/22] Allow bulk install --- source/gui/_addonStoreGui/controls/actions.py | 97 +++++++++-- .../gui/_addonStoreGui/controls/addonList.py | 53 ++++-- source/gui/_addonStoreGui/controls/details.py | 4 +- .../_addonStoreGui/controls/storeDialog.py | 4 +- .../gui/_addonStoreGui/viewModels/action.py | 154 ++++++++++++++---- .../_addonStoreGui/viewModels/addonList.py | 5 + source/gui/_addonStoreGui/viewModels/store.py | 26 +-- tests/manual/addonStore.md | 14 +- user_docs/en/userGuide.t2t | 3 + 9 files changed, 285 insertions(+), 75 deletions(-) diff --git a/source/gui/_addonStoreGui/controls/actions.py b/source/gui/_addonStoreGui/controls/actions.py index 354bc64407f..b6991dfa90c 100644 --- a/source/gui/_addonStoreGui/controls/actions.py +++ b/source/gui/_addonStoreGui/controls/actions.py @@ -3,25 +3,38 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. +from abc import ABC, abstractmethod import functools from typing import ( Dict, + Generic, + Iterable, List, + TypeVar, ) import wx +from _addonStore.models.status import _StatusFilterKey from logHandler import log +import ui -from ..viewModels.action import AddonActionVM +from ..viewModels.action import AddonActionVM, BatchAddonActionVM +from ..viewModels.addonList import AddonListItemVM from ..viewModels.store import AddonStoreVM -class _ActionsContextMenu: - def __init__(self, storeVM: AddonStoreVM): - self._storeVM = storeVM - self._actionMenuItemMap: Dict[AddonActionVM, wx.MenuItem] = {} - self._contextMenu = wx.Menu() +AddonActionT = TypeVar("AddonActionT", AddonActionVM, BatchAddonActionVM) + + +class _ActionsContextMenuP(Generic[AddonActionT], ABC): + _actions: List[AddonActionT] + _actionMenuItemMap: Dict[AddonActionT, wx.MenuItem] + _contextMenu: wx.Menu + + @abstractmethod + def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: AddonActionT): + ... def popupContextMenuFromPosition( self, @@ -31,14 +44,9 @@ def popupContextMenuFromPosition( self._populateContextMenu() targetWindow.PopupMenu(self._contextMenu, pos=position) - def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: AddonActionVM): - selectedAddon = actionVM.listItemVM - log.debug(f"action selected: actionVM: {actionVM}, selectedAddon: {selectedAddon}") - actionVM.actionHandler(selectedAddon) - def _populateContextMenu(self): prevActionIndex = -1 - for action in self._storeVM.actionVMList: + for action in self._actions: menuItem = self._actionMenuItemMap.get(action) menuItems: List[wx.MenuItem] = list(self._contextMenu.GetMenuItems()) isMenuItemInContextMenu = menuItem is not None and menuItem in menuItems @@ -71,3 +79,68 @@ def _populateContextMenu(self): # Remove the menu item from the context menu. self._contextMenu.RemoveItem(menuItem) del self._actionMenuItemMap[action] + + menuItems: List[wx.MenuItem] = list(self._contextMenu.GetMenuItems()) + for menuItem in menuItems: + if menuItem not in self._actionMenuItemMap.values(): + # The menu item is not in the action menu item map. + # It should be removed from the context menu. + self._contextMenu.RemoveItem(menuItem) + + +class _MonoActionsContextMenu(_ActionsContextMenuP[AddonActionVM]): + """Context menu for actions for a single add-on""" + def __init__(self, storeVM: AddonStoreVM): + self._storeVM = storeVM + self._actionMenuItemMap = {} + self._contextMenu = wx.Menu() + + def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: AddonActionVM): + selectedAddon = actionVM.actionTarget + log.debug(f"action selected: actionVM: {actionVM.displayName}, selectedAddon: {selectedAddon}") + actionVM.actionHandler(selectedAddon) + + @property + def _actions(self) -> List[AddonActionVM]: + return self._storeVM.actionVMList + + +class _BatchActionsContextMenu(_ActionsContextMenuP[BatchAddonActionVM]): + """Context menu for actions for a group of add-ons""" + def __init__(self, storeVM: AddonStoreVM): + self._storeVM = storeVM + self._actionMenuItemMap = {} + self._contextMenu = wx.Menu() + self._selectedAddons: Iterable[AddonListItemVM] = tuple() + + def _updateSelectedAddons(self, selectedAddons: Iterable[AddonListItemVM]): + # Reset the action menu as self._actions depends on the selected add-ons + self._actionMenuItemMap = {} + self._selectedAddons = selectedAddons + + def popupContextMenuFromPosition( + self, + targetWindow: wx.Window, + position: wx.Position = wx.DefaultPosition + ): + super().popupContextMenuFromPosition(targetWindow, position) + if self._contextMenu.GetMenuItemCount() == 0: + # Translators: a message displayed when activating the context menu on multiple selected add-ons, + # but no actions are available for the add-ons. + ui.message(pgettext("addonStore", "No actions available for the selected add-ons")) + + def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: BatchAddonActionVM): + log.debug(f"Performing batch action for actionVM: {actionVM.displayName}") + actionVM.actionHandler(self._selectedAddons) + + @property + def _actions(self) -> List[BatchAddonActionVM]: + return [ + BatchAddonActionVM( + # Translators: Label for an action that installs the selected add-ons + displayName=pgettext("addonStore", "&Install selected add-ons"), + actionHandler=self._storeVM.getAddons, + validCheck=lambda aVMs: self._storeVM._filteredStatusKey == _StatusFilterKey.AVAILABLE, + actionTarget=self._selectedAddons + ), + ] diff --git a/source/gui/_addonStoreGui/controls/addonList.py b/source/gui/_addonStoreGui/controls/addonList.py index 09375d5e357..107464d32ba 100644 --- a/source/gui/_addonStoreGui/controls/addonList.py +++ b/source/gui/_addonStoreGui/controls/addonList.py @@ -4,6 +4,7 @@ # See the file COPYING for more details. from typing import ( + List, Optional, ) @@ -16,8 +17,12 @@ from gui.dpiScalingHelper import DpiScalingHelperMixinWithoutInit from logHandler import log -from .actions import _ActionsContextMenu -from ..viewModels.addonList import AddonListVM +from .actions import ( + _ActionsContextMenuP, + _BatchActionsContextMenu, + _MonoActionsContextMenu, +) +from ..viewModels.addonList import AddonListItemVM, AddonListVM class AddonVirtualList( @@ -28,14 +33,13 @@ def __init__( self, parent: wx.Window, addonsListVM: AddonListVM, - actionsContextMenu: _ActionsContextMenu, + actionsContextMenu: _MonoActionsContextMenu, ): super().__init__( parent, style=( wx.LC_REPORT # Single or multicolumn report view, with optional header. | wx.LC_VIRTUAL # The application provides items text on demand. May only be used with LC_REPORT. - | wx.LC_SINGLE_SEL # Single selection (default is multiple). | wx.LC_HRULES # Draws light horizontal rules between rows in report mode. | wx.LC_VRULES # Draws light vertical rules between columns in report mode. ), @@ -43,6 +47,7 @@ def __init__( ) self._addonsListVM = addonsListVM self._actionsContextMenu = actionsContextMenu + self._batchActionsContextMenu = _BatchActionsContextMenu(self._addonsListVM._storeVM) self.SetMinSize(self.scaleSize((500, 500))) @@ -75,6 +80,25 @@ def _getListSelectionPosition(self) -> Optional[wx.Position]: itemRect: wx.Rect = self.GetItemRect(firstSelectedIndex) return itemRect.GetBottomLeft() + def _updateBatchContextMenuSelection(self): + numSelected = self.GetSelectedItemCount() + prevSelectedIndex = self.GetFirstSelected() + selectedAddons: List[AddonListItemVM] = [] + for _ in range(numSelected): + addon = self._addonsListVM.getAddonAtIndex(prevSelectedIndex) + selectedAddons.append(addon) + prevSelectedIndex = self.GetNextSelected(prevSelectedIndex) + + self._batchActionsContextMenu._updateSelectedAddons(selectedAddons) + + @property + def _contextMenu(self) -> _ActionsContextMenuP: + numSelected = self.GetSelectedItemCount() + if numSelected > 1: + self._updateBatchContextMenuSelection() + return self._batchActionsContextMenu + return self._actionsContextMenu + def _popupContextMenuFromList(self, evt: wx.ContextMenuEvent): listSelectionPosition = self._getListSelectionPosition() if listSelectionPosition is None: @@ -83,29 +107,38 @@ def _popupContextMenuFromList(self, evt: wx.ContextMenuEvent): if eventPosition == wx.DefaultPosition: # keyboard triggered context menu (due to "applications" key) # don't have position set. It must be fetched from the selected item. - self._actionsContextMenu.popupContextMenuFromPosition(self, listSelectionPosition) + self._contextMenu.popupContextMenuFromPosition(self, listSelectionPosition) else: # Mouse (right click) triggered context menu. # In this case the menu is positioned better with GetPopupMenuSelectionFromUser. - self._actionsContextMenu.popupContextMenuFromPosition(self) + self._contextMenu.popupContextMenuFromPosition(self) def _itemDataUpdated(self, index: int): log.debug(f"index: {index}") self.RefreshItem(index) def OnItemSelected(self, evt: wx.ListEvent): - newIndex = evt.GetIndex() + newIndex = self.GetFirstSelected() log.debug(f"item selected: {newIndex}") self._addonsListVM.setSelection(index=newIndex) def OnItemActivated(self, evt: wx.ListEvent): position = self._getListSelectionPosition() - self._actionsContextMenu.popupContextMenuFromPosition(self, position) + self._contextMenu.popupContextMenuFromPosition(self, position) log.debug(f"item activated: {evt.GetIndex()}") def OnItemDeselected(self, evt: wx.ListEvent): - log.debug(f"item deselected") - self._addonsListVM.setSelection(None) + # Call this later, as the list control doesn't update its selection until after this event, + # and we need an accurate selection count. + wx.CallAfter(self._OnItemDeselected, evt) + + def _OnItemDeselected(self, evt: wx.ListEvent): + if self.GetSelectedItemCount() == 0: + log.debug("item deselected") + self._addonsListVM.setSelection(None) + else: + log.debug("updating selection due to item deselected") + self.OnItemSelected(evt) def OnGetItemText(self, itemIndex: int, colIndex: int) -> str: dataItem = self._addonsListVM.getAddonFieldText( diff --git a/source/gui/_addonStoreGui/controls/details.py b/source/gui/_addonStoreGui/controls/details.py index 182a0141a28..22974126942 100644 --- a/source/gui/_addonStoreGui/controls/details.py +++ b/source/gui/_addonStoreGui/controls/details.py @@ -15,7 +15,7 @@ from ..viewModels.addonList import AddonDetailsVM, AddonListField -from .actions import _ActionsContextMenu +from .actions import _MonoActionsContextMenu _fontFaceName = "Segoe UI" _fontFaceName_semiBold = "Segoe UI Semibold" @@ -49,7 +49,7 @@ def __init__( self, parent: wx.Window, detailsVM: AddonDetailsVM, - actionsContextMenu: _ActionsContextMenu, + actionsContextMenu: _MonoActionsContextMenu, ): self._detailsVM: AddonDetailsVM = detailsVM self._actionsContextMenu = actionsContextMenu diff --git a/source/gui/_addonStoreGui/controls/storeDialog.py b/source/gui/_addonStoreGui/controls/storeDialog.py index 5e527163779..7f2b8a90b96 100644 --- a/source/gui/_addonStoreGui/controls/storeDialog.py +++ b/source/gui/_addonStoreGui/controls/storeDialog.py @@ -33,7 +33,7 @@ from logHandler import log from ..viewModels.store import AddonStoreVM -from .actions import _ActionsContextMenu +from .actions import _MonoActionsContextMenu from .addonList import AddonVirtualList from .details import AddonDetails from .messageDialogs import _SafetyWarningDialog @@ -50,7 +50,7 @@ class AddonStoreDialog(SettingsDialog): def __init__(self, parent: wx.Window, storeVM: AddonStoreVM): self._storeVM = storeVM self._storeVM.onDisplayableError.register(self.handleDisplayableError) - self._actionsContextMenu = _ActionsContextMenu(self._storeVM) + self._actionsContextMenu = _MonoActionsContextMenu(self._storeVM) super().__init__(parent, resizeable=True, buttons={wx.CLOSE}) if config.conf["addonStore"]["showWarning"]: displayDialogAsModal(_SafetyWarningDialog(parent)) diff --git a/source/gui/_addonStoreGui/viewModels/action.py b/source/gui/_addonStoreGui/viewModels/action.py index 9ddddc55cde..2a9189a0bf4 100644 --- a/source/gui/_addonStoreGui/viewModels/action.py +++ b/source/gui/_addonStoreGui/viewModels/action.py @@ -3,19 +3,66 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. +from abc import ABC, abstractmethod from typing import ( Callable, + Generic, + Iterable, Optional, + TypeVar, TYPE_CHECKING, ) import extensionPoints +from logHandler import log if TYPE_CHECKING: from .addonList import AddonListItemVM -class AddonActionVM: +ActionTargetT = TypeVar("ActionTargetT", Optional["AddonListItemVM"], Iterable["AddonListItemVM"]) + + +class _AddonAction(Generic[ActionTargetT], ABC): + def __init__( + self, + displayName: str, + actionHandler: Callable[[ActionTargetT, ], None], + validCheck: Callable[[ActionTargetT, ], bool], + actionTarget: ActionTargetT, + ): + """ + @param displayName: Translated string, to be displayed to the user. Should describe the action / behaviour. + @param actionHandler: Call when the action is triggered. + @param validCheck: Is the action valid for the current target + @param actionTarget: The target this action will be applied to. L{updated} notifies of modification. + """ + self.displayName = displayName + self.actionHandler = actionHandler + self._validCheck = validCheck + self._actionTarget = actionTarget + self.updated = extensionPoints.Action() + """Notify of changes to the action""" + + @abstractmethod + def _listItemChanged(self, addonListItemVM: "AddonListItemVM"): + ... + + @property + def isValid(self) -> bool: + return self._validCheck(self._actionTarget) + + @property + def actionTarget(self) -> ActionTargetT: + return self._actionTarget + + def _notify(self): + # ensure calling on the main thread. + from core import callLater + callLater(delay=0, callable=self.updated.notify, addonActionVM=self) + + +class AddonActionVM(_AddonAction[Optional["AddonListItemVM"]]): """ Actions/behaviour that can be embedded within other views/viewModels that can apply to a single L{AddonListItemVM}. Use the L{AddonActionVM.updated} extensionPoint.Action to be notified about changes. @@ -29,51 +76,90 @@ def __init__( displayName: str, actionHandler: Callable[["AddonListItemVM", ], None], validCheck: Callable[["AddonListItemVM", ], bool], - listItemVM: Optional["AddonListItemVM"], + actionTarget: Optional["AddonListItemVM"], ): """ @param displayName: Translated string, to be displayed to the user. Should describe the action / behaviour. @param actionHandler: Call when the action is triggered. @param validCheck: Is the action valid for the current listItemVM - @param listItemVM: The listItemVM this action will be applied to. L{updated} notifies of modification. + @param actionTarget: The listItemVM this action will be applied to. L{updated} notifies of modification. """ - self.displayName: str = displayName - self.actionHandler: Callable[["AddonListItemVM", ], None] = actionHandler - self._validCheck: Callable[["AddonListItemVM", ], bool] = validCheck - self._listItemVM: Optional["AddonListItemVM"] = listItemVM - if listItemVM: - listItemVM.updated.register(self._listItemChanged) - self.updated = extensionPoints.Action() - """Notify of changes to the action""" + def _validCheck(listItemVM: Optional["AddonListItemVM"]) -> bool: + # Handle the None case so that each validCheck doesn't have to. + return listItemVM is not None and validCheck(listItemVM) - def _listItemChanged(self, addonListItemVM: "AddonListItemVM"): + def _actionHandler(listItemVM: Optional["AddonListItemVM"]): + # Handle the None case so that each actionHandler doesn't have to. + if listItemVM is not None: + actionHandler(listItemVM) + else: + log.warning(f"Action triggered for invalid None listItemVM: {self.displayName}") + + super().__init__(displayName, _actionHandler, _validCheck, actionTarget) + if actionTarget: + actionTarget.updated.register(self._listItemChanged) + + def _listItemChanged(self, addonListItemVM: Optional["AddonListItemVM"]): """Something inside the AddonListItemVM has changed""" - assert self._listItemVM == addonListItemVM + assert self._actionTarget == addonListItemVM self._notify() - def _notify(self): - # ensure calling on the main thread. - from core import callLater - callLater(delay=0, callable=self.updated.notify, addonActionVM=self) + @_AddonAction.actionTarget.setter + def actionTarget(self, newActionTarget: Optional["AddonListItemVM"]): + if self._actionTarget == newActionTarget: + return + if self._actionTarget: + self._actionTarget.updated.unregister(self._listItemChanged) + if newActionTarget: + newActionTarget.updated.register(self._listItemChanged) + self._actionTarget = newActionTarget + self._notify() - @property - def isValid(self) -> bool: - return ( - self._listItemVM is not None - and self._validCheck(self._listItemVM) - ) - @property - def listItemVM(self) -> Optional["AddonListItemVM"]: - return self._listItemVM +class BatchAddonActionVM(_AddonAction[Iterable["AddonListItemVM"]]): + """ + Actions/behaviour that can be embedded within other views/viewModels + that can apply to a group of L{AddonListItemVM}. + Use the L{BatchAddonActionVM.updated} extensionPoint.Action to be notified about changes. + E.G.: + - Updates within the AddonListItemVM (perhaps changing the action validity) + - Entirely changing the AddonListItemVM action will be applied to, the validity can be checked for the new + item. + """ + def __init__( + self, + displayName: str, + actionHandler: Callable[[Iterable["AddonListItemVM"], ], None], + validCheck: Callable[[Iterable["AddonListItemVM"], ], bool], + actionTarget: Iterable["AddonListItemVM"], + ): + """ + @param displayName: Translated string, to be displayed to the user. Should describe the action / behaviour. + @param actionHandler: Call when the action is triggered. + @param validCheck: Is the action valid for the current listItemVMs + @param actionTarget: The listItemVMs this action will be applied to. L{updated} notifies of modification. + """ + super().__init__(displayName, actionHandler, validCheck, actionTarget) + for listItemVM in self._actionTarget: + listItemVM.updated.register(self._listItemChanged) - @listItemVM.setter - def listItemVM(self, listItemVM: Optional["AddonListItemVM"]): - if self._listItemVM == listItemVM: + def _listItemChanged(self, addonListItemVM: "AddonListItemVM"): + """Something inside the AddonListItemVM has changed""" + assert addonListItemVM in self._actionTarget + self._notify() + + @_AddonAction.actionTarget.setter + def actionTarget(self, newActionTarget: Iterable["AddonListItemVM"]): + if self._actionTarget == newActionTarget: return - if self._listItemVM: - self._listItemVM.updated.unregister(self._listItemChanged) - if listItemVM: - listItemVM.updated.register(self._listItemChanged) - self._listItemVM = listItemVM + + for oldListItemVM in self._actionTarget: + if oldListItemVM not in newActionTarget: + oldListItemVM.updated.unregister(self._listItemChanged) + + for newListItemVM in newActionTarget: + if newListItemVM not in self._actionTarget: + newListItemVM.updated.register(self._listItemChanged) + + self._actionTarget = newActionTarget self._notify() diff --git a/source/gui/_addonStoreGui/viewModels/addonList.py b/source/gui/_addonStoreGui/viewModels/addonList.py index a962d8a2b27..b91f303c7e0 100644 --- a/source/gui/_addonStoreGui/viewModels/addonList.py +++ b/source/gui/_addonStoreGui/viewModels/addonList.py @@ -258,6 +258,11 @@ def getSelectedIndex(self) -> Optional[int]: return self._addonsFilteredOrdered.index(self.selectedAddonId) return None + def getAddonAtIndex(self, index: int) -> AddonListItemVM: + self._validate(selectionIndex=index) + selectedAddonId = self._addonsFilteredOrdered[index] + return self._addons[selectedAddonId] + def setSelection(self, index: Optional[int]) -> Optional[AddonListItemVM]: self._validate(selectionIndex=index) self.selectedAddonId = None diff --git a/source/gui/_addonStoreGui/viewModels/store.py b/source/gui/_addonStoreGui/viewModels/store.py index 0076065d568..55ce048b5fc 100644 --- a/source/gui/_addonStoreGui/viewModels/store.py +++ b/source/gui/_addonStoreGui/viewModels/store.py @@ -110,7 +110,7 @@ def _onSelectedItemChanged(self): log.debug(f"Setting selection: {selectedVM}") self.detailsVM.listItem = selectedVM for action in self.actionVMList: - action.listItemVM = selectedVM + action.actionTarget = selectedVM def _makeActionsList(self): selectedListItem: Optional[AddonListItemVM] = self.listVM.getSelection() @@ -120,7 +120,7 @@ def _makeActionsList(self): displayName=pgettext("addonStore", "&Install"), actionHandler=self.getAddon, validCheck=lambda aVM: aVM.status == AvailableAddonStatus.AVAILABLE, - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that installs the selected addon @@ -130,14 +130,14 @@ def _makeActionsList(self): aVM.status == AvailableAddonStatus.INCOMPATIBLE and aVM.model.canOverrideCompatibility ), - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that updates the selected addon displayName=pgettext("addonStore", "&Update"), actionHandler=self.getAddon, validCheck=lambda aVM: aVM.status == AvailableAddonStatus.UPDATE, - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that replaces the selected addon with @@ -145,7 +145,7 @@ def _makeActionsList(self): displayName=pgettext("addonStore", "Re&place"), actionHandler=self.replaceAddon, validCheck=lambda aVM: aVM.status == AvailableAddonStatus.REPLACE_SIDE_LOAD, - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that disables the selected addon @@ -158,7 +158,7 @@ def _makeActionsList(self): AvailableAddonStatus.PENDING_INCOMPATIBLE_DISABLED, AvailableAddonStatus.PENDING_REMOVE, ), - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that enables the selected addon @@ -168,7 +168,7 @@ def _makeActionsList(self): aVM.status == AvailableAddonStatus.DISABLED or aVM.status == AvailableAddonStatus.PENDING_DISABLE ), - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that enables the selected addon @@ -181,7 +181,7 @@ def _makeActionsList(self): ) and aVM.model.canOverrideCompatibility ), - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that removes the selected addon @@ -197,7 +197,7 @@ def _makeActionsList(self): _StatusFilterKey.INCOMPATIBLE, ) ), - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that opens help for the selected addon @@ -214,14 +214,14 @@ def _makeActionsList(self): and aVM.model._addonHandlerModel is not None and aVM.model._addonHandlerModel.getDocFilePath() is not None ), - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that opens the homepage for the selected addon displayName=pgettext("addonStore", "Ho&mepage"), actionHandler=lambda aVM: startfile(aVM.model.homepage), validCheck=lambda aVM: aVM.model.homepage is not None, - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that opens the license for the selected addon @@ -236,14 +236,14 @@ def _makeActionsList(self): isinstance(aVM.model, _AddonStoreModel) and aVM.model.licenseURL is not None ), - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that opens the source code for the selected addon displayName=pgettext("addonStore", "Source &Code"), actionHandler=lambda aVM: startfile(cast(_AddonStoreModel, aVM.model).sourceURL), validCheck=lambda aVM: isinstance(aVM.model, _AddonStoreModel), - listItemVM=selectedListItem + actionTarget=selectedListItem ), ] diff --git a/tests/manual/addonStore.md b/tests/manual/addonStore.md index a282caee65d..17ea1db4228 100644 --- a/tests/manual/addonStore.md +++ b/tests/manual/addonStore.md @@ -45,11 +45,21 @@ Add-ons can be filtered by display name, publisher and description. ### Install add-on from add-on store 1. Open the add-on store. +1. Navigate to the available add-ons tab. 1. Select an add-on. -1. Navigate to and press the install button for the add-on. +1. Using the context menu, install the add-on. 1. Exit the dialog 1. Restart NVDA as prompted. -1. Confirm the add-on is listed in the add-ons store. +1. Confirm the add-ons are listed in the installed add-ons tab of the add-ons store. + +### Batch install add-ons from add-on store +1. Open the add-on store. +1. Navigate to the available add-ons tab. +1. Select multiple add-ons using `shift` and `ctrl`. +1. Using the context menu, install the add-ons. +1. Exit the dialog +1. Restart NVDA as prompted. +1. Confirm the add-ons are listed in the installed add-ons tab of the add-ons store. ### Install add-on from external source in add-on store 1. Open the add-on store. diff --git a/user_docs/en/userGuide.t2t b/user_docs/en/userGuide.t2t index 7d99fa34ac4..49d0b39d243 100644 --- a/user_docs/en/userGuide.t2t +++ b/user_docs/en/userGuide.t2t @@ -2740,6 +2740,9 @@ You can install and update add-ons by [browsing Available add-ons #AddonStoreBro Select an add-on from the "Available add-ons" or "Updatable add-ons" tab. Then use the update, install, or replace action to start the installation. +You can also install multiple add-ons at once. +This can be done by selecting multiple add-ons in the available add-ons tab, then activating the context menu on the selection and choosing the install action. + To install an add-on you have obtained outside of the Add-on Store, press the "Install from external source" button. This will allow you to browse for an add-on package (``.nvda-addon`` file) somewhere on your computer or on a network. Once you open the add-on package, the installation process will begin. From 79915081a8cfaab47e8ea70765fbf8688ebbbef0 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Wed, 6 Sep 2023 11:05:39 +1000 Subject: [PATCH 16/22] Fix background downloading of add-ons --- source/_addonStore/dataManager.py | 9 +-- source/_addonStore/models/addon.py | 14 ++++- source/_addonStore/models/status.py | 11 ++-- source/_addonStore/network.py | 63 ++++++++++++------- .../_addonStoreGui/viewModels/addonList.py | 13 ++-- source/gui/_addonStoreGui/viewModels/store.py | 39 ++++++++---- 6 files changed, 103 insertions(+), 46 deletions(-) diff --git a/source/_addonStore/dataManager.py b/source/_addonStore/dataManager.py index 7fa5a35f29b..99530859871 100644 --- a/source/_addonStore/dataManager.py +++ b/source/_addonStore/dataManager.py @@ -49,7 +49,7 @@ if TYPE_CHECKING: from addonHandler import Addon as AddonHandlerModel # noqa: F401 # AddonGUICollectionT must only be imported when TYPE_CHECKING - from .models.addon import AddonGUICollectionT # noqa: F401 + from .models.addon import AddonGUICollectionT, _AddonStoreModel # noqa: F401 from gui._addonStoreGui.viewModels.addonList import AddonListItemVM # noqa: F401 from gui.message import DisplayableError # noqa: F401 @@ -69,7 +69,8 @@ def initialize(): class _DataManager: _cacheLatestFilename: str = "_cachedLatestAddons.json" _cacheCompatibleFilename: str = "_cachedCompatibleAddons.json" - _downloadsPendingInstall: Set[Tuple["AddonListItemVM", os.PathLike]] = set() + _downloadsPendingInstall: Set[Tuple["AddonListItemVM[_AddonStoreModel]", os.PathLike]] = set() + _downloadsPendingCompletion: Set["AddonListItemVM[_AddonStoreModel]"] = set() def __init__(self): self._lang = languageHandler.getLanguage() @@ -123,7 +124,7 @@ def _getCacheHash(self) -> Optional[str]: cacheHash = response.json() return cacheHash - def _cacheCompatibleAddons(self, addonData: str, cacheHash: str): + def _cacheCompatibleAddons(self, addonData: str, cacheHash: Optional[str]): if not NVDAState.shouldWriteToDisk(): return if not addonData or not cacheHash: @@ -137,7 +138,7 @@ def _cacheCompatibleAddons(self, addonData: str, cacheHash: str): with open(self._cacheCompatibleFile, 'w', encoding='utf-8') as cacheFile: json.dump(cacheData, cacheFile, ensure_ascii=False) - def _cacheLatestAddons(self, addonData: str, cacheHash: str): + def _cacheLatestAddons(self, addonData: str, cacheHash: Optional[str]): if not NVDAState.shouldWriteToDisk(): return if not addonData or not cacheHash: diff --git a/source/_addonStore/models/addon.py b/source/_addonStore/models/addon.py index 329f68cc43f..2a00d374e7d 100644 --- a/source/_addonStore/models/addon.py +++ b/source/_addonStore/models/addon.py @@ -98,6 +98,7 @@ def listItemVMId(self) -> str: return f"{self.addonId}-{self.channel}" def asdict(self) -> Dict[str, Any]: + assert dataclasses.is_dataclass(self) jsonData = dataclasses.asdict(self) for field in jsonData: # dataclasses.asdict parses NamedTuples to JSON arrays, @@ -110,6 +111,15 @@ def asdict(self) -> Dict[str, Any]: class _AddonStoreModel(_AddonGUIModel): + addonId: str + displayName: str + description: str + addonVersionName: str + channel: Channel + homepage: Optional[str] + minNVDAVersion: MajorMinorPatch + lastTestedVersion: MajorMinorPatch + legacy: bool publisher: str license: str licenseURL: Optional[str] @@ -145,6 +155,7 @@ def cachedDownloadPath(self) -> str: def isPendingInstall(self) -> bool: """True if this addon has not yet been fully installed.""" from ..dataManager import addonDataManager + assert addonDataManager nameInDownloadsPendingInstall = filter( lambda m: m[0].model.name == self.name, # add-ons which have been downloaded but @@ -240,6 +251,7 @@ class InstalledAddonStoreModel(_AddonManifestModel, _AddonStoreModel): @property def manifest(self) -> "AddonManifest": from ..dataManager import addonDataManager + assert addonDataManager return addonDataManager._installedAddonsCache.installedAddons[self.name].manifest @@ -273,7 +285,7 @@ class AddonStoreModel(_AddonStoreModel): @dataclasses.dataclass class CachedAddonsModel: cachedAddonData: "AddonGUICollectionT" - cacheHash: str + cacheHash: Optional[str] cachedLanguage: str # AddonApiVersionT or the string .network._LATEST_API_VER nvdaAPIVersion: Union[addonAPIVersion.AddonApiVersionT, str] diff --git a/source/_addonStore/models/status.py b/source/_addonStore/models/status.py index b7a253e5f44..8b7df756d9b 100644 --- a/source/_addonStore/models/status.py +++ b/source/_addonStore/models/status.py @@ -8,7 +8,6 @@ from pathlib import Path from typing import ( Dict, - Optional, OrderedDict, Set, TYPE_CHECKING, @@ -51,6 +50,7 @@ class AvailableAddonStatus(DisplayStringEnum): """ Values to represent the status of add-ons within the NVDA add-on store. Although related, these are independent of the states in L{addonHandler} """ + UNKNOWN = enum.auto() PENDING_REMOVE = enum.auto() AVAILABLE = enum.auto() UPDATE = enum.auto() @@ -120,6 +120,8 @@ def _displayStringLabels(self) -> Dict["AvailableAddonStatus", str]: self.ENABLED: pgettext("addonStore", "Enabled"), # Translators: Status for addons shown in the add-on store dialog self.RUNNING: pgettext("addonStore", "Enabled"), + # Translators: Status for addons shown in the add-on store dialog + self.UNKNOWN: pgettext("addonStore", "Unknown status"), } @@ -144,7 +146,7 @@ class AddonStateCategory(str, enum.Enum): """Add-ons that are blocked from running because they are incompatible""" -def getStatus(model: "_AddonGUIModel") -> Optional[AvailableAddonStatus]: +def getStatus(model: "_AddonGUIModel") -> AvailableAddonStatus: from addonHandler import ( state as addonHandlerState, ) @@ -213,8 +215,8 @@ def getStatus(model: "_AddonGUIModel") -> Optional[AvailableAddonStatus]: if addonHandlerModel.isEnabled: return AvailableAddonStatus.ENABLED - log.debugWarning(f"Add-on in unknown state: {model.addonId}") - return None + log.error(f"Add-on in unknown state: {model.addonId}") + return AvailableAddonStatus.UNKNOWN _addonStoreStateToAddonHandlerState: OrderedDict[ @@ -341,6 +343,7 @@ def displayStringWithAccelerator(self) -> str: AvailableAddonStatus.PENDING_INCOMPATIBLE_ENABLED, AvailableAddonStatus.INCOMPATIBLE_DISABLED, AvailableAddonStatus.INCOMPATIBLE_ENABLED, + AvailableAddonStatus.UNKNOWN, }, }) """A dictionary where the keys are a status to filter by, diff --git a/source/_addonStore/network.py b/source/_addonStore/network.py index 013e6f2980a..2920e0ce863 100644 --- a/source/_addonStore/network.py +++ b/source/_addonStore/network.py @@ -3,6 +3,10 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. +# Needed for type hinting Future +# Can be removed in a future version of python (3.8+) +from __future__ import annotations + from concurrent.futures import ( Future, ThreadPoolExecutor, @@ -28,7 +32,6 @@ from utils.security import sha256_checksum from .models.addon import ( - AddonStoreModel, _AddonGUIModel, _AddonStoreModel, ) @@ -37,6 +40,7 @@ if TYPE_CHECKING: from gui.message import DisplayableError + from gui._addonStoreGui.viewModels.addonList import AddonListItemVM _BASE_URL = "https://nvaccess.org/addonStore" @@ -61,21 +65,28 @@ def _getCacheHashURL() -> str: class AddonFileDownloader: - OnCompleteT = Callable[[AddonStoreModel, Optional[os.PathLike]], None] + OnCompleteT = Callable[ + ["AddonListItemVM[_AddonStoreModel]", Optional[os.PathLike]], + None + ] def __init__(self): - self.progress: Dict[AddonStoreModel, int] = {} # Number of chunks received + self.progress: Dict["AddonListItemVM[_AddonStoreModel]", int] = {} # Number of chunks received self._pending: Dict[ - Future, + Future[Optional[os.PathLike]], Tuple[ - AddonStoreModel, + "AddonListItemVM[_AddonStoreModel]", AddonFileDownloader.OnCompleteT, "DisplayableError.OnDisplayableErrorT" ] ] = {} - self.complete: Dict[AddonStoreModel, os.PathLike] = {} # Path to downloaded file + self.complete: Dict[ + "AddonListItemVM[_AddonStoreModel]", + # Path to downloaded file + Optional[os.PathLike] + ] = {} self._executor = ThreadPoolExecutor( - max_workers=1, + max_workers=10, thread_name_prefix="AddonDownloader", ) @@ -85,20 +96,21 @@ def __init__(self): def download( self, - addonData: AddonStoreModel, + addonData: "AddonListItemVM[_AddonStoreModel]", onComplete: OnCompleteT, onDisplayableError: "DisplayableError.OnDisplayableErrorT", ): self.progress[addonData] = 0 - f: Future = self._executor.submit( + assert self._executor + f: Future[Optional[os.PathLike]] = self._executor.submit( self._download, addonData, ) self._pending[f] = addonData, onComplete, onDisplayableError f.add_done_callback(self._done) - def _done(self, downloadAddonFuture: Future): + def _done(self, downloadAddonFuture: Future[Optional[os.PathLike]]): isCancelled = downloadAddonFuture not in self._pending - addonId = "CANCELLED" if isCancelled else self._pending[downloadAddonFuture][0].addonId + addonId = "CANCELLED" if isCancelled else self._pending[downloadAddonFuture][0].model.addonId log.debug(f"Done called for {addonId}") if isCancelled: log.debug("Download was cancelled, not calling onComplete") @@ -108,6 +120,7 @@ def _done(self, downloadAddonFuture: Future): return addonData, onComplete, onDisplayableError = self._pending[downloadAddonFuture] downloadAddonFutureException = downloadAddonFuture.exception() + cacheFilePath: Optional[os.PathLike] if downloadAddonFutureException: cacheFilePath = None from gui.message import DisplayableError @@ -120,10 +133,12 @@ def _done(self, downloadAddonFuture: Future): displayableError=downloadAddonFutureException ) else: - cacheFilePath: Optional[os.PathLike] = downloadAddonFuture.result() + cacheFilePath = downloadAddonFuture.result() - del self._pending[downloadAddonFuture] - del self.progress[addonData] + # If canceled after our previous isCancelled check, + # then _pending and progress will be empty. + self._pending.pop(downloadAddonFuture, None) + self.progress.pop(addonData, None) self.complete[addonData] = cacheFilePath onComplete(addonData, cacheFilePath) @@ -131,12 +146,17 @@ def cancelAll(self): log.debug("Cancelling all") for f in self._pending.keys(): f.cancel() + assert self._executor self._executor.shutdown(wait=False) self._executor = None self.progress.clear() self._pending.clear() - def _downloadAddonToPath(self, addonData: AddonStoreModel, downloadFilePath: str) -> bool: + def _downloadAddonToPath( + self, + addonData: "AddonListItemVM[_AddonStoreModel]", + downloadFilePath: str + ) -> bool: """ @return: True if the add-on is downloaded successfully, False if the download is cancelled @@ -144,7 +164,7 @@ def _downloadAddonToPath(self, addonData: AddonStoreModel, downloadFilePath: str if not NVDAState.shouldWriteToDisk(): return False - with requests.get(addonData.URL, stream=True) as r: + with requests.get(addonData.model.URL, stream=True) as r: with open(downloadFilePath, 'wb') as fd: # Most add-ons are small. This value was chosen quite arbitrarily, but with the intention to allow # interrupting the download. This is particularly important on a slow connection, to provide @@ -159,15 +179,16 @@ def _downloadAddonToPath(self, addonData: AddonStoreModel, downloadFilePath: str if addonData in self.progress: # Removed when the download should be cancelled. self.progress[addonData] += 1 else: - log.debug(f"Cancelled download: {addonData.addonId}") + log.debug(f"Cancelled download: {addonData.model.addonId}") return False # The download was cancelled return True - def _download(self, addonData: AddonStoreModel) -> Optional[os.PathLike]: + def _download(self, listItem: "AddonListItemVM[_AddonStoreModel]") -> Optional[os.PathLike]: from gui.message import DisplayableError # Translators: A title for a dialog notifying a user of an add-on download failure. _addonDownloadFailureMessageTitle = pgettext("addonStore", "Add-on download failure") + addonData = listItem.model log.debug(f"starting download: {addonData.addonId}") cacheFilePath = addonData.cachedDownloadPath if os.path.exists(cacheFilePath): @@ -175,11 +196,11 @@ def _download(self, addonData: AddonStoreModel) -> Optional[os.PathLike]: os.remove(cacheFilePath) inProgressFilePath = addonData.tempDownloadPath - if addonData not in self.progress: + if listItem not in self.progress: log.debug("the download was cancelled before it started.") return None # The download was cancelled try: - if not self._downloadAddonToPath(addonData, inProgressFilePath): + if not self._downloadAddonToPath(listItem, inProgressFilePath): return None # The download was cancelled except requests.exceptions.RequestException as e: log.debugWarning(f"Unable to download addon file: {e}") @@ -218,7 +239,7 @@ def _download(self, addonData: AddonStoreModel) -> Optional[os.PathLike]: return cast(os.PathLike, cacheFilePath) @staticmethod - def _checkChecksum(addonFilePath: str, addonData: _AddonStoreModel) -> Optional[os.PathLike]: + def _checkChecksum(addonFilePath: str, addonData: _AddonStoreModel) -> bool: with open(addonFilePath, "rb") as f: sha256Addon = sha256_checksum(f) return sha256Addon.casefold() == addonData.sha256.casefold() diff --git a/source/gui/_addonStoreGui/viewModels/addonList.py b/source/gui/_addonStoreGui/viewModels/addonList.py index 2ee5c12dc64..a962d8a2b27 100644 --- a/source/gui/_addonStoreGui/viewModels/addonList.py +++ b/source/gui/_addonStoreGui/viewModels/addonList.py @@ -12,9 +12,11 @@ from locale import strxfrm from typing import ( FrozenSet, + Generic, List, Optional, TYPE_CHECKING, + TypeVar, ) from requests.structures import CaseInsensitiveDict @@ -92,18 +94,21 @@ class AddonListField(_AddonListFieldData, Enum): ) -class AddonListItemVM: +_AddonModelT = TypeVar("_AddonModelT", bound=_AddonGUIModel) + + +class AddonListItemVM(Generic[_AddonModelT]): def __init__( self, - model: _AddonGUIModel, + model: _AddonModelT, status: AvailableAddonStatus = AvailableAddonStatus.AVAILABLE ): - self._model: _AddonGUIModel = model # read-only + self._model: _AddonModelT = model # read-only self._status: AvailableAddonStatus = status # modifications triggers L{updated.notify} self.updated = extensionPoints.Action() # Notify of changes to VM, argument: addonListItemVM @property - def model(self) -> _AddonGUIModel: + def model(self) -> _AddonModelT: return self._model @property diff --git a/source/gui/_addonStoreGui/viewModels/store.py b/source/gui/_addonStoreGui/viewModels/store.py index bf2a9976e27..dc83a07af7f 100644 --- a/source/gui/_addonStoreGui/viewModels/store.py +++ b/source/gui/_addonStoreGui/viewModels/store.py @@ -13,6 +13,7 @@ ) import os from typing import ( + Iterable, List, Optional, cast, @@ -62,6 +63,7 @@ class AddonStoreVM: def __init__(self): + assert addonDataManager self._installedAddons = addonDataManager._installedAddonsCache.installedAddonGUICollection self._availableAddons = _createAddonGUICollection() self.hasError = extensionPoints.Action() @@ -224,7 +226,12 @@ def _makeActionsList(self): AddonActionVM( # Translators: Label for an action that opens the license for the selected addon displayName=pgettext("addonStore", "&License"), - actionHandler=lambda aVM: startfile(cast(_AddonStoreModel, aVM.model).licenseURL), + actionHandler=lambda aVM: startfile( + cast( + str, + cast(_AddonStoreModel, aVM.model).licenseURL + ) + ), validCheck=lambda aVM: ( isinstance(aVM.model, _AddonStoreModel) and aVM.model.licenseURL is not None @@ -246,9 +253,12 @@ def helpAddon(self, listItemVM: AddonListItemVM) -> None: assert path is not None startfile(path) - def removeAddon(self, listItemVM: AddonListItemVM) -> None: + def removeAddon(self, listItemVM: AddonListItemVM[_AddonGUIModel]) -> None: + assert addonDataManager + assert listItemVM.model if _shouldProceedToRemoveAddonDialog(listItemVM.model): addonDataManager._deleteCacheInstalledAddon(listItemVM.model.name) + assert listItemVM.model._addonHandlerModel is not None listItemVM.model._addonHandlerModel.requestRemove() self.refresh() listItemVM.status = getStatus(listItemVM.model) @@ -278,11 +288,11 @@ def _handleEnableDisable(self, listItemVM: AddonListItemVM, shouldEnable: bool) try: listItemVM.model._addonHandlerModel.enable(shouldEnable) except addonHandler.AddonError: - log.debug(exc_info=True) if shouldEnable: errorMessage = self._enableErrorMessage else: errorMessage = self._disableErrorMessage + log.debug(errorMessage, exc_info=True) displayableError = DisplayableError( displayMessage=errorMessage.format(addon=listItemVM.model.displayName) ) @@ -309,16 +319,19 @@ def replaceAddon(self, listItemVM: AddonListItemVM) -> None: if _shouldProceedWhenInstalledAddonVersionUnknown(mainFrame, listItemVM.model): self.getAddon(listItemVM) - def getAddon(self, listItemVM: AddonListItemVM) -> None: + def getAddon(self, listItemVM: AddonListItemVM[_AddonStoreModel]) -> None: + assert addonDataManager + addonDataManager._downloadsPendingCompletion.add(listItemVM) listItemVM.status = AvailableAddonStatus.DOWNLOADING log.debug(f"{listItemVM.Id} status: {listItemVM.status}") - self._downloader.download(listItemVM.model, self._downloadComplete, self.onDisplayableError) + self._downloader.download(listItemVM, self._downloadComplete, self.onDisplayableError) - def _downloadComplete(self, addonDetails: AddonStoreModel, fileDownloaded: Optional[PathLike]): - listItemVM: Optional[AddonListItemVM] = self.listVM._addons[addonDetails.listItemVMId] - if listItemVM is None: - log.error(f"No list item VM for addon with id: {addonDetails.addonId}") - return + def _downloadComplete( + self, + listItemVM: AddonListItemVM[_AddonStoreModel], + fileDownloaded: Optional[PathLike] + ): + addonDataManager._downloadsPendingCompletion.remove(listItemVM) if fileDownloaded is None: # Download may have been cancelled or otherwise failed @@ -329,6 +342,7 @@ def _downloadComplete(self, addonDetails: AddonStoreModel, fileDownloaded: Optio listItemVM.status = AvailableAddonStatus.DOWNLOAD_SUCCESS log.debug(f"Queuing add-on for install on dialog exit: {listItemVM.Id}") # Add-ons can have "installTasks", which often call the GUI assuming they are on the main thread. + assert addonDataManager addonDataManager._downloadsPendingInstall.add((listItemVM, fileDownloaded)) def installPending(self): @@ -406,8 +420,9 @@ def _getAvailableAddonsInBG(self): log.debug("completed refresh") def cancelDownloads(self): - for a in self._downloader.progress.keys(): - self.listVM._addons[a.listItemVMId].status = AvailableAddonStatus.AVAILABLE + while addonDataManager._downloadsPendingCompletion: + listItem = addonDataManager._downloadsPendingCompletion.pop() + listItem.status = AvailableAddonStatus.AVAILABLE self._downloader.cancelAll() def _filterByEnabledKey(self, model: _AddonGUIModel) -> bool: From 1cae28b22bd19679599b1a58f591836acfef5be9 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Wed, 6 Sep 2023 11:13:14 +1000 Subject: [PATCH 17/22] Allow bulk install --- source/gui/_addonStoreGui/controls/actions.py | 97 +++++++++-- .../gui/_addonStoreGui/controls/addonList.py | 53 ++++-- source/gui/_addonStoreGui/controls/details.py | 4 +- .../_addonStoreGui/controls/storeDialog.py | 4 +- .../gui/_addonStoreGui/viewModels/action.py | 154 ++++++++++++++---- .../_addonStoreGui/viewModels/addonList.py | 18 +- source/gui/_addonStoreGui/viewModels/store.py | 71 ++++---- tests/manual/addonStore.md | 14 +- user_docs/en/userGuide.t2t | 3 + 9 files changed, 308 insertions(+), 110 deletions(-) diff --git a/source/gui/_addonStoreGui/controls/actions.py b/source/gui/_addonStoreGui/controls/actions.py index 354bc64407f..b6991dfa90c 100644 --- a/source/gui/_addonStoreGui/controls/actions.py +++ b/source/gui/_addonStoreGui/controls/actions.py @@ -3,25 +3,38 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. +from abc import ABC, abstractmethod import functools from typing import ( Dict, + Generic, + Iterable, List, + TypeVar, ) import wx +from _addonStore.models.status import _StatusFilterKey from logHandler import log +import ui -from ..viewModels.action import AddonActionVM +from ..viewModels.action import AddonActionVM, BatchAddonActionVM +from ..viewModels.addonList import AddonListItemVM from ..viewModels.store import AddonStoreVM -class _ActionsContextMenu: - def __init__(self, storeVM: AddonStoreVM): - self._storeVM = storeVM - self._actionMenuItemMap: Dict[AddonActionVM, wx.MenuItem] = {} - self._contextMenu = wx.Menu() +AddonActionT = TypeVar("AddonActionT", AddonActionVM, BatchAddonActionVM) + + +class _ActionsContextMenuP(Generic[AddonActionT], ABC): + _actions: List[AddonActionT] + _actionMenuItemMap: Dict[AddonActionT, wx.MenuItem] + _contextMenu: wx.Menu + + @abstractmethod + def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: AddonActionT): + ... def popupContextMenuFromPosition( self, @@ -31,14 +44,9 @@ def popupContextMenuFromPosition( self._populateContextMenu() targetWindow.PopupMenu(self._contextMenu, pos=position) - def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: AddonActionVM): - selectedAddon = actionVM.listItemVM - log.debug(f"action selected: actionVM: {actionVM}, selectedAddon: {selectedAddon}") - actionVM.actionHandler(selectedAddon) - def _populateContextMenu(self): prevActionIndex = -1 - for action in self._storeVM.actionVMList: + for action in self._actions: menuItem = self._actionMenuItemMap.get(action) menuItems: List[wx.MenuItem] = list(self._contextMenu.GetMenuItems()) isMenuItemInContextMenu = menuItem is not None and menuItem in menuItems @@ -71,3 +79,68 @@ def _populateContextMenu(self): # Remove the menu item from the context menu. self._contextMenu.RemoveItem(menuItem) del self._actionMenuItemMap[action] + + menuItems: List[wx.MenuItem] = list(self._contextMenu.GetMenuItems()) + for menuItem in menuItems: + if menuItem not in self._actionMenuItemMap.values(): + # The menu item is not in the action menu item map. + # It should be removed from the context menu. + self._contextMenu.RemoveItem(menuItem) + + +class _MonoActionsContextMenu(_ActionsContextMenuP[AddonActionVM]): + """Context menu for actions for a single add-on""" + def __init__(self, storeVM: AddonStoreVM): + self._storeVM = storeVM + self._actionMenuItemMap = {} + self._contextMenu = wx.Menu() + + def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: AddonActionVM): + selectedAddon = actionVM.actionTarget + log.debug(f"action selected: actionVM: {actionVM.displayName}, selectedAddon: {selectedAddon}") + actionVM.actionHandler(selectedAddon) + + @property + def _actions(self) -> List[AddonActionVM]: + return self._storeVM.actionVMList + + +class _BatchActionsContextMenu(_ActionsContextMenuP[BatchAddonActionVM]): + """Context menu for actions for a group of add-ons""" + def __init__(self, storeVM: AddonStoreVM): + self._storeVM = storeVM + self._actionMenuItemMap = {} + self._contextMenu = wx.Menu() + self._selectedAddons: Iterable[AddonListItemVM] = tuple() + + def _updateSelectedAddons(self, selectedAddons: Iterable[AddonListItemVM]): + # Reset the action menu as self._actions depends on the selected add-ons + self._actionMenuItemMap = {} + self._selectedAddons = selectedAddons + + def popupContextMenuFromPosition( + self, + targetWindow: wx.Window, + position: wx.Position = wx.DefaultPosition + ): + super().popupContextMenuFromPosition(targetWindow, position) + if self._contextMenu.GetMenuItemCount() == 0: + # Translators: a message displayed when activating the context menu on multiple selected add-ons, + # but no actions are available for the add-ons. + ui.message(pgettext("addonStore", "No actions available for the selected add-ons")) + + def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: BatchAddonActionVM): + log.debug(f"Performing batch action for actionVM: {actionVM.displayName}") + actionVM.actionHandler(self._selectedAddons) + + @property + def _actions(self) -> List[BatchAddonActionVM]: + return [ + BatchAddonActionVM( + # Translators: Label for an action that installs the selected add-ons + displayName=pgettext("addonStore", "&Install selected add-ons"), + actionHandler=self._storeVM.getAddons, + validCheck=lambda aVMs: self._storeVM._filteredStatusKey == _StatusFilterKey.AVAILABLE, + actionTarget=self._selectedAddons + ), + ] diff --git a/source/gui/_addonStoreGui/controls/addonList.py b/source/gui/_addonStoreGui/controls/addonList.py index 09375d5e357..107464d32ba 100644 --- a/source/gui/_addonStoreGui/controls/addonList.py +++ b/source/gui/_addonStoreGui/controls/addonList.py @@ -4,6 +4,7 @@ # See the file COPYING for more details. from typing import ( + List, Optional, ) @@ -16,8 +17,12 @@ from gui.dpiScalingHelper import DpiScalingHelperMixinWithoutInit from logHandler import log -from .actions import _ActionsContextMenu -from ..viewModels.addonList import AddonListVM +from .actions import ( + _ActionsContextMenuP, + _BatchActionsContextMenu, + _MonoActionsContextMenu, +) +from ..viewModels.addonList import AddonListItemVM, AddonListVM class AddonVirtualList( @@ -28,14 +33,13 @@ def __init__( self, parent: wx.Window, addonsListVM: AddonListVM, - actionsContextMenu: _ActionsContextMenu, + actionsContextMenu: _MonoActionsContextMenu, ): super().__init__( parent, style=( wx.LC_REPORT # Single or multicolumn report view, with optional header. | wx.LC_VIRTUAL # The application provides items text on demand. May only be used with LC_REPORT. - | wx.LC_SINGLE_SEL # Single selection (default is multiple). | wx.LC_HRULES # Draws light horizontal rules between rows in report mode. | wx.LC_VRULES # Draws light vertical rules between columns in report mode. ), @@ -43,6 +47,7 @@ def __init__( ) self._addonsListVM = addonsListVM self._actionsContextMenu = actionsContextMenu + self._batchActionsContextMenu = _BatchActionsContextMenu(self._addonsListVM._storeVM) self.SetMinSize(self.scaleSize((500, 500))) @@ -75,6 +80,25 @@ def _getListSelectionPosition(self) -> Optional[wx.Position]: itemRect: wx.Rect = self.GetItemRect(firstSelectedIndex) return itemRect.GetBottomLeft() + def _updateBatchContextMenuSelection(self): + numSelected = self.GetSelectedItemCount() + prevSelectedIndex = self.GetFirstSelected() + selectedAddons: List[AddonListItemVM] = [] + for _ in range(numSelected): + addon = self._addonsListVM.getAddonAtIndex(prevSelectedIndex) + selectedAddons.append(addon) + prevSelectedIndex = self.GetNextSelected(prevSelectedIndex) + + self._batchActionsContextMenu._updateSelectedAddons(selectedAddons) + + @property + def _contextMenu(self) -> _ActionsContextMenuP: + numSelected = self.GetSelectedItemCount() + if numSelected > 1: + self._updateBatchContextMenuSelection() + return self._batchActionsContextMenu + return self._actionsContextMenu + def _popupContextMenuFromList(self, evt: wx.ContextMenuEvent): listSelectionPosition = self._getListSelectionPosition() if listSelectionPosition is None: @@ -83,29 +107,38 @@ def _popupContextMenuFromList(self, evt: wx.ContextMenuEvent): if eventPosition == wx.DefaultPosition: # keyboard triggered context menu (due to "applications" key) # don't have position set. It must be fetched from the selected item. - self._actionsContextMenu.popupContextMenuFromPosition(self, listSelectionPosition) + self._contextMenu.popupContextMenuFromPosition(self, listSelectionPosition) else: # Mouse (right click) triggered context menu. # In this case the menu is positioned better with GetPopupMenuSelectionFromUser. - self._actionsContextMenu.popupContextMenuFromPosition(self) + self._contextMenu.popupContextMenuFromPosition(self) def _itemDataUpdated(self, index: int): log.debug(f"index: {index}") self.RefreshItem(index) def OnItemSelected(self, evt: wx.ListEvent): - newIndex = evt.GetIndex() + newIndex = self.GetFirstSelected() log.debug(f"item selected: {newIndex}") self._addonsListVM.setSelection(index=newIndex) def OnItemActivated(self, evt: wx.ListEvent): position = self._getListSelectionPosition() - self._actionsContextMenu.popupContextMenuFromPosition(self, position) + self._contextMenu.popupContextMenuFromPosition(self, position) log.debug(f"item activated: {evt.GetIndex()}") def OnItemDeselected(self, evt: wx.ListEvent): - log.debug(f"item deselected") - self._addonsListVM.setSelection(None) + # Call this later, as the list control doesn't update its selection until after this event, + # and we need an accurate selection count. + wx.CallAfter(self._OnItemDeselected, evt) + + def _OnItemDeselected(self, evt: wx.ListEvent): + if self.GetSelectedItemCount() == 0: + log.debug("item deselected") + self._addonsListVM.setSelection(None) + else: + log.debug("updating selection due to item deselected") + self.OnItemSelected(evt) def OnGetItemText(self, itemIndex: int, colIndex: int) -> str: dataItem = self._addonsListVM.getAddonFieldText( diff --git a/source/gui/_addonStoreGui/controls/details.py b/source/gui/_addonStoreGui/controls/details.py index 182a0141a28..22974126942 100644 --- a/source/gui/_addonStoreGui/controls/details.py +++ b/source/gui/_addonStoreGui/controls/details.py @@ -15,7 +15,7 @@ from ..viewModels.addonList import AddonDetailsVM, AddonListField -from .actions import _ActionsContextMenu +from .actions import _MonoActionsContextMenu _fontFaceName = "Segoe UI" _fontFaceName_semiBold = "Segoe UI Semibold" @@ -49,7 +49,7 @@ def __init__( self, parent: wx.Window, detailsVM: AddonDetailsVM, - actionsContextMenu: _ActionsContextMenu, + actionsContextMenu: _MonoActionsContextMenu, ): self._detailsVM: AddonDetailsVM = detailsVM self._actionsContextMenu = actionsContextMenu diff --git a/source/gui/_addonStoreGui/controls/storeDialog.py b/source/gui/_addonStoreGui/controls/storeDialog.py index 5e527163779..7f2b8a90b96 100644 --- a/source/gui/_addonStoreGui/controls/storeDialog.py +++ b/source/gui/_addonStoreGui/controls/storeDialog.py @@ -33,7 +33,7 @@ from logHandler import log from ..viewModels.store import AddonStoreVM -from .actions import _ActionsContextMenu +from .actions import _MonoActionsContextMenu from .addonList import AddonVirtualList from .details import AddonDetails from .messageDialogs import _SafetyWarningDialog @@ -50,7 +50,7 @@ class AddonStoreDialog(SettingsDialog): def __init__(self, parent: wx.Window, storeVM: AddonStoreVM): self._storeVM = storeVM self._storeVM.onDisplayableError.register(self.handleDisplayableError) - self._actionsContextMenu = _ActionsContextMenu(self._storeVM) + self._actionsContextMenu = _MonoActionsContextMenu(self._storeVM) super().__init__(parent, resizeable=True, buttons={wx.CLOSE}) if config.conf["addonStore"]["showWarning"]: displayDialogAsModal(_SafetyWarningDialog(parent)) diff --git a/source/gui/_addonStoreGui/viewModels/action.py b/source/gui/_addonStoreGui/viewModels/action.py index 9ddddc55cde..2a9189a0bf4 100644 --- a/source/gui/_addonStoreGui/viewModels/action.py +++ b/source/gui/_addonStoreGui/viewModels/action.py @@ -3,19 +3,66 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. +from abc import ABC, abstractmethod from typing import ( Callable, + Generic, + Iterable, Optional, + TypeVar, TYPE_CHECKING, ) import extensionPoints +from logHandler import log if TYPE_CHECKING: from .addonList import AddonListItemVM -class AddonActionVM: +ActionTargetT = TypeVar("ActionTargetT", Optional["AddonListItemVM"], Iterable["AddonListItemVM"]) + + +class _AddonAction(Generic[ActionTargetT], ABC): + def __init__( + self, + displayName: str, + actionHandler: Callable[[ActionTargetT, ], None], + validCheck: Callable[[ActionTargetT, ], bool], + actionTarget: ActionTargetT, + ): + """ + @param displayName: Translated string, to be displayed to the user. Should describe the action / behaviour. + @param actionHandler: Call when the action is triggered. + @param validCheck: Is the action valid for the current target + @param actionTarget: The target this action will be applied to. L{updated} notifies of modification. + """ + self.displayName = displayName + self.actionHandler = actionHandler + self._validCheck = validCheck + self._actionTarget = actionTarget + self.updated = extensionPoints.Action() + """Notify of changes to the action""" + + @abstractmethod + def _listItemChanged(self, addonListItemVM: "AddonListItemVM"): + ... + + @property + def isValid(self) -> bool: + return self._validCheck(self._actionTarget) + + @property + def actionTarget(self) -> ActionTargetT: + return self._actionTarget + + def _notify(self): + # ensure calling on the main thread. + from core import callLater + callLater(delay=0, callable=self.updated.notify, addonActionVM=self) + + +class AddonActionVM(_AddonAction[Optional["AddonListItemVM"]]): """ Actions/behaviour that can be embedded within other views/viewModels that can apply to a single L{AddonListItemVM}. Use the L{AddonActionVM.updated} extensionPoint.Action to be notified about changes. @@ -29,51 +76,90 @@ def __init__( displayName: str, actionHandler: Callable[["AddonListItemVM", ], None], validCheck: Callable[["AddonListItemVM", ], bool], - listItemVM: Optional["AddonListItemVM"], + actionTarget: Optional["AddonListItemVM"], ): """ @param displayName: Translated string, to be displayed to the user. Should describe the action / behaviour. @param actionHandler: Call when the action is triggered. @param validCheck: Is the action valid for the current listItemVM - @param listItemVM: The listItemVM this action will be applied to. L{updated} notifies of modification. + @param actionTarget: The listItemVM this action will be applied to. L{updated} notifies of modification. """ - self.displayName: str = displayName - self.actionHandler: Callable[["AddonListItemVM", ], None] = actionHandler - self._validCheck: Callable[["AddonListItemVM", ], bool] = validCheck - self._listItemVM: Optional["AddonListItemVM"] = listItemVM - if listItemVM: - listItemVM.updated.register(self._listItemChanged) - self.updated = extensionPoints.Action() - """Notify of changes to the action""" + def _validCheck(listItemVM: Optional["AddonListItemVM"]) -> bool: + # Handle the None case so that each validCheck doesn't have to. + return listItemVM is not None and validCheck(listItemVM) - def _listItemChanged(self, addonListItemVM: "AddonListItemVM"): + def _actionHandler(listItemVM: Optional["AddonListItemVM"]): + # Handle the None case so that each actionHandler doesn't have to. + if listItemVM is not None: + actionHandler(listItemVM) + else: + log.warning(f"Action triggered for invalid None listItemVM: {self.displayName}") + + super().__init__(displayName, _actionHandler, _validCheck, actionTarget) + if actionTarget: + actionTarget.updated.register(self._listItemChanged) + + def _listItemChanged(self, addonListItemVM: Optional["AddonListItemVM"]): """Something inside the AddonListItemVM has changed""" - assert self._listItemVM == addonListItemVM + assert self._actionTarget == addonListItemVM self._notify() - def _notify(self): - # ensure calling on the main thread. - from core import callLater - callLater(delay=0, callable=self.updated.notify, addonActionVM=self) + @_AddonAction.actionTarget.setter + def actionTarget(self, newActionTarget: Optional["AddonListItemVM"]): + if self._actionTarget == newActionTarget: + return + if self._actionTarget: + self._actionTarget.updated.unregister(self._listItemChanged) + if newActionTarget: + newActionTarget.updated.register(self._listItemChanged) + self._actionTarget = newActionTarget + self._notify() - @property - def isValid(self) -> bool: - return ( - self._listItemVM is not None - and self._validCheck(self._listItemVM) - ) - @property - def listItemVM(self) -> Optional["AddonListItemVM"]: - return self._listItemVM +class BatchAddonActionVM(_AddonAction[Iterable["AddonListItemVM"]]): + """ + Actions/behaviour that can be embedded within other views/viewModels + that can apply to a group of L{AddonListItemVM}. + Use the L{BatchAddonActionVM.updated} extensionPoint.Action to be notified about changes. + E.G.: + - Updates within the AddonListItemVM (perhaps changing the action validity) + - Entirely changing the AddonListItemVM action will be applied to, the validity can be checked for the new + item. + """ + def __init__( + self, + displayName: str, + actionHandler: Callable[[Iterable["AddonListItemVM"], ], None], + validCheck: Callable[[Iterable["AddonListItemVM"], ], bool], + actionTarget: Iterable["AddonListItemVM"], + ): + """ + @param displayName: Translated string, to be displayed to the user. Should describe the action / behaviour. + @param actionHandler: Call when the action is triggered. + @param validCheck: Is the action valid for the current listItemVMs + @param actionTarget: The listItemVMs this action will be applied to. L{updated} notifies of modification. + """ + super().__init__(displayName, actionHandler, validCheck, actionTarget) + for listItemVM in self._actionTarget: + listItemVM.updated.register(self._listItemChanged) - @listItemVM.setter - def listItemVM(self, listItemVM: Optional["AddonListItemVM"]): - if self._listItemVM == listItemVM: + def _listItemChanged(self, addonListItemVM: "AddonListItemVM"): + """Something inside the AddonListItemVM has changed""" + assert addonListItemVM in self._actionTarget + self._notify() + + @_AddonAction.actionTarget.setter + def actionTarget(self, newActionTarget: Iterable["AddonListItemVM"]): + if self._actionTarget == newActionTarget: return - if self._listItemVM: - self._listItemVM.updated.unregister(self._listItemChanged) - if listItemVM: - listItemVM.updated.register(self._listItemChanged) - self._listItemVM = listItemVM + + for oldListItemVM in self._actionTarget: + if oldListItemVM not in newActionTarget: + oldListItemVM.updated.unregister(self._listItemChanged) + + for newListItemVM in newActionTarget: + if newListItemVM not in self._actionTarget: + newListItemVM.updated.register(self._listItemChanged) + + self._actionTarget = newActionTarget self._notify() diff --git a/source/gui/_addonStoreGui/viewModels/addonList.py b/source/gui/_addonStoreGui/viewModels/addonList.py index a962d8a2b27..5e285cfc5d0 100644 --- a/source/gui/_addonStoreGui/viewModels/addonList.py +++ b/source/gui/_addonStoreGui/viewModels/addonList.py @@ -12,11 +12,9 @@ from locale import strxfrm from typing import ( FrozenSet, - Generic, List, Optional, TYPE_CHECKING, - TypeVar, ) from requests.structures import CaseInsensitiveDict @@ -94,21 +92,18 @@ class AddonListField(_AddonListFieldData, Enum): ) -_AddonModelT = TypeVar("_AddonModelT", bound=_AddonGUIModel) - - -class AddonListItemVM(Generic[_AddonModelT]): +class AddonListItemVM: def __init__( self, - model: _AddonModelT, + model: _AddonGUIModel, status: AvailableAddonStatus = AvailableAddonStatus.AVAILABLE ): - self._model: _AddonModelT = model # read-only + self._model: _AddonGUIModel = model # read-only self._status: AvailableAddonStatus = status # modifications triggers L{updated.notify} self.updated = extensionPoints.Action() # Notify of changes to VM, argument: addonListItemVM @property - def model(self) -> _AddonModelT: + def model(self) -> _AddonGUIModel: return self._model @property @@ -258,6 +253,11 @@ def getSelectedIndex(self) -> Optional[int]: return self._addonsFilteredOrdered.index(self.selectedAddonId) return None + def getAddonAtIndex(self, index: int) -> AddonListItemVM: + self._validate(selectionIndex=index) + selectedAddonId = self._addonsFilteredOrdered[index] + return self._addons[selectedAddonId] + def setSelection(self, index: Optional[int]) -> Optional[AddonListItemVM]: self._validate(selectionIndex=index) self.selectedAddonId = None diff --git a/source/gui/_addonStoreGui/viewModels/store.py b/source/gui/_addonStoreGui/viewModels/store.py index dc83a07af7f..408f8b8a355 100644 --- a/source/gui/_addonStoreGui/viewModels/store.py +++ b/source/gui/_addonStoreGui/viewModels/store.py @@ -63,7 +63,6 @@ class AddonStoreVM: def __init__(self): - assert addonDataManager self._installedAddons = addonDataManager._installedAddonsCache.installedAddonGUICollection self._availableAddons = _createAddonGUICollection() self.hasError = extensionPoints.Action() @@ -110,7 +109,7 @@ def _onSelectedItemChanged(self): log.debug(f"Setting selection: {selectedVM}") self.detailsVM.listItem = selectedVM for action in self.actionVMList: - action.listItemVM = selectedVM + action.actionTarget = selectedVM def _makeActionsList(self): selectedListItem: Optional[AddonListItemVM] = self.listVM.getSelection() @@ -120,7 +119,7 @@ def _makeActionsList(self): displayName=pgettext("addonStore", "&Install"), actionHandler=self.getAddon, validCheck=lambda aVM: aVM.status == AvailableAddonStatus.AVAILABLE, - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that installs the selected addon @@ -130,14 +129,14 @@ def _makeActionsList(self): aVM.status == AvailableAddonStatus.INCOMPATIBLE and aVM.model.canOverrideCompatibility ), - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that updates the selected addon displayName=pgettext("addonStore", "&Update"), actionHandler=self.getAddon, validCheck=lambda aVM: aVM.status == AvailableAddonStatus.UPDATE, - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that replaces the selected addon with @@ -145,7 +144,7 @@ def _makeActionsList(self): displayName=pgettext("addonStore", "Re&place"), actionHandler=self.replaceAddon, validCheck=lambda aVM: aVM.status == AvailableAddonStatus.REPLACE_SIDE_LOAD, - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that disables the selected addon @@ -158,7 +157,7 @@ def _makeActionsList(self): AvailableAddonStatus.PENDING_INCOMPATIBLE_DISABLED, AvailableAddonStatus.PENDING_REMOVE, ), - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that enables the selected addon @@ -168,7 +167,7 @@ def _makeActionsList(self): aVM.status == AvailableAddonStatus.DISABLED or aVM.status == AvailableAddonStatus.PENDING_DISABLE ), - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that enables the selected addon @@ -181,7 +180,7 @@ def _makeActionsList(self): ) and aVM.model.canOverrideCompatibility ), - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that removes the selected addon @@ -197,7 +196,7 @@ def _makeActionsList(self): _StatusFilterKey.INCOMPATIBLE, ) ), - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that opens help for the selected addon @@ -214,36 +213,31 @@ def _makeActionsList(self): and aVM.model._addonHandlerModel is not None and aVM.model._addonHandlerModel.getDocFilePath() is not None ), - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that opens the homepage for the selected addon displayName=pgettext("addonStore", "Ho&mepage"), actionHandler=lambda aVM: startfile(aVM.model.homepage), validCheck=lambda aVM: aVM.model.homepage is not None, - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that opens the license for the selected addon displayName=pgettext("addonStore", "&License"), - actionHandler=lambda aVM: startfile( - cast( - str, - cast(_AddonStoreModel, aVM.model).licenseURL - ) - ), + actionHandler=lambda aVM: startfile(cast(_AddonStoreModel, aVM.model).licenseURL), validCheck=lambda aVM: ( isinstance(aVM.model, _AddonStoreModel) and aVM.model.licenseURL is not None ), - listItemVM=selectedListItem + actionTarget=selectedListItem ), AddonActionVM( # Translators: Label for an action that opens the source code for the selected addon displayName=pgettext("addonStore", "Source &Code"), actionHandler=lambda aVM: startfile(cast(_AddonStoreModel, aVM.model).sourceURL), validCheck=lambda aVM: isinstance(aVM.model, _AddonStoreModel), - listItemVM=selectedListItem + actionTarget=selectedListItem ), ] @@ -253,12 +247,9 @@ def helpAddon(self, listItemVM: AddonListItemVM) -> None: assert path is not None startfile(path) - def removeAddon(self, listItemVM: AddonListItemVM[_AddonGUIModel]) -> None: - assert addonDataManager - assert listItemVM.model + def removeAddon(self, listItemVM: AddonListItemVM) -> None: if _shouldProceedToRemoveAddonDialog(listItemVM.model): addonDataManager._deleteCacheInstalledAddon(listItemVM.model.name) - assert listItemVM.model._addonHandlerModel is not None listItemVM.model._addonHandlerModel.requestRemove() self.refresh() listItemVM.status = getStatus(listItemVM.model) @@ -288,11 +279,11 @@ def _handleEnableDisable(self, listItemVM: AddonListItemVM, shouldEnable: bool) try: listItemVM.model._addonHandlerModel.enable(shouldEnable) except addonHandler.AddonError: + log.debug(exc_info=True) if shouldEnable: errorMessage = self._enableErrorMessage else: errorMessage = self._disableErrorMessage - log.debug(errorMessage, exc_info=True) displayableError = DisplayableError( displayMessage=errorMessage.format(addon=listItemVM.model.displayName) ) @@ -319,19 +310,23 @@ def replaceAddon(self, listItemVM: AddonListItemVM) -> None: if _shouldProceedWhenInstalledAddonVersionUnknown(mainFrame, listItemVM.model): self.getAddon(listItemVM) - def getAddon(self, listItemVM: AddonListItemVM[_AddonStoreModel]) -> None: - assert addonDataManager - addonDataManager._downloadsPendingCompletion.add(listItemVM) + def getAddon(self, listItemVM: AddonListItemVM) -> None: listItemVM.status = AvailableAddonStatus.DOWNLOADING log.debug(f"{listItemVM.Id} status: {listItemVM.status}") - self._downloader.download(listItemVM, self._downloadComplete, self.onDisplayableError) + self._downloader.download(listItemVM.model, self._downloadComplete, self.onDisplayableError) - def _downloadComplete( - self, - listItemVM: AddonListItemVM[_AddonStoreModel], - fileDownloaded: Optional[PathLike] - ): - addonDataManager._downloadsPendingCompletion.remove(listItemVM) + def getAddons(self, listItemVMs: Iterable[AddonListItemVM]) -> None: + for aVM in listItemVMs: + if not aVM.model.isCompatible and aVM.model.canOverrideCompatibility: + self.installOverrideIncompatibilityForAddon(aVM) + else: + self.getAddon(aVM) + + def _downloadComplete(self, addonDetails: AddonStoreModel, fileDownloaded: Optional[PathLike]): + listItemVM: Optional[AddonListItemVM] = self.listVM._addons.get(addonDetails.listItemVMId) + if listItemVM is None: + log.error(f"No list item VM for addon with id: {addonDetails.addonId}") + return if fileDownloaded is None: # Download may have been cancelled or otherwise failed @@ -342,7 +337,6 @@ def _downloadComplete( listItemVM.status = AvailableAddonStatus.DOWNLOAD_SUCCESS log.debug(f"Queuing add-on for install on dialog exit: {listItemVM.Id}") # Add-ons can have "installTasks", which often call the GUI assuming they are on the main thread. - assert addonDataManager addonDataManager._downloadsPendingInstall.add((listItemVM, fileDownloaded)) def installPending(self): @@ -420,9 +414,8 @@ def _getAvailableAddonsInBG(self): log.debug("completed refresh") def cancelDownloads(self): - while addonDataManager._downloadsPendingCompletion: - listItem = addonDataManager._downloadsPendingCompletion.pop() - listItem.status = AvailableAddonStatus.AVAILABLE + for a in self._downloader.progress.keys(): + self.listVM._addons[a.listItemVMId].status = AvailableAddonStatus.AVAILABLE self._downloader.cancelAll() def _filterByEnabledKey(self, model: _AddonGUIModel) -> bool: diff --git a/tests/manual/addonStore.md b/tests/manual/addonStore.md index a282caee65d..17ea1db4228 100644 --- a/tests/manual/addonStore.md +++ b/tests/manual/addonStore.md @@ -45,11 +45,21 @@ Add-ons can be filtered by display name, publisher and description. ### Install add-on from add-on store 1. Open the add-on store. +1. Navigate to the available add-ons tab. 1. Select an add-on. -1. Navigate to and press the install button for the add-on. +1. Using the context menu, install the add-on. 1. Exit the dialog 1. Restart NVDA as prompted. -1. Confirm the add-on is listed in the add-ons store. +1. Confirm the add-ons are listed in the installed add-ons tab of the add-ons store. + +### Batch install add-ons from add-on store +1. Open the add-on store. +1. Navigate to the available add-ons tab. +1. Select multiple add-ons using `shift` and `ctrl`. +1. Using the context menu, install the add-ons. +1. Exit the dialog +1. Restart NVDA as prompted. +1. Confirm the add-ons are listed in the installed add-ons tab of the add-ons store. ### Install add-on from external source in add-on store 1. Open the add-on store. diff --git a/user_docs/en/userGuide.t2t b/user_docs/en/userGuide.t2t index 7d99fa34ac4..49d0b39d243 100644 --- a/user_docs/en/userGuide.t2t +++ b/user_docs/en/userGuide.t2t @@ -2740,6 +2740,9 @@ You can install and update add-ons by [browsing Available add-ons #AddonStoreBro Select an add-on from the "Available add-ons" or "Updatable add-ons" tab. Then use the update, install, or replace action to start the installation. +You can also install multiple add-ons at once. +This can be done by selecting multiple add-ons in the available add-ons tab, then activating the context menu on the selection and choosing the install action. + To install an add-on you have obtained outside of the Add-on Store, press the "Install from external source" button. This will allow you to browse for an add-on package (``.nvda-addon`` file) somewhere on your computer or on a network. Once you open the add-on package, the installation process will begin. From 6c1bb3110af19d49aa2711ff070e77c1b45d6b55 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Wed, 6 Sep 2023 13:33:21 +1000 Subject: [PATCH 18/22] address review comments --- .../gui/_addonStoreGui/controls/addonList.py | 15 +++------------ source/gui/_addonStoreGui/controls/details.py | 18 ++++++++++++++++-- .../gui/_addonStoreGui/viewModels/addonList.py | 9 --------- source/gui/_addonStoreGui/viewModels/store.py | 7 ++++++- 4 files changed, 25 insertions(+), 24 deletions(-) diff --git a/source/gui/_addonStoreGui/controls/addonList.py b/source/gui/_addonStoreGui/controls/addonList.py index 107464d32ba..c63f86520d4 100644 --- a/source/gui/_addonStoreGui/controls/addonList.py +++ b/source/gui/_addonStoreGui/controls/addonList.py @@ -118,7 +118,7 @@ def _itemDataUpdated(self, index: int): self.RefreshItem(index) def OnItemSelected(self, evt: wx.ListEvent): - newIndex = self.GetFirstSelected() + newIndex = evt.GetIndex() log.debug(f"item selected: {newIndex}") self._addonsListVM.setSelection(index=newIndex) @@ -128,17 +128,8 @@ def OnItemActivated(self, evt: wx.ListEvent): log.debug(f"item activated: {evt.GetIndex()}") def OnItemDeselected(self, evt: wx.ListEvent): - # Call this later, as the list control doesn't update its selection until after this event, - # and we need an accurate selection count. - wx.CallAfter(self._OnItemDeselected, evt) - - def _OnItemDeselected(self, evt: wx.ListEvent): - if self.GetSelectedItemCount() == 0: - log.debug("item deselected") - self._addonsListVM.setSelection(None) - else: - log.debug("updating selection due to item deselected") - self.OnItemSelected(evt) + log.debug("item deselected") + self._addonsListVM.setSelection(None) def OnGetItemText(self, itemIndex: int, colIndex: int) -> str: dataItem = self._addonsListVM.getAddonFieldText( diff --git a/source/gui/_addonStoreGui/controls/details.py b/source/gui/_addonStoreGui/controls/details.py index 22974126942..f44175485ef 100644 --- a/source/gui/_addonStoreGui/controls/details.py +++ b/source/gui/_addonStoreGui/controls/details.py @@ -3,6 +3,8 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. +from typing import TYPE_CHECKING, cast + import wx from _addonStore.models.addon import ( @@ -20,6 +22,9 @@ _fontFaceName = "Segoe UI" _fontFaceName_semiBold = "Segoe UI Semibold" +if TYPE_CHECKING: + from .storeDialog import AddonStoreDialog + class AddonDetails( wx.Panel, @@ -33,6 +38,9 @@ class AddonDetails( # Translators: Header (usually the add-on name) when no add-on is selected. In the add-on store dialog. _noAddonSelectedLabelText: str = pgettext("addonStore", "No add-on selected.") + # Translators: Header (usually the add-on name) when multiple add-ons are selected. In the add-on store dialog. + _multiAddonSelectedLabelText: str = pgettext("addonStore", "{num} add-ons selected.") + # Translators: Label for the text control containing a description of the selected add-on. # In the add-on store dialog. _descriptionLabelText: str = pgettext("addonStore", "Description:") @@ -45,9 +53,11 @@ class AddonDetails( # In the add-on store dialog. _actionsLabelText: str = pgettext("addonStore", "A&ctions") + Parent: "AddonStoreDialog" + def __init__( self, - parent: wx.Window, + parent: "AddonStoreDialog", detailsVM: AddonDetailsVM, actionsContextMenu: _MonoActionsContextMenu, ): @@ -194,12 +204,16 @@ def _updatedListItem(self, addonDetailsVM: AddonDetailsVM): def _refresh(self): details = None if self._detailsVM.listItem is None else self._detailsVM.listItem.model + numSelectedAddons = self.Parent.addonListView.GetSelectedItemCount() with guiHelper.autoThaw(self): # AppendText is used to build up the details so that formatting can be set as text is added, via # SetDefaultStyle, however, this means the text control must start empty. self.otherDetailsTextCtrl.SetValue("") - if not details: + if numSelectedAddons > 1: + self.contentsPanel.Hide() + self.updateAddonName(AddonDetails._multiAddonSelectedLabelText.format(num=numSelectedAddons)) + elif not details: self.contentsPanel.Hide() if self._detailsVM._listVM._isLoading: self.updateAddonName(AddonDetails._loadingAddonsLabelText) diff --git a/source/gui/_addonStoreGui/viewModels/addonList.py b/source/gui/_addonStoreGui/viewModels/addonList.py index b91f303c7e0..7aa2ef5b3c4 100644 --- a/source/gui/_addonStoreGui/viewModels/addonList.py +++ b/source/gui/_addonStoreGui/viewModels/addonList.py @@ -143,15 +143,6 @@ def listItem(self) -> Optional[AddonListItemVM]: @listItem.setter def listItem(self, newListItem: Optional[AddonListItemVM]): - if ( - self._listItem == newListItem # both may be same ref or None - or ( - None not in (newListItem, self._listItem) - and self._listItem.Id == newListItem.Id # confirm with addonId - ) - ): - # already set, exit early - return self._listItem = newListItem # ensure calling on the main thread. core.callLater(delay=0, callable=self.updated.notify, addonDetailsVM=self) diff --git a/source/gui/_addonStoreGui/viewModels/store.py b/source/gui/_addonStoreGui/viewModels/store.py index 55ce048b5fc..50d587e84a5 100644 --- a/source/gui/_addonStoreGui/viewModels/store.py +++ b/source/gui/_addonStoreGui/viewModels/store.py @@ -328,7 +328,12 @@ def getAddon(self, listItemVM: AddonListItemVM[_AddonStoreModel]) -> None: def getAddons(self, listItemVMs: Iterable[AddonListItemVM[_AddonStoreModel]]) -> None: for aVM in listItemVMs: - if not aVM.model.isCompatible and aVM.model.canOverrideCompatibility: + if aVM.status not in ( + AvailableAddonStatus.AVAILABLE, + AvailableAddonStatus.UPDATE, + ): + log.debug(f"Skipping {aVM.Id} as it is not available or updatable") + elif not aVM.model.isCompatible and aVM.model.canOverrideCompatibility: self.installOverrideIncompatibilityForAddon(aVM) else: self.getAddon(aVM) From 7cff663e4f293d6aaeaaf1885e46e3e1920149e4 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Wed, 6 Sep 2023 13:43:00 +1000 Subject: [PATCH 19/22] fix lint --- source/gui/_addonStoreGui/controls/details.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/source/gui/_addonStoreGui/controls/details.py b/source/gui/_addonStoreGui/controls/details.py index f44175485ef..5c12a4d9eb6 100644 --- a/source/gui/_addonStoreGui/controls/details.py +++ b/source/gui/_addonStoreGui/controls/details.py @@ -3,7 +3,7 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING import wx @@ -38,7 +38,8 @@ class AddonDetails( # Translators: Header (usually the add-on name) when no add-on is selected. In the add-on store dialog. _noAddonSelectedLabelText: str = pgettext("addonStore", "No add-on selected.") - # Translators: Header (usually the add-on name) when multiple add-ons are selected. In the add-on store dialog. + # Translators: Header (usually the add-on name) when multiple add-ons are selected. + # In the add-on store dialog. _multiAddonSelectedLabelText: str = pgettext("addonStore", "{num} add-ons selected.") # Translators: Label for the text control containing a description of the selected add-on. From b4cdc5c642221297c6ef0b83112c6caba3e13288 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Wed, 6 Sep 2023 15:00:15 +1000 Subject: [PATCH 20/22] unbind space --- source/gui/_addonStoreGui/controls/addonList.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/source/gui/_addonStoreGui/controls/addonList.py b/source/gui/_addonStoreGui/controls/addonList.py index c63f86520d4..2619289801f 100644 --- a/source/gui/_addonStoreGui/controls/addonList.py +++ b/source/gui/_addonStoreGui/controls/addonList.py @@ -123,6 +123,10 @@ def OnItemSelected(self, evt: wx.ListEvent): self._addonsListVM.setSelection(index=newIndex) def OnItemActivated(self, evt: wx.ListEvent): + if evt.GetKeyCode() == wx.WXK_SPACE: + # Space key is used to toggle add-on selection. + # Don't trigger the context menu. + return position = self._getListSelectionPosition() self._contextMenu.popupContextMenuFromPosition(self, position) log.debug(f"item activated: {evt.GetIndex()}") From 35efcb9a214dd88d7ae560b045f1634ea5830b11 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Fri, 8 Sep 2023 16:01:12 +1000 Subject: [PATCH 21/22] minor fixes --- source/_addonStore/models/status.py | 3 +++ source/_addonStore/network.py | 5 +++-- source/gui/_addonStoreGui/viewModels/store.py | 11 ++++++++--- user_docs/en/changes.t2t | 2 +- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/source/_addonStore/models/status.py b/source/_addonStore/models/status.py index 7eab4871ebb..18196c1866e 100644 --- a/source/_addonStore/models/status.py +++ b/source/_addonStore/models/status.py @@ -156,6 +156,9 @@ def getStatus(model: "_AddonGUIModel") -> AvailableAddonStatus: from .version import MajorMinorPatch addonHandlerModel = model._addonHandlerModel + if model.name in (d.model.name for d in addonDataManager._downloadsPendingCompletion): + return AvailableAddonStatus.DOWNLOADING + if model.name in (d.model.name for d, _ in addonDataManager._downloadsPendingInstall): return AvailableAddonStatus.DOWNLOAD_SUCCESS diff --git a/source/_addonStore/network.py b/source/_addonStore/network.py index 2920e0ce863..0be28a010a7 100644 --- a/source/_addonStore/network.py +++ b/source/_addonStore/network.py @@ -109,7 +109,7 @@ def download( f.add_done_callback(self._done) def _done(self, downloadAddonFuture: Future[Optional[os.PathLike]]): - isCancelled = downloadAddonFuture not in self._pending + isCancelled = downloadAddonFuture.cancelled() or downloadAddonFuture not in self._pending addonId = "CANCELLED" if isCancelled else self._pending[downloadAddonFuture][0].model.addonId log.debug(f"Done called for {addonId}") if isCancelled: @@ -144,7 +144,8 @@ def _done(self, downloadAddonFuture: Future[Optional[os.PathLike]]): def cancelAll(self): log.debug("Cancelling all") - for f in self._pending.keys(): + futuresCopy = self._pending.copy() + for f in futuresCopy.keys(): f.cancel() assert self._executor self._executor.shutdown(wait=False) diff --git a/source/gui/_addonStoreGui/viewModels/store.py b/source/gui/_addonStoreGui/viewModels/store.py index 50d587e84a5..38fd2bb3040 100644 --- a/source/gui/_addonStoreGui/viewModels/store.py +++ b/source/gui/_addonStoreGui/viewModels/store.py @@ -24,7 +24,6 @@ from _addonStore.dataManager import addonDataManager from _addonStore.install import installAddon from _addonStore.models.addon import ( - AddonStoreModel, _createAddonGUICollection, _AddonGUIModel, _AddonStoreModel, @@ -343,7 +342,10 @@ def _downloadComplete( listItemVM: AddonListItemVM[_AddonStoreModel], fileDownloaded: Optional[PathLike] ): - addonDataManager._downloadsPendingCompletion.remove(listItemVM) + try: + addonDataManager._downloadsPendingCompletion.remove(listItemVM) + except KeyError: + log.debug("Download already completed") if fileDownloaded is None: # Download may have been cancelled or otherwise failed @@ -382,7 +384,10 @@ def _doInstall(self, listItemVM: AddonListItemVM, fileDownloaded: PathLike): listItemVM.status = AvailableAddonStatus.INSTALLED addonDataManager._cacheInstalledAddon(listItemVM.model) # Clean up download file - os.remove(fileDownloaded) + try: + os.remove(fileDownloaded) + except FileNotFoundError: + pass log.debug(f"{listItemVM.Id} status: {listItemVM.status}") def refresh(self): diff --git a/user_docs/en/changes.t2t b/user_docs/en/changes.t2t index 96458891e51..17315a08945 100644 --- a/user_docs/en/changes.t2t +++ b/user_docs/en/changes.t2t @@ -39,7 +39,7 @@ What's New in NVDA - The advanced setting to enable support for HID braille has been removed in favor of a new option. You can now disable specific drivers for braille display auto detection in the braille display selection dialog. (#15196) - -- Add-on Store: Installed add-ons will now be listed in the Available Add-ons tab, if they are still available in the store. (#15374) +- Add-on Store: Installed add-ons will now be listed in the Available Add-ons tab, if they are available in the store. (#15374) - Some shortcut keys have been updated in the NVDA menu. (#15364) - From 075514ec7dfc5d70216d59cd165b692482f55423 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Mon, 11 Sep 2023 13:19:51 +1000 Subject: [PATCH 22/22] fix up lint: too complex --- source/_addonStore/models/status.py | 90 +++++++++++++++++++---------- 1 file changed, 58 insertions(+), 32 deletions(-) diff --git a/source/_addonStore/models/status.py b/source/_addonStore/models/status.py index 18196c1866e..2f11c1db0af 100644 --- a/source/_addonStore/models/status.py +++ b/source/_addonStore/models/status.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import ( Dict, + Optional, OrderedDict, Set, TYPE_CHECKING, @@ -21,7 +22,7 @@ from NVDAState import WritePaths from utils.displayString import DisplayStringEnum -from .version import SupportsVersionCheck +from .version import MajorMinorPatch, SupportsVersionCheck if TYPE_CHECKING: from .addon import _AddonGUIModel # noqa: F401 @@ -146,15 +147,9 @@ class AddonStateCategory(str, enum.Enum): """Add-ons that are blocked from running because they are incompatible""" -def getStatus(model: "_AddonGUIModel") -> AvailableAddonStatus: - from addonHandler import ( - state as addonHandlerState, - ) +def _getDownloadableStatus(model: "_AddonGUIModel") -> Optional[AvailableAddonStatus]: from ..dataManager import addonDataManager assert addonDataManager is not None - from .addon import AddonStoreModel - from .version import MajorMinorPatch - addonHandlerModel = model._addonHandlerModel if model.name in (d.model.name for d in addonDataManager._downloadsPendingCompletion): return AvailableAddonStatus.DOWNLOADING @@ -162,7 +157,8 @@ def getStatus(model: "_AddonGUIModel") -> AvailableAddonStatus: if model.name in (d.model.name for d, _ in addonDataManager._downloadsPendingInstall): return AvailableAddonStatus.DOWNLOAD_SUCCESS - if addonHandlerModel is None: + if model._addonHandlerModel is None: + # add-on is not installed if model.isPendingInstall: return AvailableAddonStatus.DOWNLOAD_SUCCESS @@ -173,8 +169,56 @@ def getStatus(model: "_AddonGUIModel") -> AvailableAddonStatus: # Any compatible add-on which is not installed should be listed as available return AvailableAddonStatus.AVAILABLE + return None + + +def _getUpdateStatus(model: "_AddonGUIModel") -> Optional[AvailableAddonStatus]: + from .addon import AddonStoreModel + from ..dataManager import addonDataManager + assert addonDataManager is not None + + if not isinstance(model, AddonStoreModel): + # If the listed add-on is installed from a side-load + # and not available on the add-on store + # the type will not be AddonStoreModel + return None + + addonStoreInstalledData = addonDataManager._getCachedInstalledAddonData(model.addonId) + if addonStoreInstalledData is not None: + if model.addonVersionNumber > addonStoreInstalledData.addonVersionNumber: + return AvailableAddonStatus.UPDATE + else: + # Parsing from a side-loaded add-on + try: + manifestAddonVersion = MajorMinorPatch._parseVersionFromVersionStr(model._addonHandlerModel.version) + except ValueError: + # Parsing failed to get a numeric version. + # Ideally a numeric version would be compared, + # however the manifest only has a version string. + # Ensure the user is aware that it may be a downgrade or reinstall. + # Encourage users to re-install or upgrade the add-on from the add-on store. + return AvailableAddonStatus.REPLACE_SIDE_LOAD + + if model.addonVersionNumber > manifestAddonVersion: + return AvailableAddonStatus.UPDATE + + return None + + +def getStatus(model: "_AddonGUIModel") -> AvailableAddonStatus: + from addonHandler import state as addonHandlerState + + downloadableStatus = _getDownloadableStatus(model) + if downloadableStatus: + # Is this available in the add-on store and not installed? + return downloadableStatus + else: + # Add-on is currently installed or pending restart + addonHandlerModel = model._addonHandlerModel + assert addonHandlerModel + for storeState, handlerStateCategories in _addonStoreStateToAddonHandlerState.items(): - # Match addonHandler states early for installed add-ons. + # Match special addonHandler states early for installed add-ons. # Includes enabled, pending enabled, disabled, e.t.c. if all( model.addonId in addonHandlerState[stateCategory] @@ -189,28 +233,10 @@ def getStatus(model: "_AddonGUIModel") -> AvailableAddonStatus: # and another for enabled/disabled. return storeState - addonStoreInstalledData = addonDataManager._getCachedInstalledAddonData(model.addonId) - if isinstance(model, AddonStoreModel): - # If the listed add-on is installed from a side-load - # and not available on the add-on store - # the type will not be AddonStoreModel - if addonStoreInstalledData is not None: - if model.addonVersionNumber > addonStoreInstalledData.addonVersionNumber: - return AvailableAddonStatus.UPDATE - else: - # Parsing from a side-loaded add-on - try: - manifestAddonVersion = MajorMinorPatch._parseVersionFromVersionStr(addonHandlerModel.version) - except ValueError: - # Parsing failed to get a numeric version. - # Ideally a numeric version would be compared, - # however the manifest only has a version string. - # Ensure the user is aware that it may be a downgrade or reinstall. - # Encourage users to re-install or upgrade the add-on from the add-on store. - return AvailableAddonStatus.REPLACE_SIDE_LOAD - - if model.addonVersionNumber > manifestAddonVersion: - return AvailableAddonStatus.UPDATE + updateStatus = _getUpdateStatus(model) + if updateStatus: + # Can add-on be updated? + return updateStatus if addonHandlerModel.isRunning: return AvailableAddonStatus.RUNNING