Skip to content

Commit a52ebe9

Browse files
authored
Merge 075514e into 6b19080
2 parents 6b19080 + 075514e commit a52ebe9

12 files changed

Lines changed: 380 additions & 122 deletions

File tree

source/_addonStore/models/status.py

Lines changed: 61 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from pathlib import Path
99
from typing import (
1010
Dict,
11+
Optional,
1112
OrderedDict,
1213
Set,
1314
TYPE_CHECKING,
@@ -21,7 +22,7 @@
2122
from NVDAState import WritePaths
2223
from utils.displayString import DisplayStringEnum
2324

24-
from .version import SupportsVersionCheck
25+
from .version import MajorMinorPatch, SupportsVersionCheck
2526

2627
if TYPE_CHECKING:
2728
from .addon import _AddonGUIModel # noqa: F401
@@ -146,20 +147,18 @@ class AddonStateCategory(str, enum.Enum):
146147
"""Add-ons that are blocked from running because they are incompatible"""
147148

148149

149-
def getStatus(model: "_AddonGUIModel") -> AvailableAddonStatus:
150-
from addonHandler import (
151-
state as addonHandlerState,
152-
)
150+
def _getDownloadableStatus(model: "_AddonGUIModel") -> Optional[AvailableAddonStatus]:
153151
from ..dataManager import addonDataManager
154152
assert addonDataManager is not None
155-
from .addon import AddonStoreModel
156-
from .version import MajorMinorPatch
157-
addonHandlerModel = model._addonHandlerModel
153+
154+
if model.name in (d.model.name for d in addonDataManager._downloadsPendingCompletion):
155+
return AvailableAddonStatus.DOWNLOADING
158156

159157
if model.name in (d.model.name for d, _ in addonDataManager._downloadsPendingInstall):
160158
return AvailableAddonStatus.DOWNLOAD_SUCCESS
161159

162-
if addonHandlerModel is None:
160+
if model._addonHandlerModel is None:
161+
# add-on is not installed
163162
if model.isPendingInstall:
164163
return AvailableAddonStatus.DOWNLOAD_SUCCESS
165164

@@ -170,8 +169,56 @@ def getStatus(model: "_AddonGUIModel") -> AvailableAddonStatus:
170169
# Any compatible add-on which is not installed should be listed as available
171170
return AvailableAddonStatus.AVAILABLE
172171

172+
return None
173+
174+
175+
def _getUpdateStatus(model: "_AddonGUIModel") -> Optional[AvailableAddonStatus]:
176+
from .addon import AddonStoreModel
177+
from ..dataManager import addonDataManager
178+
assert addonDataManager is not None
179+
180+
if not isinstance(model, AddonStoreModel):
181+
# If the listed add-on is installed from a side-load
182+
# and not available on the add-on store
183+
# the type will not be AddonStoreModel
184+
return None
185+
186+
addonStoreInstalledData = addonDataManager._getCachedInstalledAddonData(model.addonId)
187+
if addonStoreInstalledData is not None:
188+
if model.addonVersionNumber > addonStoreInstalledData.addonVersionNumber:
189+
return AvailableAddonStatus.UPDATE
190+
else:
191+
# Parsing from a side-loaded add-on
192+
try:
193+
manifestAddonVersion = MajorMinorPatch._parseVersionFromVersionStr(model._addonHandlerModel.version)
194+
except ValueError:
195+
# Parsing failed to get a numeric version.
196+
# Ideally a numeric version would be compared,
197+
# however the manifest only has a version string.
198+
# Ensure the user is aware that it may be a downgrade or reinstall.
199+
# Encourage users to re-install or upgrade the add-on from the add-on store.
200+
return AvailableAddonStatus.REPLACE_SIDE_LOAD
201+
202+
if model.addonVersionNumber > manifestAddonVersion:
203+
return AvailableAddonStatus.UPDATE
204+
205+
return None
206+
207+
208+
def getStatus(model: "_AddonGUIModel") -> AvailableAddonStatus:
209+
from addonHandler import state as addonHandlerState
210+
211+
downloadableStatus = _getDownloadableStatus(model)
212+
if downloadableStatus:
213+
# Is this available in the add-on store and not installed?
214+
return downloadableStatus
215+
else:
216+
# Add-on is currently installed or pending restart
217+
addonHandlerModel = model._addonHandlerModel
218+
assert addonHandlerModel
219+
173220
for storeState, handlerStateCategories in _addonStoreStateToAddonHandlerState.items():
174-
# Match addonHandler states early for installed add-ons.
221+
# Match special addonHandler states early for installed add-ons.
175222
# Includes enabled, pending enabled, disabled, e.t.c.
176223
if all(
177224
model.addonId in addonHandlerState[stateCategory]
@@ -186,28 +233,10 @@ def getStatus(model: "_AddonGUIModel") -> AvailableAddonStatus:
186233
# and another for enabled/disabled.
187234
return storeState
188235

189-
addonStoreInstalledData = addonDataManager._getCachedInstalledAddonData(model.addonId)
190-
if isinstance(model, AddonStoreModel):
191-
# If the listed add-on is installed from a side-load
192-
# and not available on the add-on store
193-
# the type will not be AddonStoreModel
194-
if addonStoreInstalledData is not None:
195-
if model.addonVersionNumber > addonStoreInstalledData.addonVersionNumber:
196-
return AvailableAddonStatus.UPDATE
197-
else:
198-
# Parsing from a side-loaded add-on
199-
try:
200-
manifestAddonVersion = MajorMinorPatch._parseVersionFromVersionStr(addonHandlerModel.version)
201-
except ValueError:
202-
# Parsing failed to get a numeric version.
203-
# Ideally a numeric version would be compared,
204-
# however the manifest only has a version string.
205-
# Ensure the user is aware that it may be a downgrade or reinstall.
206-
# Encourage users to re-install or upgrade the add-on from the add-on store.
207-
return AvailableAddonStatus.REPLACE_SIDE_LOAD
208-
209-
if model.addonVersionNumber > manifestAddonVersion:
210-
return AvailableAddonStatus.UPDATE
236+
updateStatus = _getUpdateStatus(model)
237+
if updateStatus:
238+
# Can add-on be updated?
239+
return updateStatus
211240

212241
if addonHandlerModel.isRunning:
213242
return AvailableAddonStatus.RUNNING

source/_addonStore/network.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def download(
109109
f.add_done_callback(self._done)
110110

111111
def _done(self, downloadAddonFuture: Future[Optional[os.PathLike]]):
112-
isCancelled = downloadAddonFuture not in self._pending
112+
isCancelled = downloadAddonFuture.cancelled() or downloadAddonFuture not in self._pending
113113
addonId = "CANCELLED" if isCancelled else self._pending[downloadAddonFuture][0].model.addonId
114114
log.debug(f"Done called for {addonId}")
115115
if isCancelled:
@@ -144,7 +144,8 @@ def _done(self, downloadAddonFuture: Future[Optional[os.PathLike]]):
144144

145145
def cancelAll(self):
146146
log.debug("Cancelling all")
147-
for f in self._pending.keys():
147+
futuresCopy = self._pending.copy()
148+
for f in futuresCopy.keys():
148149
f.cancel()
149150
assert self._executor
150151
self._executor.shutdown(wait=False)

source/gui/_addonStoreGui/controls/actions.py

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,38 @@
33
# This file is covered by the GNU General Public License.
44
# See the file COPYING for more details.
55

6+
from abc import ABC, abstractmethod
67
import functools
78
from typing import (
89
Dict,
10+
Generic,
11+
Iterable,
912
List,
13+
TypeVar,
1014
)
1115

1216
import wx
1317

18+
from _addonStore.models.status import _StatusFilterKey
1419
from logHandler import log
20+
import ui
1521

16-
from ..viewModels.action import AddonActionVM
22+
from ..viewModels.action import AddonActionVM, BatchAddonActionVM
23+
from ..viewModels.addonList import AddonListItemVM
1724
from ..viewModels.store import AddonStoreVM
1825

1926

20-
class _ActionsContextMenu:
21-
def __init__(self, storeVM: AddonStoreVM):
22-
self._storeVM = storeVM
23-
self._actionMenuItemMap: Dict[AddonActionVM, wx.MenuItem] = {}
24-
self._contextMenu = wx.Menu()
27+
AddonActionT = TypeVar("AddonActionT", AddonActionVM, BatchAddonActionVM)
28+
29+
30+
class _ActionsContextMenuP(Generic[AddonActionT], ABC):
31+
_actions: List[AddonActionT]
32+
_actionMenuItemMap: Dict[AddonActionT, wx.MenuItem]
33+
_contextMenu: wx.Menu
34+
35+
@abstractmethod
36+
def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: AddonActionT):
37+
...
2538

2639
def popupContextMenuFromPosition(
2740
self,
@@ -31,14 +44,9 @@ def popupContextMenuFromPosition(
3144
self._populateContextMenu()
3245
targetWindow.PopupMenu(self._contextMenu, pos=position)
3346

34-
def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: AddonActionVM):
35-
selectedAddon = actionVM.listItemVM
36-
log.debug(f"action selected: actionVM: {actionVM}, selectedAddon: {selectedAddon}")
37-
actionVM.actionHandler(selectedAddon)
38-
3947
def _populateContextMenu(self):
4048
prevActionIndex = -1
41-
for action in self._storeVM.actionVMList:
49+
for action in self._actions:
4250
menuItem = self._actionMenuItemMap.get(action)
4351
menuItems: List[wx.MenuItem] = list(self._contextMenu.GetMenuItems())
4452
isMenuItemInContextMenu = menuItem is not None and menuItem in menuItems
@@ -71,3 +79,68 @@ def _populateContextMenu(self):
7179
# Remove the menu item from the context menu.
7280
self._contextMenu.RemoveItem(menuItem)
7381
del self._actionMenuItemMap[action]
82+
83+
menuItems: List[wx.MenuItem] = list(self._contextMenu.GetMenuItems())
84+
for menuItem in menuItems:
85+
if menuItem not in self._actionMenuItemMap.values():
86+
# The menu item is not in the action menu item map.
87+
# It should be removed from the context menu.
88+
self._contextMenu.RemoveItem(menuItem)
89+
90+
91+
class _MonoActionsContextMenu(_ActionsContextMenuP[AddonActionVM]):
92+
"""Context menu for actions for a single add-on"""
93+
def __init__(self, storeVM: AddonStoreVM):
94+
self._storeVM = storeVM
95+
self._actionMenuItemMap = {}
96+
self._contextMenu = wx.Menu()
97+
98+
def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: AddonActionVM):
99+
selectedAddon = actionVM.actionTarget
100+
log.debug(f"action selected: actionVM: {actionVM.displayName}, selectedAddon: {selectedAddon}")
101+
actionVM.actionHandler(selectedAddon)
102+
103+
@property
104+
def _actions(self) -> List[AddonActionVM]:
105+
return self._storeVM.actionVMList
106+
107+
108+
class _BatchActionsContextMenu(_ActionsContextMenuP[BatchAddonActionVM]):
109+
"""Context menu for actions for a group of add-ons"""
110+
def __init__(self, storeVM: AddonStoreVM):
111+
self._storeVM = storeVM
112+
self._actionMenuItemMap = {}
113+
self._contextMenu = wx.Menu()
114+
self._selectedAddons: Iterable[AddonListItemVM] = tuple()
115+
116+
def _updateSelectedAddons(self, selectedAddons: Iterable[AddonListItemVM]):
117+
# Reset the action menu as self._actions depends on the selected add-ons
118+
self._actionMenuItemMap = {}
119+
self._selectedAddons = selectedAddons
120+
121+
def popupContextMenuFromPosition(
122+
self,
123+
targetWindow: wx.Window,
124+
position: wx.Position = wx.DefaultPosition
125+
):
126+
super().popupContextMenuFromPosition(targetWindow, position)
127+
if self._contextMenu.GetMenuItemCount() == 0:
128+
# Translators: a message displayed when activating the context menu on multiple selected add-ons,
129+
# but no actions are available for the add-ons.
130+
ui.message(pgettext("addonStore", "No actions available for the selected add-ons"))
131+
132+
def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: BatchAddonActionVM):
133+
log.debug(f"Performing batch action for actionVM: {actionVM.displayName}")
134+
actionVM.actionHandler(self._selectedAddons)
135+
136+
@property
137+
def _actions(self) -> List[BatchAddonActionVM]:
138+
return [
139+
BatchAddonActionVM(
140+
# Translators: Label for an action that installs the selected add-ons
141+
displayName=pgettext("addonStore", "&Install selected add-ons"),
142+
actionHandler=self._storeVM.getAddons,
143+
validCheck=lambda aVMs: self._storeVM._filteredStatusKey == _StatusFilterKey.AVAILABLE,
144+
actionTarget=self._selectedAddons
145+
),
146+
]

source/gui/_addonStoreGui/controls/addonList.py

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# See the file COPYING for more details.
55

66
from typing import (
7+
List,
78
Optional,
89
)
910

@@ -16,8 +17,12 @@
1617
from gui.dpiScalingHelper import DpiScalingHelperMixinWithoutInit
1718
from logHandler import log
1819

19-
from .actions import _ActionsContextMenu
20-
from ..viewModels.addonList import AddonListVM
20+
from .actions import (
21+
_ActionsContextMenuP,
22+
_BatchActionsContextMenu,
23+
_MonoActionsContextMenu,
24+
)
25+
from ..viewModels.addonList import AddonListItemVM, AddonListVM
2126

2227

2328
class AddonVirtualList(
@@ -28,21 +33,21 @@ def __init__(
2833
self,
2934
parent: wx.Window,
3035
addonsListVM: AddonListVM,
31-
actionsContextMenu: _ActionsContextMenu,
36+
actionsContextMenu: _MonoActionsContextMenu,
3237
):
3338
super().__init__(
3439
parent,
3540
style=(
3641
wx.LC_REPORT # Single or multicolumn report view, with optional header.
3742
| wx.LC_VIRTUAL # The application provides items text on demand. May only be used with LC_REPORT.
38-
| wx.LC_SINGLE_SEL # Single selection (default is multiple).
3943
| wx.LC_HRULES # Draws light horizontal rules between rows in report mode.
4044
| wx.LC_VRULES # Draws light vertical rules between columns in report mode.
4145
),
4246
autoSizeColumn=1,
4347
)
4448
self._addonsListVM = addonsListVM
4549
self._actionsContextMenu = actionsContextMenu
50+
self._batchActionsContextMenu = _BatchActionsContextMenu(self._addonsListVM._storeVM)
4651

4752
self.SetMinSize(self.scaleSize((500, 500)))
4853

@@ -75,6 +80,25 @@ def _getListSelectionPosition(self) -> Optional[wx.Position]:
7580
itemRect: wx.Rect = self.GetItemRect(firstSelectedIndex)
7681
return itemRect.GetBottomLeft()
7782

83+
def _updateBatchContextMenuSelection(self):
84+
numSelected = self.GetSelectedItemCount()
85+
prevSelectedIndex = self.GetFirstSelected()
86+
selectedAddons: List[AddonListItemVM] = []
87+
for _ in range(numSelected):
88+
addon = self._addonsListVM.getAddonAtIndex(prevSelectedIndex)
89+
selectedAddons.append(addon)
90+
prevSelectedIndex = self.GetNextSelected(prevSelectedIndex)
91+
92+
self._batchActionsContextMenu._updateSelectedAddons(selectedAddons)
93+
94+
@property
95+
def _contextMenu(self) -> _ActionsContextMenuP:
96+
numSelected = self.GetSelectedItemCount()
97+
if numSelected > 1:
98+
self._updateBatchContextMenuSelection()
99+
return self._batchActionsContextMenu
100+
return self._actionsContextMenu
101+
78102
def _popupContextMenuFromList(self, evt: wx.ContextMenuEvent):
79103
listSelectionPosition = self._getListSelectionPosition()
80104
if listSelectionPosition is None:
@@ -83,11 +107,11 @@ def _popupContextMenuFromList(self, evt: wx.ContextMenuEvent):
83107
if eventPosition == wx.DefaultPosition:
84108
# keyboard triggered context menu (due to "applications" key)
85109
# don't have position set. It must be fetched from the selected item.
86-
self._actionsContextMenu.popupContextMenuFromPosition(self, listSelectionPosition)
110+
self._contextMenu.popupContextMenuFromPosition(self, listSelectionPosition)
87111
else:
88112
# Mouse (right click) triggered context menu.
89113
# In this case the menu is positioned better with GetPopupMenuSelectionFromUser.
90-
self._actionsContextMenu.popupContextMenuFromPosition(self)
114+
self._contextMenu.popupContextMenuFromPosition(self)
91115

92116
def _itemDataUpdated(self, index: int):
93117
log.debug(f"index: {index}")
@@ -99,12 +123,16 @@ def OnItemSelected(self, evt: wx.ListEvent):
99123
self._addonsListVM.setSelection(index=newIndex)
100124

101125
def OnItemActivated(self, evt: wx.ListEvent):
126+
if evt.GetKeyCode() == wx.WXK_SPACE:
127+
# Space key is used to toggle add-on selection.
128+
# Don't trigger the context menu.
129+
return
102130
position = self._getListSelectionPosition()
103-
self._actionsContextMenu.popupContextMenuFromPosition(self, position)
131+
self._contextMenu.popupContextMenuFromPosition(self, position)
104132
log.debug(f"item activated: {evt.GetIndex()}")
105133

106134
def OnItemDeselected(self, evt: wx.ListEvent):
107-
log.debug(f"item deselected")
135+
log.debug("item deselected")
108136
self._addonsListVM.setSelection(None)
109137

110138
def OnGetItemText(self, itemIndex: int, colIndex: int) -> str:

0 commit comments

Comments
 (0)