Skip to content

Commit ac2866d

Browse files
authored
Merge 1f2a703 into 442476a
2 parents 442476a + 1f2a703 commit ac2866d

9 files changed

Lines changed: 221 additions & 30 deletions

File tree

source/_addonStore/network.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ def __init__(self):
7575
] = {}
7676
self.complete: Dict[AddonStoreModel, os.PathLike] = {} # Path to downloaded file
7777
self._executor = ThreadPoolExecutor(
78-
max_workers=1,
78+
max_workers=10,
7979
thread_name_prefix="AddonDownloader",
8080
)
8181

@@ -122,8 +122,10 @@ def _done(self, downloadAddonFuture: Future):
122122
else:
123123
cacheFilePath: Optional[os.PathLike] = downloadAddonFuture.result()
124124

125-
del self._pending[downloadAddonFuture]
126-
del self.progress[addonData]
125+
# If canceled after our previous isCancelled check,
126+
# then _pending and progress will be empty.
127+
self._pending.pop(downloadAddonFuture, None)
128+
self.progress.pop(addonData, None)
127129
self.complete[addonData] = cacheFilePath
128130
onComplete(addonData, cacheFilePath)
129131

source/gui/_addonStoreGui/controls/actions.py

Lines changed: 84 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,34 @@
66
import functools
77
from typing import (
88
Dict,
9+
Generic,
10+
Iterable,
911
List,
12+
TypeVar,
1013
)
14+
from typing_extensions import Protocol
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, BulkAddonActionVM
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, BulkAddonActionVM)
28+
29+
30+
class _ActionsContextMenuP(Generic[AddonActionT], Protocol):
31+
_actions: List[AddonActionT]
32+
_actionMenuItemMap: Dict[AddonActionT, wx.MenuItem]
33+
_contextMenu: wx.Menu
34+
35+
def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: AddonActionT):
36+
...
2537

