Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions source/gui/addonStoreGui/controls/addonList.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import (
List,
Optional,
cast,
)

import wx
Expand Down Expand Up @@ -148,17 +149,20 @@ def OnGetItemText(self, itemIndex: int, colIndex: int) -> str:
return str(dataItem)

def OnColClick(self, evt: wx.ListEvent):
from .storeDialog import AddonStoreDialog

newColIndex = evt.GetColumn()
log.debug(f"col clicked: {newColIndex}")
sel = self.Parent.columnFilterCtrl.GetSelection()
parent = cast(AddonStoreDialog, self.Parent)
sel = parent.columnFilterCtrl.GetSelection()
curColIndex = sel // 2
curReverse = sel % 2
if newColIndex == curColIndex:
newReverse = 0 if curReverse else 1
else:
newReverse = 0
self._addonsListVM.setSortField(self._addonsListVM.presentedFields[newColIndex], newReverse)
self.Parent.columnFilterCtrl.SetSelection(newColIndex * 2 + newReverse)
self._addonsListVM.setSortField(self._addonsListVM.sortableFields[newColIndex], newReverse)
parent.columnFilterCtrl.SetSelection(newColIndex * 2 + newReverse)

def _doRefresh(self):
with guiHelper.autoThaw(self):
Expand Down
38 changes: 34 additions & 4 deletions source/gui/addonStoreGui/controls/storeDialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from logHandler import log

from ..viewModels.store import AddonStoreVM
from ..viewModels.addonList import AddonListField
from .actions import _MonoActionsContextMenu
from .addonList import AddonVirtualList
from .details import AddonDetails
Expand Down Expand Up @@ -363,8 +364,8 @@ def onListTabPageChange(self, evt: wx.EVT_CHOICE):
self._storeVM._filteredStatusKey = self._statusFilterKey
self.addonListView._refreshColumns()
self._toggleFilterControls()
self.columnFilterCtrl.SetSelection(0)
self._storeVM.listVM.setSortField(self._storeVM.listVM.presentedFields[0])
fieldIndex = self._storeVM.listVM.sortableFields.index(self._storeVM.listVM._sortByModelField)
self.columnFilterCtrl.SetSelection(fieldIndex * 2 + (1 if self._storeVM.listVM._reverseSort else 0))

channelFilterIndex = list(_channelFilters.keys()).index(self._storeVM._filterChannelKey)
self.channelFilterCtrl.SetSelection(channelFilterIndex)
Expand All @@ -382,8 +383,8 @@ def onColumnFilterChange(self, evt: wx.EVT_CHOICE):
colIndex = evt.GetSelection() // 2
# Descending sort should be applied for odd choices of the combo box
reverse = evt.GetSelection() % 2
self._storeVM.listVM.setSortField(self._storeVM.listVM.presentedFields[colIndex], reverse)
log.debug(f"sortered by: {colIndex}; reversed: {reverse}")
self._storeVM.listVM.setSortField(self._storeVM.listVM.sortableFields[colIndex], reverse)
log.debug(f"sorted by: {colIndex}; reversed: {reverse}")
self._storeVM.refresh()

def onChannelFilterChange(self, evt: wx.EVT_CHOICE):
Expand All @@ -394,7 +395,36 @@ def onChannelFilterChange(self, evt: wx.EVT_CHOICE):

def onFilterTextChange(self, evt: wx.EVT_TEXT):
filterText = self.searchFilterCtrl.GetValue()

# Clear selection in the VM so the list has a single canonical source of truth.
self._storeVM.listVM.setSelection(None)
# Also clear any UI selection to avoid accumulating multiple selected rows while filtering.
idx = self.addonListView.GetFirstSelected()
while idx >= 0:
self.addonListView.Select(idx, on=False)
idx = self.addonListView.GetNextSelected(idx)

# When a search is active, reflect search relevance (descending) in the column choice.
if filterText and filterText.strip():
newSortField = AddonListField.searchRank
newReverse = True
if self._storeVM.listVM._sortByModelField != AddonListField.searchRank:
self._storeVM.listVM._prevSortByModelField = self._storeVM.listVM._sortByModelField
self._storeVM.listVM._prevReverseSort = self._storeVM.listVM._reverseSort
else:
# If cleared, revert the choice to the previously active VM sort field.
newSortField = self._storeVM.listVM._prevSortByModelField
newReverse = self._storeVM.listVM._prevReverseSort

