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/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..2619289801f 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,11 +107,11 @@ 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}") @@ -99,12 +123,16 @@ 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._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") + log.debug("item deselected") self._addonsListVM.setSelection(None) def OnGetItemText(self, itemIndex: int, colIndex: int) -> str: diff --git a/source/gui/_addonStoreGui/controls/details.py b/source/gui/_addonStoreGui/controls/details.py index 182a0141a28..5c12a4d9eb6 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 + import wx from _addonStore.models.addon import ( @@ -15,11 +17,14 @@ from ..viewModels.addonList import AddonDetailsVM, AddonListField -from .actions import _ActionsContextMenu +from .actions import _MonoActionsContextMenu _fontFaceName = "Segoe UI" _fontFaceName_semiBold = "Segoe UI Semibold" +if TYPE_CHECKING: + from .storeDialog import AddonStoreDialog + class AddonDetails( wx.Panel, @@ -33,6 +38,10 @@ 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,11 +54,13 @@ 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: _ActionsContextMenu, + actionsContextMenu: _MonoActionsContextMenu, ): self._detailsVM: AddonDetailsVM = detailsVM self._actionsContextMenu = actionsContextMenu @@ -194,12 +205,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/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..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) @@ -258,6 +249,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..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, @@ -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,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 @@ -236,14 +235,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 ), ] @@ -326,12 +325,27 @@ def getAddon(self, listItemVM: AddonListItemVM[_AddonStoreModel]) -> None: log.debug(f"{listItemVM.Id} status: {listItemVM.status}") self._downloader.download(listItemVM, self._downloadComplete, self.onDisplayableError) + def getAddons(self, listItemVMs: Iterable[AddonListItemVM[_AddonStoreModel]]) -> None: + for aVM in listItemVMs: + 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) + def _downloadComplete( self, 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 @@ -370,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/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/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) - 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.