2638
def popupContextMenuFromPosition(
2739
self,
@@ -31,14 +43,9 @@ def popupContextMenuFromPosition(
3143
self._populateContextMenu()
3244
targetWindow.PopupMenu(self._contextMenu, pos=position)
3345

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

source/gui/_addonStoreGui/controls/addonList.py

Lines changed: 43 additions & 10 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+
_BulkActionsContextMenu,
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._bulkActionsContextMenu = _BulkActionsContextMenu(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 _updateBulkContextMenuSelection(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._bulkActionsContextMenu._updateSelectedAddons(selectedAddons)
93+
94+
@property
95+
def _contextMenu(self) -> _ActionsContextMenuP:
96+
numSelected = self.GetSelectedItemCount()
97+
if numSelected > 1:
98+
self._updateBulkContextMenuSelection()
99+
return self._bulkActionsContextMenu
100+
return self._actionsContextMenu
101+
78102
def _popupContextMenuFromList(self, evt: wx.ContextMenuEvent):
79103
listSelectionPosition = self._getListSelectionPosition()
80104
if listSelectionPosition is None:
@@ -83,29 +107,38 @@ 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}")
94118
self.RefreshItem(index)
95119

96120
def OnItemSelected(self, evt: wx.ListEvent):
97-
newIndex = evt.GetIndex()
121+
newIndex = self.GetFirstSelected()
98122
log.debug(f"item selected: {newIndex}")
99123
self._addonsListVM.setSelection(index=newIndex)
100124

101125
def OnItemActivated(self, evt: wx.ListEvent):
102126
position = self._getListSelectionPosition()
103-
self._actionsContextMenu.popupContextMenuFromPosition(self, position)
127+
self._contextMenu.popupContextMenuFromPosition(self, position)
104128
log.debug(f"item activated: {evt.GetIndex()}")
105129

106130
def OnItemDeselected(self, evt: wx.ListEvent):
107-
log.debug(f"item deselected")
108-
self._addonsListVM.setSelection(None)
131+
# Call this later, as the list control doesn't update its selection until after this event,
132+
# and we need an accurate selection count.
133+
wx.CallAfter(self._OnItemDeselected, evt)
134+
135+
def _OnItemDeselected(self, evt: wx.ListEvent):
136+
if self.GetSelectedItemCount() == 0:
137+
log.debug("item deselected")
138+
self._addonsListVM.setSelection(None)
139+
else:
140+
log.debug("updating selection due to item deselected")
141+
self.OnItemSelected(evt)
109142

110143
def OnGetItemText(self, itemIndex: int, colIndex: int) -> str:
111144
dataItem = self._addonsListVM.getAddonFieldText(

source/gui/_addonStoreGui/controls/details.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from ..viewModels.addonList import AddonDetailsVM, AddonListField
1717

18-
from .actions import _ActionsContextMenu
18+
from .actions import _MonoActionsContextMenu
1919

2020
_fontFaceName = "Segoe UI"
2121
_fontFaceName_semiBold = "Segoe UI Semibold"
@@ -49,7 +49,7 @@ def __init__(
4949
self,
5050
parent: wx.Window,
5151
detailsVM: AddonDetailsVM,
52-
actionsContextMenu: _ActionsContextMenu,
52+
actionsContextMenu: _MonoActionsContextMenu,
5353
):
5454
self._detailsVM: AddonDetailsVM = detailsVM
5555
self._actionsContextMenu = actionsContextMenu

source/gui/_addonStoreGui/controls/storeDialog.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
from logHandler import log
3434

3535
from ..viewModels.store import AddonStoreVM
36-
from .actions import _ActionsContextMenu
36+
from .actions import _MonoActionsContextMenu
3737
from .addonList import AddonVirtualList
3838
from .details import AddonDetails
3939
from .messageDialogs import _SafetyWarningDialog
@@ -50,7 +50,7 @@ class AddonStoreDialog(SettingsDialog):
5050
def __init__(self, parent: wx.Window, storeVM: AddonStoreVM):
5151
self._storeVM = storeVM
5252
self._storeVM.onDisplayableError.register(self.handleDisplayableError)
53-
self._actionsContextMenu = _ActionsContextMenu(self._storeVM)
53+
self._actionsContextMenu = _MonoActionsContextMenu(self._storeVM)
5454
super().__init__(parent, resizeable=True, buttons={wx.CLOSE})
5555
if config.conf["addonStore"]["showWarning"]:
5656
displayDialogAsModal(_SafetyWarningDialog(parent))

source/gui/_addonStoreGui/viewModels/action.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from typing import (
77
Callable,
8+
Iterable,
89
Optional,
910
TYPE_CHECKING,
1011
)
@@ -77,3 +78,69 @@ def listItemVM(self, listItemVM: Optional["AddonListItemVM"]):
7778
listItemVM.updated.register(self._listItemChanged)
7879
self._listItemVM = listItemVM
7980
self._notify()
81+
82+
83+
class BulkAddonActionVM:
84+
"""
85+
Actions/behaviour that can be embedded within other views/viewModels
86+
that can apply to a group of L{AddonListItemVM}.
87+
Use the L{BulkAddonActionVM.updated} extensionPoint.Action to be notified about changes.
88+
E.G.:
89+
- Updates within the AddonListItemVM (perhaps changing the action validity)
90+
- Entirely changing the AddonListItemVM action will be applied to, the validity can be checked for the new
91+
item.
92+
"""
93+
def __init__(
94+
self,
95+
displayName: str,
96+
actionHandler: Callable[[Iterable["AddonListItemVM"], ], None],
97+
validCheck: Callable[[Iterable["AddonListItemVM"], ], bool],
98+
listItemVMs: Iterable["AddonListItemVM"],
99+
):
100+
"""
101+
@param displayName: Translated string, to be displayed to the user. Should describe the action / behaviour.
102+
@param actionHandler: Call when the action is triggered.
103+
@param validCheck: Is the action valid for the current listItemVMs
104+
@param listItemVMs: The listItemVMs this action will be applied to. L{updated} notifies of modification.
105+
"""
106+
self.displayName = displayName
107+
self.actionHandler = actionHandler
108+
self._validCheck = validCheck
109+
self._listItemVMs = listItemVMs
110+
for listItemVM in listItemVMs:
111+
listItemVM.updated.register(self._listItemChanged)
112+
self.updated = extensionPoints.Action()
113+
"""Notify of changes to the action"""
114+
115+
def _listItemChanged(self, addonListItemVM: "AddonListItemVM"):
116+
"""Something inside the AddonListItemVM has changed"""
117+
assert addonListItemVM in self._listItemVMs, f"{addonListItemVM} {list(self._listItemVMs)}"
118+
self._notify()
119+
120+
def _notify(self):
121+
# ensure calling on the main thread.
122+
from core import callLater
123+
callLater(delay=0, callable=self.updated.notify, addonActionVM=self)
124+
125+
@property
126+
def isValid(self) -> bool:
127+
return self._validCheck(self._listItemVMs)
128+
129+
@property
130+
def listItemVMs(self) -> Iterable["AddonListItemVM"]:
131+
return self._listItemVMs
132+
133+
@listItemVMs.setter
134+
def listItemVMs(self, newListItemVMs: Iterable["AddonListItemVM"]):
135+
if self._listItemVMs == newListItemVMs:
136+
return
137+
for oldListItemVM in self._listItemVMs:
138+
if oldListItemVM not in newListItemVMs:
139+
oldListItemVM.updated.unregister(self._listItemChanged)
140+
141+
for newListItemVM in newListItemVMs:
142+
if newListItemVM not in self._listItemVMs:
143+
newListItemVM.updated.register(self._listItemChanged)
144+
145+
self._listItemVMs = newListItemVMs
146+
self._notify()

source/gui/_addonStoreGui/viewModels/addonList.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,12 @@ def getSelectedIndex(self) -> Optional[int]:
253253
return self._addonsFilteredOrdered.index(self.selectedAddonId)
254254
return None
255255

256+
def getAddonAtIndex(self, index: int) -> AddonListItemVM:
257+
self._validate(selectionIndex=index)
258+
selectedAddonId = self._addonsFilteredOrdered[index]
259+
selectedItemVM: Optional[AddonListItemVM] = self._addons[selectedAddonId]
260+
return selectedItemVM
261+
256262
def setSelection(self, index: Optional[int]) -> Optional[AddonListItemVM]:
257263
self._validate(selectionIndex=index)
258264
self.selectedAddonId = None

0 commit comments

Comments
 (0)