newIndex = self._storeVM.listVM.sortableFields.index(newSortField)
newReverseOffset = 1 if newReverse else 0
self.columnFilterCtrl.SetSelection(newIndex * 2 + newReverseOffset)
self._storeVM.listVM.setSortField(newSortField, newReverse)
log.debug(f"filter text changed: {filterText}")

self.filter(filterText)
if self._storeVM.listVM.getCount() > 0:
self._storeVM.listVM.setSelection(0)

def onEnabledFilterChange(self, evt: wx.EVT_CHOICE):
index = self.enabledFilterCtrl.GetCurrentSelection()
Expand Down
108 changes: 80 additions & 28 deletions source/gui/addonStoreGui/viewModels/addonList.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
from dataclasses import dataclass
from enum import Enum

from functools import lru_cache
from locale import strxfrm
from typing import (
Any,
FrozenSet,
Generic,
List,
Optional,
Expand Down Expand Up @@ -50,13 +50,20 @@ def __lt__(self, other: Any) -> bool: ...
class _AddonListFieldData:
displayString: str
width: int
hideStatuses: FrozenSet[_StatusFilterKey] = frozenset()
hideStatuses: frozenset[_StatusFilterKey] = frozenset()
"""Hide this field if the current tab filter is in hideStatuses."""


class AddonListField(_AddonListFieldData, Enum):
"""An ordered enum of fields to use as columns in the add-on list."""

searchRank = (
# Translators: The name of a sorting option for the add-on store to sort by search relevance
pgettext("addonStore", "Search relevance"),
0,
# hide for all statuses, as this is only used for sorting when a search filter is applied.
frozenset(_StatusFilterKey),
)
displayName = (
# Translators: The name of the column that contains names of addons.
pgettext("addonStore", "Name"),
Expand Down Expand Up @@ -211,6 +218,32 @@ def canUseDisableAction(self) -> bool:
)
)

@property
@lru_cache(maxsize=1)
def searchableText(self) -> str:
"""Extract searchable text from addon."""
model = self.model
searchableText = " ".join(
[
model.displayName,
model.description,
model.addonId,
isinstance(model, _AddonStoreModel) and model.publisher or "",
isinstance(model, _AddonManifestModel) and model.author or "",
],
)
return searchableText.casefold()

@lru_cache(maxsize=256)
def searchRank(self, searchTerm: str) -> float:
"""Calculate a search rank for this addon based on the filter trigrams."""
if not searchTerm or len(searchTerm) < 3:
return 1.0 # empty or too short search matches everything
addonSearchableText = self.searchableText
filterTrigrams = AddonListVM._generateTrigrams(searchTerm)
addonTrigrams = AddonListVM._generateTrigrams(addonSearchableText)
return AddonListVM._calculateTrigramSimilarity(filterTrigrams, addonTrigrams)

def __repr__(self) -> str:
return f"{self.__class__.__name__}: {self.Id}, {self.status}"

Expand All @@ -233,9 +266,11 @@ def listItem(self, newListItem: Optional[AddonListItemVM]):


class AddonListVM:
DEFAULT_SORT_FIELD = AddonListField.displayName

