Skip to content

Commit

Permalink
Merge 075514e into 6b19080
Browse files Browse the repository at this point in the history
  • Loading branch information
seanbudd committed Sep 11, 2023
2 parents 6b19080 + 075514e commit a52ebe9
Show file tree
Hide file tree
Showing 12 changed files with 380 additions and 122 deletions.
93 changes: 61 additions & 32 deletions source/_addonStore/models/status.py
Expand Up @@ -8,6 +8,7 @@
from pathlib import Path
from typing import (
Dict,
Optional,
OrderedDict,
Set,
TYPE_CHECKING,
Expand All @@ -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
Expand Down Expand Up @@ -146,20 +147,18 @@ 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

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

Expand All @@ -170,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]
Expand All @@ -186,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
Expand Down
5 changes: 3 additions & 2 deletions source/_addonStore/network.py
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
97 changes: 85 additions & 12 deletions source/gui/_addonStoreGui/controls/actions.py
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
),
]
44 changes: 36 additions & 8 deletions source/gui/_addonStoreGui/controls/addonList.py
Expand Up @@ -4,6 +4,7 @@
# See the file COPYING for more details.

from typing import (
List,
Optional,
)

Expand All @@ -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(
Expand All @@ -28,21 +33,21 @@ 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.
),
autoSizeColumn=1,
)
self._addonsListVM = addonsListVM
self._actionsContextMenu = actionsContextMenu
self._batchActionsContextMenu = _BatchActionsContextMenu(self._addonsListVM._storeVM)

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

Expand Down Expand Up @@ -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:
Expand All @@ -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}")
Expand All @@ -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:
Expand Down

0 comments on commit a52ebe9

Please sign in to comment.