def __init__(
self,
addons: List[AddonListItemVM],
addons: list[AddonListItemVM],
storeVM: "AddonStoreVM",
):
self._isLoading: bool = False
Expand All @@ -244,14 +279,16 @@ def __init__(
self.itemUpdated = extensionPoints.Action()
self.updated = extensionPoints.Action()
self.selectionChanged = extensionPoints.Action()
self.selectedAddonId: Optional[str] = None
self.selectedAddonId: str | None = None
self.lastSelectedAddonId = self.selectedAddonId
self._sortByModelField: AddonListField = AddonListField.displayName
self._filterString: Optional[str] = None
self._sortByModelField: AddonListField = self.DEFAULT_SORT_FIELD
self._prevSortByModelField: AddonListField = self._sortByModelField
self._filterString: str | None = None
self._reverseSort: bool = False
self._prevReverseSort: bool = self._reverseSort

self._setSelectionPending = False
self._addonsFilteredOrdered: List[str] = self._getFilteredSortedIds()
self._addonsFilteredOrdered: list[str] = self._getFilteredSortedIds()
self._validate(
sortField=self._sortByModelField,
selectionIndex=self.getSelectedIndex(),
Expand All @@ -261,9 +298,13 @@ def __init__(
self.resetListItems(addons)

@property
def presentedFields(self) -> List[AddonListField]:
def presentedFields(self) -> list[AddonListField]:
return [c for c in AddonListField if self._storeVM._filteredStatusKey not in c.hideStatuses]

@property
def sortableFields(self) -> list[AddonListField]:
return [AddonListField.searchRank] + self.presentedFields

def _itemDataUpdated(self, addonListItemVM: AddonListItemVM):
addonId: str = addonListItemVM.Id
log.debug(f"Item updated: {addonListItemVM!r}")
Expand Down Expand Up @@ -391,7 +432,7 @@ def setSortField(self, modelField: AddonListField, reverse: bool = False):
@property
def _columnSortChoices(self) -> list[str]:
columnChoices = []
for c in self.presentedFields:
for c in self.sortableFields:
columnChoices.append(
pgettext(
"addonStore",
Expand All @@ -414,35 +455,46 @@ def _columnSortChoices(self) -> list[str]:
)
return columnChoices

TRIGRAM_SEARCH_THRESHOLD = 0.3

@staticmethod
@lru_cache(maxsize=256)
def _generateTrigrams(text: str) -> frozenset[str]:
"""Generate character trigrams from text."""
normalized = text.casefold().strip()
trigrams = set()
assert len(normalized) >= 3
for i in range(len(normalized) - 2):
trigrams.add(normalized[i : i + 3])
return frozenset(trigrams)

@staticmethod
def _calculateTrigramSimilarity(searchTrigrams: frozenset[str], textTrigrams: frozenset[str]) -> float:
"""Calculate similarity score between two sets of trigrams."""
if not searchTrigrams:
return 1.0 # Empty search matches everything
matches = len(searchTrigrams & textTrigrams)
return matches / len(searchTrigrams)

def _getFilteredSortedIds(self) -> list[str]:
def _getSortFieldData(listItemVM: AddonListItemVM) -> "_SupportsLessThan":
def _getSortFieldData(listItemVM: AddonListItemVM[_AddonGUIModel]) -> "_SupportsLessThan":
if self._sortByModelField == AddonListField.publicationDate:
if getattr(listItemVM.model, "submissionTime", None):
listItemVM = cast(AddonListItemVM[_AddonStoreModel], listItemVM)
return listItemVM.model.submissionTime
addonStoreListItemVM = cast(AddonListItemVM[_AddonStoreModel], listItemVM)
return addonStoreListItemVM.model.submissionTime
return 0
if self._sortByModelField == AddonListField.installDate:
listItemVM = cast(AddonListItemVM[_AddonManifestModel], listItemVM)
return listItemVM.model.installDate
addonManifestListItemVM = cast(AddonListItemVM[_AddonManifestModel], listItemVM)
return addonManifestListItemVM.model.installDate
if self._sortByModelField == AddonListField.searchRank:
return listItemVM.searchRank(self._filterString or "")
return strxfrm(self._getAddonFieldText(listItemVM, self._sortByModelField))

def _containsTerm(detailsVM: AddonListItemVM, term: str) -> bool:
term = term.casefold()
model = detailsVM.model
inPublisher = isinstance(model, _AddonStoreModel) and term in model.publisher.casefold()
inAuthor = isinstance(model, _AddonManifestModel) and term in model.author.casefold()
return (
term in model.displayName.casefold()
or term in model.description.casefold()
or term in model.addonId.casefold()
or inPublisher
or inAuthor
)

filtered = (
vm
for vm in self._addons.values()
if self._filterString is None or _containsTerm(vm, self._filterString)
if self._filterString is None
or vm.searchRank(self._filterString) >= self.TRIGRAM_SEARCH_THRESHOLD
)
filteredSorted = list(
[vm.Id for vm in sorted(filtered, key=_getSortFieldData, reverse=self._reverseSort)],
Expand Down
Loading