From e42eec2558f6e3735a1f9bd6898f4b0ca43e739c Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Tue, 16 Nov 2021 16:11:14 -0600 Subject: [PATCH] Addon Manager: Begin UI redesign Migrate to a UI that looks more like other software package managers, giving more space to each addon by hiding the list when an addon is selected, and providing a "back" button to get back. Implements a two-style view option for the list of addons: expanded and condensed, via a delegate that provides the drawing function for each row in the table based on two different widget designs. --- src/Mod/AddonManager/AddonManager.py | 649 +++++------------- src/Mod/AddonManager/AddonManager.ui | 346 +++------- src/Mod/AddonManager/AddonManagerRepo.py | 43 +- src/Mod/AddonManager/CMakeLists.txt | 4 + .../AddonManager/Resources/AddonManager.qrc | 2 + .../Resources/icons/compact_view.svg | 15 + .../Resources/icons/expanded_view.svg | 15 + src/Mod/AddonManager/addonmanager_macro.py | 13 + src/Mod/AddonManager/addonmanager_metadata.py | 2 +- src/Mod/AddonManager/addonmanager_workers.py | 250 ++++--- src/Mod/AddonManager/compact_view.py | 84 +++ src/Mod/AddonManager/compact_view.ui | 110 +++ src/Mod/AddonManager/expanded_view.py | 123 ++++ src/Mod/AddonManager/expanded_view.ui | 203 ++++++ src/Mod/AddonManager/package_details.py | 245 +++++++ src/Mod/AddonManager/package_details.ui | 90 +++ src/Mod/AddonManager/package_list.py | 439 ++++++++++++ 17 files changed, 1786 insertions(+), 847 deletions(-) create mode 100644 src/Mod/AddonManager/Resources/icons/compact_view.svg create mode 100644 src/Mod/AddonManager/Resources/icons/expanded_view.svg create mode 100644 src/Mod/AddonManager/compact_view.py create mode 100644 src/Mod/AddonManager/compact_view.ui create mode 100644 src/Mod/AddonManager/expanded_view.py create mode 100644 src/Mod/AddonManager/expanded_view.ui create mode 100644 src/Mod/AddonManager/package_details.py create mode 100644 src/Mod/AddonManager/package_details.ui create mode 100644 src/Mod/AddonManager/package_list.py diff --git a/src/Mod/AddonManager/AddonManager.py b/src/Mod/AddonManager/AddonManager.py index 0420e6413c..07a655ec53 100644 --- a/src/Mod/AddonManager/AddonManager.py +++ b/src/Mod/AddonManager/AddonManager.py @@ -28,7 +28,9 @@ import os import shutil import stat import tempfile +from datetime import date, timedelta from typing import Dict, Union +from enum import Enum from PySide2 import QtGui, QtCore, QtWidgets import FreeCADGui @@ -37,6 +39,8 @@ from addonmanager_utilities import translate # this needs to be as is for pylup from addonmanager_workers import * import addonmanager_utilities as utils import AddonManager_rc +from package_list import PackageList, PackageListItemModel +from package_details import PackageDetails from AddonManagerRepo import AddonManagerRepo __title__ = "FreeCAD Addon Manager Module" @@ -113,79 +117,77 @@ class CommandAddonManager: "AddonManager.ui")) # cleanup the leftovers from previous runs - self.macro_repo_dir = tempfile.mkdtemp() + self.macro_repo_dir = FreeCAD.getUserMacroDir() self.packages_with_updates = [] self.startup_sequence = [] self.addon_removed = False self.cleanup_workers() - # restore window geometry and splitter state from stored state + # restore window geometry from stored state pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") - w = pref.GetInt("WindowWidth", 600) - h = pref.GetInt("WindowHeight", 480) + w = pref.GetInt("WindowWidth", 800) + h = pref.GetInt("WindowHeight", 600) self.dialog.resize(w, h) - sl = pref.GetInt("SplitterLeft", 298) - sr = pref.GetInt("SplitterRight", 274) - self.dialog.splitter.setSizes([sl, sr]) + + # figure out our cache update frequency: + # -1: Only manual updates (default) + # 0: Update every launch + # >0: Update every n days + self.update_cache = False + days_between_updates = pref.GetInt("DaysBetweenUpdates", -1) + last_cache_update_string = pref.GetString("LastCacheUpdate", "never") + cache_path = FreeCAD.getUserCachePath() + am_path = os.path.join(cache_path,"AddonManager") + if last_cache_update_string == "never": + self.update_cache = True + elif days_between_updates > 0: + last_cache_update = date.fromisoformat(last_cache_update_string) + delta_update = timedelta(days=days_between_updates) + if date.today() >= last_cache_update + delta_update: + self.update_cache = True + elif days_between_updates == 0: + self.update_cache = True + elif not os.path.isdir(am_path): + self.update_cache = True # Set up the listing of packages using the model-view-controller architecture + self.packageList = PackageList(self.dialog) self.item_model = PackageListItemModel() - self.item_delegate = PackageListIconDelegate() - self.item_filter = PackageListFilter() - self.item_filter.setSourceModel(self.item_model) - self.dialog.tablePackages.setModel (self.item_filter) - self.dialog.tablePackages.setItemDelegate(self.item_delegate) - header = self.dialog.tablePackages.horizontalHeader() - header.setSectionResizeMode(0, QtWidgets.QHeaderView.Fixed) - header.resizeSection(0,20) - header.setSectionResizeMode(1, QtWidgets.QHeaderView.Stretch) - header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents) - self.dialog.tablePackages.sortByColumn(1, QtCore.Qt.AscendingOrder) # Default to sorting alphabetically by name + self.packageList.setModel(self.item_model) + self.dialog.contentPlaceholder.hide() + self.dialog.layout().replaceWidget(self.dialog.contentPlaceholder, self.packageList) + self.packageList.show() + + # Package details start out hidden + self.packageDetails = PackageDetails(self.dialog) + self.packageDetails.hide() + index = self.dialog.layout().indexOf(self.packageList) + self.dialog.layout().insertWidget(index, self.packageDetails) # set nice icons to everything, by theme with fallback to FreeCAD icons self.dialog.setWindowIcon(QtGui.QIcon(":/icons/AddonManager.svg")) - self.dialog.buttonUninstall.setIcon(QtGui.QIcon.fromTheme("cancel", QtGui.QIcon(":/icons/edit_Cancel.svg"))) - self.dialog.buttonInstall.setIcon(QtGui.QIcon.fromTheme("download", QtGui.QIcon(":/icons/edit_OK.svg"))) self.dialog.buttonUpdateAll.setIcon(QtGui.QIcon(":/icons/button_valid.svg")) - self.dialog.buttonConfigure.setIcon(QtGui.QIcon(":/icons/preferences-system.svg")) self.dialog.buttonClose.setIcon(QtGui.QIcon.fromTheme("close", QtGui.QIcon(":/icons/process-stop.svg"))) + self.dialog.buttonPauseUpdate.setIcon(QtGui.QIcon.fromTheme("pause", QtGui.QIcon(":/icons/media-playback-stop.svg"))) # enable/disable stuff - self.dialog.buttonUninstall.setEnabled(False) - self.dialog.buttonInstall.setEnabled(False) self.dialog.buttonUpdateAll.setEnabled(False) - self.dialog.buttonExecute.hide() - self.dialog.labelFilterValidity.hide() - - # Hide package-related interface elements until needed - self.dialog.labelPackageName.hide() - self.dialog.labelVersion.hide() - self.dialog.labelMaintainer.hide() - self.dialog.labelIcon.hide() - self.dialog.labelDescription.hide() - self.dialog.labelUrl.hide() - self.dialog.labelUrlType.hide() - self.dialog.labelContents.hide() + self.hide_progress_widgets() # connect slots self.dialog.rejected.connect(self.reject) - self.dialog.buttonInstall.clicked.connect(self.install) - self.dialog.buttonUninstall.clicked.connect(self.remove) self.dialog.buttonUpdateAll.clicked.connect(self.update_all) - self.dialog.comboPackageType.currentIndexChanged.connect(self.update_type_filter) - self.dialog.lineEditFilter.textChanged.connect(self.update_text_filter) - self.dialog.buttonConfigure.clicked.connect(self.show_config) self.dialog.buttonClose.clicked.connect(self.dialog.reject) - self.dialog.buttonExecute.clicked.connect(self.executemacro) - self.dialog.tablePackages.selectionModel().currentRowChanged.connect(self.table_row_selected) - self.dialog.tablePackages.setEnabled(False) - - # Show "Workbenches" to start with - self.dialog.comboPackageType.setCurrentIndex(1) - - # allow links to open in browser - self.dialog.description.setOpenLinks(True) - self.dialog.description.setOpenExternalLinks(True) + self.dialog.buttonUpdateCache.clicked.connect(self.on_buttonUpdateCache_clicked) + self.dialog.buttonShowDetails.clicked.connect(self.toggle_details) + self.dialog.buttonPauseUpdate.clicked.connect(self.stop_update) + self.packageList.itemSelected.connect(self.table_row_activated) + self.packageList.setEnabled(False) + self.packageDetails.executeClicked.connect(self.executemacro) + self.packageDetails.installClicked.connect(self.install) + self.packageDetails.uninstallClicked.connect(self.remove) + self.packageDetails.updateClicked.connect(self.remove) + self.packageDetails.backClicked.connect(self.on_buttonBack_clicked) # center the dialog over the FreeCAD window mw = FreeCADGui.getMainWindow() @@ -224,12 +226,10 @@ class CommandAddonManager: def reject(self) -> None: """called when the window has been closed""" - # save window geometry and splitter state for next use + # save window geometry for next use pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") pref.SetInt("WindowWidth", self.dialog.width()) pref.SetInt("WindowHeight", self.dialog.height()) - pref.SetInt("SplitterLeft", self.dialog.splitter.sizes()[0]) - pref.SetInt("SplitterRight", self.dialog.splitter.sizes()[1]) # ensure all threads are finished before closing oktoclose = True @@ -269,13 +269,8 @@ class CommandAddonManager: cancelBtn.setText(translate("AddonsInstaller","Restart later")) ret = m.exec_() if ret == m.Ok: - shutil.rmtree(self.macro_repo_dir, onerror=self.remove_readonly) # restart FreeCAD after a delay to give time to this dialog to close QtCore.QTimer.singleShot(1000, utils.restart_freecad) - try: - shutil.rmtree(self.macro_repo_dir, onerror=self.remove_readonly) - except Exception: - pass else: FreeCAD.Console.PrintWarning("Could not terminate sub-threads in Addon Manager.\n") self.cleanup_workers() @@ -299,11 +294,15 @@ class CommandAddonManager: Each of these stages is launched in a separate thread to ensure that the UI remains responsive, and the operation can be cancelled. + Each stage is also subject to caching, so may return immediately, if no cache update has been requested. + """ # Each function in this list is expected to launch a thread and connect its completion signal - # to self.do_next_startup_phase + # to self.do_next_startup_phase, or to shortcut to calling self.do_next_startup_phase if it + # is not launching a worker self.startup_sequence = [self.populate_packages_table, + self.activate_table_widgets, self.populate_macros, self.update_metadata_cache, self.check_updates] @@ -320,32 +319,81 @@ class CommandAddonManager: phase_runner() else: self.hide_progress_widgets() - self.dialog.tablePackages.setEnabled(True) - self.dialog.lineEditFilter.setFocus() + self.update_cache = False + pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") + pref.SetString("LastCacheUpdate", date.today().isoformat()) + + def get_cache_file_name(self, file:str) -> str: + cache_path = FreeCAD.getUserCachePath() + am_path = os.path.join(cache_path,"AddonManager") + os.makedirs(am_path,exist_ok=True) + return os.path.join(am_path,file) def populate_packages_table(self) -> None: self.item_model.clear() self.current_progress_region += 1 - self.update_worker = UpdateWorker() - self.update_worker.status_message.connect(self.show_information) - self.update_worker.addon_repo.connect(self.add_addon_repo) - self.update_progress_bar(10,100) - self.update_worker.done.connect(self.do_next_startup_phase) # Link to step 2 - self.update_worker.start() + if self.update_cache or not os.path.isfile(self.get_cache_file_name("package_cache.json")): + self.update_cache = True # Make sure to trigger the other cache updates, if the json file was missing + self.update_worker = UpdateWorker() + self.update_worker.status_message.connect(self.show_information) + self.update_worker.addon_repo.connect(self.add_addon_repo) + self.update_worker.addon_repo.connect(self.cache_package) + self.update_progress_bar(10,100) + self.update_worker.done.connect(self.do_next_startup_phase) # Link to step 2 + self.update_worker.done.connect(self.write_package_cache) + self.update_worker.start() + else: + self.update_worker = LoadPackagesFromCacheWorker(self.get_cache_file_name("package_cache.json")) + self.update_worker.addon_repo.connect(self.add_addon_repo) + self.update_progress_bar(10,100) + self.update_worker.done.connect(self.do_next_startup_phase) # Link to step 2 + self.update_worker.start() + + def cache_package(self, repo:AddonManagerRepo): + if not hasattr(self, "package_cache"): + self.package_cache = [] + self.package_cache.append(repo.to_cache()) + + def write_package_cache(self): + package_cache_path = self.get_cache_file_name("package_cache.json") + with open(package_cache_path,"w") as f: + f.write(json.dumps(self.package_cache)) + + def activate_table_widgets(self) -> None: + self.packageList.setEnabled(True) + self.packageList.ui.lineEditFilter.setFocus() + self.do_next_startup_phase() def populate_macros(self) -> None: self.current_progress_region += 1 - self.macro_worker = FillMacroListWorker(self.macro_repo_dir) - self.macro_worker.status_message_signal.connect(self.show_information) - self.macro_worker.progress_made.connect(self.update_progress_bar) - self.macro_worker.add_macro_signal.connect(self.add_addon_repo) - self.macro_worker.done.connect(self.do_next_startup_phase) # Link to step 3 - self.macro_worker.start() + if self.update_cache or not os.path.isfile(self.get_cache_file_name("macro_cache.json")): + self.macro_worker = FillMacroListWorker(self.get_cache_file_name("Macros")) + self.macro_worker.status_message_signal.connect(self.show_information) + self.macro_worker.progress_made.connect(self.update_progress_bar) + self.macro_worker.add_macro_signal.connect(self.add_addon_repo) + self.macro_worker.add_macro_signal.connect(self.cache_macro) + self.macro_worker.done.connect(self.do_next_startup_phase) # Link to step 3 + self.macro_worker.done.connect(self.write_macro_cache) + self.macro_worker.start() + else: + self.macro_worker = LoadMacrosFromCacheWorker(self.get_cache_file_name("macro_cache.json")) + self.macro_worker.add_macro_signal.connect(self.add_addon_repo) + self.macro_worker.done.connect(self.do_next_startup_phase) # Link to step 3 + self.macro_worker.start() + + def cache_macro(self, macro:AddonManagerRepo): + if not hasattr(self, "macro_cache"): + self.macro_cache = [] + self.macro_cache.append(macro.macro.to_cache()) + + def write_macro_cache(self): + macro_cache_path = self.get_cache_file_name("macro_cache.json") + with open(macro_cache_path,"w") as f: + f.write(json.dumps(self.macro_cache)) def update_metadata_cache(self) -> None: self.current_progress_region += 1 - pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") - if pref.GetBool("AutoFetchMetadata", True): + if self.update_cache: self.update_metadata_cache_worker = UpdateMetadataCacheWorker(self.item_model.repos) self.update_metadata_cache_worker.status_message.connect(self.show_information) self.update_metadata_cache_worker.done.connect(self.do_next_startup_phase) # Link to step 4 @@ -353,18 +401,21 @@ class CommandAddonManager: self.update_metadata_cache_worker.package_updated.connect(self.on_package_updated) self.update_metadata_cache_worker.start() else: - self.do_next_startup_phase() + self.update_metadata_cache_worker = LoadMetadataFromCacheWorker() + self.update_metadata_cache_worker.done.connect(self.do_next_startup_phase) # Link to step 4 + self.update_metadata_cache_worker.package_updated.connect(self.on_package_updated) + self.update_metadata_cache_worker.start() + + def on_buttonUpdateCache_clicked(self) -> None: + self.update_cache = True + self.startup() def on_package_updated(self, repo:AddonManagerRepo) -> None: """Called when the named package has either new metadata or a new icon (or both)""" - + with self.lock: - cache_path = os.path.join(FreeCAD.getUserAppDataDir(), "AddonManager", "PackageMetadata", repo.name) - icon_filename = repo.metadata.Icon - icon_path = os.path.join(cache_path, icon_filename) - if os.path.isfile(icon_path): - addonicon = QtGui.QIcon(icon_path) - repo.icon = addonicon + cache_path = os.path.join(FreeCAD.getUserCachePath(), "AddonManager", "PackageMetadata", repo.name) + repo.icon = self.get_icon(repo, update=True) self.item_model.reload_item(repo) @@ -424,7 +475,7 @@ class CommandAddonManager: def get_icon(self, repo:AddonManagerRepo, update:bool=False) -> QtGui.QIcon: """returns an icon for a repo""" - if not update and repo.icon and not repo.icon.isNull(): + if not update and repo.icon and not repo.icon.isNull() and repo.icon.isValid(): return repo.icon path = ":/icons/" + repo.name.replace(" ", "_") @@ -436,8 +487,8 @@ class CommandAddonManager: default_icon = QtGui.QIcon(":/icons/document-python.svg") elif repo.repo_type == AddonManagerRepo.RepoType.PACKAGE: # The cache might not have been downloaded yet, check to see if it's there... - if repo.cached_icon_filename and os.path.isfile(repo.cached_icon_filename): - path = repo.cached_icon_filename + if os.path.isfile(repo.get_cached_icon_filename()): + path = repo.get_cached_icon_filename() elif repo.contains_workbench(): path += "_workbench_icon.svg" default_icon = QtGui.QIcon(":/icons/document-package.svg") @@ -455,168 +506,26 @@ class CommandAddonManager: return addonicon - def table_row_selected(self, current:QtCore.QModelIndex, previous:QtCore.QModelIndex) -> None: - """a new row was selected, show the relevant data""" - - if not current.isValid(): - self.selected_repo = None - return - source_selection = self.item_filter.mapToSource (current) - self.selected_repo = self.item_model.repos[source_selection.row()] - self.dialog.description.clear() - if self.selected_repo.repo_type == AddonManagerRepo.RepoType.MACRO: - self.show_macro(self.selected_repo) - self.dialog.buttonExecute.show() - elif self.selected_repo.repo_type == AddonManagerRepo.RepoType.WORKBENCH: - self.show_workbench(self.selected_repo) - self.dialog.buttonExecute.hide() - elif self.selected_repo.repo_type == AddonManagerRepo.RepoType.PACKAGE: - self.show_package(self.selected_repo) - self.dialog.buttonExecute.hide() - if self.selected_repo.update_status == AddonManagerRepo.UpdateStatus.NOT_INSTALLED: - self.dialog.buttonInstall.setEnabled(True) - self.dialog.buttonUninstall.setEnabled(False) - self.dialog.buttonInstall.setText(translate("AddonsInstaller", "Install selected")) - elif self.selected_repo.update_status == AddonManagerRepo.UpdateStatus.NO_UPDATE_AVAILABLE: - self.dialog.buttonInstall.setEnabled(False) - self.dialog.buttonUninstall.setEnabled(True) - self.dialog.buttonInstall.setText(translate("AddonsInstaller", "Already updated")) - elif self.selected_repo.update_status == AddonManagerRepo.UpdateStatus.UPDATE_AVAILABLE: - self.dialog.buttonInstall.setEnabled(True) - self.dialog.buttonUninstall.setEnabled(True) - self.dialog.buttonInstall.setText(translate("AddonsInstaller", "Update selected")) - elif self.selected_repo.update_status == AddonManagerRepo.UpdateStatus.UNCHECKED: - self.dialog.buttonInstall.setEnabled(False) - self.dialog.buttonUninstall.setEnabled(True) - self.dialog.buttonInstall.setText(translate("AddonsInstaller", "Checking status...")) + def table_row_activated(self, selected_repo:AddonManagerRepo) -> None: + """a row was activated, show the relevant data""" - def show_package_widgets(self, show:bool) -> None: - """ Show or hide the widgets related to packages with a package.xml metadata file """ - - # Always rebuild the urlGrid, remove all previous items - for i in reversed(range(self.dialog.urlGrid.rowCount())): - if self.dialog.urlGrid.itemAtPosition(i,0): - self.dialog.urlGrid.itemAtPosition(i,0).widget().setParent(None) - if self.dialog.urlGrid.itemAtPosition(i,1): - self.dialog.urlGrid.itemAtPosition(i,1).widget().setParent(None) - - if show: - # Show all the package-related widgets: - self.dialog.labelPackageName.show() - self.dialog.labelVersion.show() - self.dialog.labelMaintainer.show() - self.dialog.labelIcon.show() - self.dialog.labelDescription.show() - self.dialog.labelContents.show() - else: - # Hide all the package-related widgets: - self.dialog.labelPackageName.hide() - self.dialog.labelVersion.hide() - self.dialog.labelMaintainer.hide() - self.dialog.labelIcon.hide() - self.dialog.labelDescription.hide() - self.dialog.labelContents.hide() + self.packageList.hide() + self.packageDetails.show() + self.packageDetails.show_repo(selected_repo) def show_information(self, message:str) -> None: - """shows generic text in the information pane""" + """shows generic text in the information pane (which might be collapsed)""" - self.dialog.labelStatusInfo.show() self.dialog.labelStatusInfo.setText(message) def show_workbench(self, repo:AddonManagerRepo) -> None: - """loads information of a given workbench""" + self.packageList.hide() + self.packageDetails.show() + self.packageDetails.show_repo(repo) - self.cleanup_workers() - self.show_package_widgets(False) - self.show_worker = ShowWorker(repo) - self.show_worker.status_message.connect(self.show_information) - self.show_worker.description_updated.connect(lambda desc: self.dialog.description.setText(desc)) - self.show_worker.addon_repos.connect(self.append_to_repos_list) - self.show_worker.done.connect(lambda : self.dialog.labelStatusInfo.hide()) - self.show_worker.start() - - def show_package(self, repo:AddonManagerRepo) -> None: - """ Show the details for a package (a repo with a package.xml metadata file) """ - - self.cleanup_workers() - self.show_package_widgets(True) - - # Name - self.dialog.labelPackageName.setText(f"

{repo.metadata.Name}

") - - # Description - self.dialog.labelDescription.setText(repo.metadata.Description) - - # Version - self.dialog.labelVersion.setText(f"

v{repo.metadata.Version}

") - - # Maintainers and authors - maintainers = "" - for maintainer in repo.metadata.Maintainer: - maintainers += translate("AddonsInstaller","Maintainer") + f": {maintainer['name']} <{maintainer['email']}>\n" - if len(repo.metadata.Author) > 0: - for author in repo.metadata.Author: - maintainers += translate("AddonsInstaller","Author") + f": {author['name']} <{author['email']}>\n" - self.dialog.labelMaintainer.setText(maintainers) - - # Main package icon - if not repo.icon or repo.icon.isNull(): - icon = self.get_icon(repo, update=True) - self.item_model.update_item_icon(repo.name, icon) - self.dialog.labelIcon.setPixmap(repo.icon.pixmap(QtCore.QSize(64,64))) - - # Urls - urls = repo.metadata.Urls - ui = FreeCADGui.UiLoader() - for row, url in enumerate(urls): - location = url["location"] - url_type = url["type"] - url_type_string = translate("AddonsInstaller","Other URL") - if url_type == "website": - url_type_string = translate("AddonsInstaller", "Website") - elif url_type == "repository": - url_type_string = translate("AddonsInstaller", "Repository") - elif url_type == "bugtracker": - url_type_string = translate("AddonsInstaller", "Bug tracker") - elif url_type == "readme": - url_type_string = translate("AddonsInstaller", "Readme") - elif url_type == "documentation": - url_type_string = translate("AddonsInstaller", "Documentation") - self.dialog.urlGrid.addWidget(QtWidgets.QLabel(url_type_string), row, 0) - ui=FreeCADGui.UiLoader() - url_label=ui.createWidget("Gui::UrlLabel") - url_label.setText(location) - url_label.setUrl(location) - self.dialog.urlGrid.addWidget(url_label, row, 1) - - # Package contents: - content_string = "" - for name,item_list in repo.metadata.Content.items(): - if name == "preferencepack": - content_type = translate("AddonsInstaller","Preference Packs") - elif name == "workbench": - content_type = translate("AddonsInstaller","Workbenches") - elif name == "macro": - content_type = translate("AddonsInstaller","Macros") - else: - content_type = translate("AddonsInstaller","Other content") + ": " + name - content_string += f"

{content_type}

" - content_string += "" - self.dialog.description.setText(content_string) - - def show_macro(self, repo:AddonManagerRepo) -> None: - """loads information of a given macro""" - - self.cleanup_workers() - self.show_package_widgets(False) - self.showmacro_worker = GetMacroDetailsWorker(repo) - self.showmacro_worker.status_message.connect(self.show_information) - self.showmacro_worker.description_updated.connect(lambda desc: self.dialog.description.setText(desc)) - self.showmacro_worker.done.connect(lambda : self.dialog.labelStatusInfo.hide()) - self.showmacro_worker.start() + def on_buttonBack_clicked(self) -> None: + self.packageDetails.hide() + self.packageList.show() def append_to_repos_list(self, repo:AddonManagerRepo) -> None: """this function allows threads to update the main list of workbenches""" @@ -721,23 +630,47 @@ class CommandAddonManager: self.dialog.labelStatusInfo.hide() self.dialog.progressBar.hide() - self.dialog.lineEditFilter.setFocus() + self.dialog.buttonPauseUpdate.hide() + self.dialog.buttonShowDetails.hide() + self.dialog.labelUpdateInProgress.hide() + self.packageList.ui.lineEditFilter.setFocus() + + def show_progress_widgets(self) -> None: + if self.dialog.progressBar.isHidden(): + self.dialog.progressBar.show() + self.dialog.buttonPauseUpdate.show() + self.dialog.buttonShowDetails.show() + self.dialog.labelStatusInfo.hide() + self.dialog.buttonShowDetails.setArrowType(QtCore.Qt.RightArrow) + self.dialog.labelUpdateInProgress.show() def update_progress_bar(self, current_value:int, max_value:int) -> None: """ Update the progress bar, showing it if it's hidden """ - self.dialog.progressBar.show() + self.show_progress_widgets() region_size = 100 / self.number_of_progress_regions value = (self.current_progress_region-1)*region_size + (current_value / max_value / self.number_of_progress_regions)*region_size self.dialog.progressBar.setValue(value) + def toggle_details(self) -> None: + if self.dialog.labelStatusInfo.isHidden(): + self.dialog.labelStatusInfo.show() + self.dialog.buttonShowDetails.setArrowType(QtCore.Qt.DownArrow) + else: + self.dialog.labelStatusInfo.hide() + self.dialog.buttonShowDetails.setArrowType(QtCore.Qt.RightArrow) + + def stop_update(self)-> None: + self.cleanup_workers() + self.hide_progress_widgets() + def on_package_installed(self, repo:AddonManagerRepo, message:str) -> None: QtWidgets.QMessageBox.information(None, translate("AddonsInstaller", "Installation succeeded"), message, QtWidgets.QMessageBox.Close) self.dialog.progressBar.hide() - self.table_row_selected(self.dialog.tablePackages.selectionModel().selectedIndexes()[0], QtCore.QModelIndex()) + self.table_row_selected(self.dialog.listPackages.selectionModel().selectedIndexes()[0], QtCore.QModelIndex()) if repo.contains_workbench(): self.item_model.update_item_status(repo.name, AddonManagerRepo.UpdateStatus.PENDING_RESTART) else: @@ -791,20 +724,20 @@ class CommandAddonManager: clonedir = moddir + os.sep + self.selected_repo.name if os.path.exists(clonedir): shutil.rmtree(clonedir, onerror=self.remove_readonly) - self.dialog.description.setText(translate("AddonsInstaller", + self.dialog.textBrowserReadMe.setText(translate("AddonsInstaller", "Addon successfully removed. Please restart FreeCAD.")) self.item_model.update_item_status(self.selected_repo.name, AddonManagerRepo.UpdateStatus.NOT_INSTALLED) self.addon_removed = True # A value to trigger the restart message on dialog close else: - self.dialog.description.setText(translate("AddonsInstaller", "Unable to remove this addon with the Addon Manager.")) + self.dialog.textBrowserReadMe.setText(translate("AddonsInstaller", "Unable to remove this addon with the Addon Manager.")) elif self.selected_repo.repo_type == AddonManagerRepo.RepoType.MACRO: macro = self.selected_repo.macro if macro.remove(): - self.dialog.description.setText(translate("AddonsInstaller", "Macro successfully removed.")) + self.dialog.textBrowserReadMe.setText(translate("AddonsInstaller", "Macro successfully removed.")) self.item_model.update_item_status(self.selected_repo.name, AddonManagerRepo.UpdateStatus.NOT_INSTALLED) else: - self.dialog.description.setText(translate("AddonsInstaller", "Macro could not be removed.")) + self.dialog.textBrowserReadMe.setText(translate("AddonsInstaller", "Macro could not be removed.")) def show_config(self) -> None: """shows the configuration dialog""" @@ -836,226 +769,4 @@ class CommandAddonManager: pref.SetBool("UserProxyCheck", self.config.radioButtonUserProxy.isChecked()) pref.SetString("ProxyUrl", self.config.userProxy.toPlainText()) - - - def update_type_filter(self, type_filter:int) -> None: - """hide/show rows corresponding to the type filter - - type_filter is an integer: 0 for all, 1 for workbenches, 2 for macros, and 3 for preference packs - - """ - self.item_filter.setPackageFilter(type_filter) - - - def update_text_filter(self, text_filter:str) -> None: - """filter name and description by the regex specified by text_filter""" - - if text_filter: - test_regex = QtCore.QRegularExpression(text_filter) - if test_regex.isValid(): - self.dialog.labelFilterValidity.setToolTip(translate("AddonsInstaller","Filter is valid")) - icon = QtGui.QIcon.fromTheme("ok", QtGui.QIcon(":/icons/edit_OK.svg")) - self.dialog.labelFilterValidity.setPixmap(icon.pixmap(16,16)) - else: - self.dialog.labelFilterValidity.setToolTip(translate("AddonsInstaller","Filter regular expression is invalid")) - icon = QtGui.QIcon.fromTheme("cancel", QtGui.QIcon(":/icons/edit_Cancel.svg")) - self.dialog.labelFilterValidity.setPixmap(icon.pixmap(16,16)) - self.dialog.labelFilterValidity.show() - else: - self.dialog.labelFilterValidity.hide() - self.item_filter.setFilterRegularExpression(text_filter) - - -class PackageListItemModel(QtCore.QAbstractTableModel): - - repos = [] - write_lock = threading.Lock() - - DataAccessRole = QtCore.Qt.UserRole - StatusUpdateRole = QtCore.Qt.UserRole + 1 - IconUpdateRole = QtCore.Qt.UserRole + 2 - - def __init__(self) -> None: - QtCore.QAbstractTableModel.__init__(self) - - def rowCount(self, parent:QtCore.QModelIndex=QtCore.QModelIndex()) -> int: - if parent.isValid(): - return 0 - return len(self.repos) - - def columnCount(self, parent:QtCore.QModelIndex=QtCore.QModelIndex()) -> int: - if parent.isValid(): - return 0 - return 3 # Icon, Name, Status - - def data(self, index:QtCore.QModelIndex, role:int=QtCore.Qt.DisplayRole) -> Union[QtGui.QIcon,str]: - if not index.isValid(): - return None - row = index.row() - column = index.column() - if role == QtCore.Qt.DisplayRole: - if row >= len(self.repos): - return None - if column == 1: - return self.repos[row].name if self.repos[row].metadata is None else self.repos[row].metadata.Name - elif column == 2: - if self.repos[row].update_status == AddonManagerRepo.UpdateStatus.UNCHECKED: - return translate("AddonsInstaller","Installed") - elif self.repos[row].update_status == AddonManagerRepo.UpdateStatus.NO_UPDATE_AVAILABLE: - return translate("AddonsInstaller","Up-to-date") - elif self.repos[row].update_status == AddonManagerRepo.UpdateStatus.UPDATE_AVAILABLE: - return translate("AddonsInstaller","Update available") - elif self.repos[row].update_status == AddonManagerRepo.UpdateStatus.PENDING_RESTART: - return translate("AddonsInstaller","Restart required") - else: - return None - else: - return None - elif role == QtCore.Qt.DecorationRole: - if column == 0: - return self.repos[row].icon - elif role == QtCore.Qt.ToolTipRole: - tooltip = "" - if self.repos[row].repo_type == AddonManagerRepo.RepoType.PACKAGE: - tooltip = f"Package '{self.repos[row].name}'" - # TODO add more info from Metadata - elif self.repos[row].repo_type == AddonManagerRepo.RepoType.WORKBENCH: - tooltip = f"Workbench '{self.repos[row].name}'" - elif self.repos[row].repo_type == AddonManagerRepo.RepoType.MACRO: - tooltip = f"Macro '{self.repos[row].name}'" - return tooltip - elif role == QtCore.Qt.TextAlignmentRole: - return QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop - elif role == PackageListItemModel.DataAccessRole: - return self.repos[row] - - def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole): - if role == QtCore.Qt.DisplayRole: - if orientation == QtCore.Qt.Horizontal: - if section == 0: - return None - elif section == 1: - return translate("AddonsInstaller", "Name") - elif section == 2: - return translate("AddonsInstaller", "Status") - else: - return None - - def setData(self, index:QtCore.QModelIndex, value, role=QtCore.Qt.EditRole) -> None: - """ Set the data for this row. The column of the index is ignored. """ - - row = index.row() - self.write_lock.acquire() - if role == PackageListItemModel.StatusUpdateRole: - self.repos[row].update_status = value - self.dataChanged.emit(self.index(row,2), self.index(row,2), [PackageListItemModel.StatusUpdateRole]) - elif role == PackageListItemModel.IconUpdateRole: - self.repos[row].icon = value - self.dataChanged.emit(self.index(row,0), self.index(row,0), [PackageListItemModel.IconUpdateRole]) - self.write_lock.release() - - def append_item(self, repo:AddonManagerRepo) -> None: - if repo in self.repos: - # Cowardly refuse to insert the same repo a second time - return - self.write_lock.acquire() - self.beginInsertRows(QtCore.QModelIndex(), self.rowCount(), self.rowCount()) - self.repos.append(repo) - self.endInsertRows() - self.write_lock.release() - - def clear(self) -> None: - if self.rowCount() > 0: - self.write_lock.acquire() - self.beginRemoveRows(QtCore.QModelIndex(), 0, self.rowCount()-1) - self.repos = [] - self.endRemoveRows() - self.write_lock.release() - - def update_item_status(self, name:str, status:AddonManagerRepo.UpdateStatus) -> None: - for row,item in enumerate(self.repos): - if item.name == name: - self.setData(self.index(row,0), status, PackageListItemModel.StatusUpdateRole) - return - - def update_item_icon(self, name:str, icon:QtGui.QIcon) -> None: - for row,item in enumerate(self.repos): - if item.name == name: - self.setData(self.index(row,0), icon, PackageListItemModel.IconUpdateRole) - return - - def reload_item(self,repo:AddonManagerRepo) -> None: - for index,item in enumerate(self.repos): - if item.name == repo.name: - self.write_lock.acquire() - self.repos[index] = repo - self.write_lock.release() - return - - -class PackageListIconDelegate(QtWidgets.QStyledItemDelegate): - """ A delegate to ensure proper alignment of the icon in the table cells """ - - def paint(self, painter, option, index): - if (index.column() == 0): - option.decorationAlignment = QtCore.Qt.AlignTop | QtCore.Qt.AlignHCenter - super().paint(painter, option, index) - - -class PackageListFilter(QtCore.QSortFilterProxyModel): - """ Handle filtering the item list on various criteria """ - - def __init__(self): - super().__init__() - self.package_type = 0 # Default to showing everything - self.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) - - def setPackageFilter(self, type:int) -> None: # 0=All, 1=Workbenches, 2=Macros, 3=Preference Packs - self.package_type = type - self.invalidateFilter() - - def lessThan(self, left, right) -> bool: - l = self.sourceModel().data(left,PackageListItemModel.DataAccessRole) - r = self.sourceModel().data(right,PackageListItemModel.DataAccessRole) - - if left.column() == 0: # Icon - return False - elif left.column() == 1: # Name - lname = l.name if l.metadata is None else l.metadata.Name - rname = r.name if r.metadata is None else r.metadata.Name - return lname.lower() < rname.lower() - elif left.column() == 2: # Status - return l.update_status < r.update_status - - def filterAcceptsRow(self, row, parent=QtCore.QModelIndex()): - index = self.sourceModel().createIndex(row, 0) - data = self.sourceModel().data(index,PackageListItemModel.DataAccessRole) - if self.package_type == 1: - if not data.contains_workbench(): - return False - elif self.package_type == 2: - if not data.contains_macro(): - return False - elif self.package_type == 3: - if not data.contains_preference_pack(): - return False - - name = data.name if data.metadata is None else data.metadata.Name - desc = data.description if not data.metadata else data.metadata.Description - re = self.filterRegularExpression() - if re.isValid(): - re.setPatternOptions(QtCore.QRegularExpression.CaseInsensitiveOption) - if re.match(name).hasMatch(): - return True - if re.match(desc).hasMatch(): - return True - return False - else: - return False - - def sort(self, column, order): - if column == 0: # Icons - return - else: - super().sort(column, order) # @} diff --git a/src/Mod/AddonManager/AddonManager.ui b/src/Mod/AddonManager/AddonManager.ui index 5c3e08a1f2..13fff38d6f 100644 --- a/src/Mod/AddonManager/AddonManager.ui +++ b/src/Mod/AddonManager/AddonManager.ui @@ -6,8 +6,8 @@ 0 0 - 599 - 480 + 800 + 600 @@ -15,282 +15,98 @@ - + 0 0 - - Qt::Horizontal - - - + + + + + + - - - - - Show packages containing: - - - - - - - - All - - - - - Workbenches - - - - - Macros - - - - - Preference Packs - - - - - + + + Show details + + + ... + + + Qt::RightArrow + + - - - - - Filter - - - true - - - - - - - OK - - - - + + + Loading... + + - - - QAbstractItemView::NoEditTriggers + + + + 0 + 0 + - - false - - - QAbstractItemView::SingleSelection - - - QAbstractItemView::SelectRows - - + - 16 - 16 + 0 + 0 - + + + 16777215 + 12 + + + + + 0 + 0 + + + + 0 + + false - - true - - - false - - - 16 - - - false - - - 12 - - - 16 - - - false - - - - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Sets configuration options for the Addon Manager - - - Configure... - - - - - - - - - - - - 0 - 0 - - - - - 64 - 64 - - - - - 64 - 64 - - - - Icon - - - - - - - 0 - - - QLayout::SetDefaultConstraint - - - - - <h1>Package Name</h1> - - - - - - - <em>Version</em> - - - - - - - Maintainer - - - - - - - - - - - Description - - - Qt::PlainText - - - true + + Downloading info... - - - - - URL Type - - - - - - - Url - - - - - - - - - Package contents: + + + Pause cache update - - - - - - - - Run Macro + - - - - - - - labelStatusInfo - - - - - + - - - 24 + + + + 0 + 0 + - - false - - - Downloading info... + + labelStatusInfo @@ -302,22 +118,9 @@ QLayout::SetDefaultConstraint - - - Uninstalls a selected macro or workbench - + - Uninstall selected - - - - - - - Installs or updates the selected macro or workbench - - - Install/update selected + Refresh local cache @@ -327,10 +130,23 @@ Download and apply all available updates - Update all + Update all packages + + + + Qt::Horizontal + + + + 40 + 20 + + + + diff --git a/src/Mod/AddonManager/AddonManagerRepo.py b/src/Mod/AddonManager/AddonManagerRepo.py index 79a22e130c..399cb09093 100644 --- a/src/Mod/AddonManager/AddonManagerRepo.py +++ b/src/Mod/AddonManager/AddonManagerRepo.py @@ -23,15 +23,16 @@ import FreeCAD import os +from typing import Dict from addonmanager_macro import Macro class AddonManagerRepo: "Encapsulate information about a FreeCAD addon" - from enum import Enum + from enum import IntEnum - class RepoType(Enum): + class RepoType(IntEnum): WORKBENCH = 1 MACRO = 2 PACKAGE = 3 @@ -44,10 +45,7 @@ class AddonManagerRepo: elif self.value == 3: return "Package" - def __int__(self) -> int : - return self.value - - class UpdateStatus(Enum): + class UpdateStatus(IntEnum): NOT_INSTALLED = 0 UNCHECKED = 1 NO_UPDATE_AVAILABLE = 2 @@ -72,9 +70,9 @@ class AddonManagerRepo: return "Restart required" def __init__ (self, name:str, url:str, status:UpdateStatus, branch:str): - self.name = name - self.url = url - self.branch = branch + self.name = name.strip() + self.url = url.strip() + self.branch = branch.strip() self.update_status = status self.repo_type = AddonManagerRepo.RepoType.WORKBENCH self.description = None @@ -105,6 +103,31 @@ class AddonManagerRepo: instance.repo_type = AddonManagerRepo.RepoType.MACRO instance.description = macro.desc return instance + + @classmethod + def from_cache (self, data:Dict): + """ Load basic data from cached dict data. Does not include Macro or Metadata information, which must be populated separately. """ + + mod_dir = os.path.join(FreeCAD.getUserAppDataDir(),"Mod",data["name"]) + if os.path.isdir(mod_dir): + status = AddonManagerRepo.UpdateStatus.UNCHECKED + else: + status = AddonManagerRepo.UpdateStatus.NOT_INSTALLED + instance = AddonManagerRepo(data["name"], data["url"], status, data["branch"]) + instance.repo_type = AddonManagerRepo.RepoType(data["repo_type"]) + instance.description = data["description"] + instance.cached_icon_filename = data["cached_icon_filename"] + return instance + + def to_cache (self) -> Dict: + """ Returns a dictionary with cache information that can be used later with from_cache to recreate this object. """ + + return {"name":self.name, + "url":self.url, + "branch":self.branch, + "repo_type":int(self.repo_type), + "description":self.description, + "cached_icon_filename":self.cached_icon_filename} def contains_workbench(self) -> bool: """ Determine if this package contains (or is) a workbench """ @@ -160,7 +183,7 @@ class AddonManagerRepo: real_icon = real_icon.replace("/", os.path.sep) # Required path separator in the metadata.xml file to local separator _, file_extension = os.path.splitext(real_icon) - store = os.path.join(FreeCAD.getUserAppDataDir(), "AddonManager", "PackageMetadata") + store = os.path.join(FreeCAD.getUserCachePath(), "AddonManager", "PackageMetadata") self.cached_icon_filename = os.path.join(store, self.name, "cached_icon"+file_extension) return self.cached_icon_filename diff --git a/src/Mod/AddonManager/CMakeLists.txt b/src/Mod/AddonManager/CMakeLists.txt index fa84f324fb..de711853f1 100644 --- a/src/Mod/AddonManager/CMakeLists.txt +++ b/src/Mod/AddonManager/CMakeLists.txt @@ -13,6 +13,10 @@ SET(AddonManager_SRCS addonmanager_workers.py AddonManager.ui AddonManagerOptions.ui + compact_view.py + expanded_view.py + package_list.py + package_details.py ) SOURCE_GROUP("" FILES ${AddonManager_SRCS}) diff --git a/src/Mod/AddonManager/Resources/AddonManager.qrc b/src/Mod/AddonManager/Resources/AddonManager.qrc index 312e646a45..be3f70b57d 100644 --- a/src/Mod/AddonManager/Resources/AddonManager.qrc +++ b/src/Mod/AddonManager/Resources/AddonManager.qrc @@ -61,6 +61,8 @@ icons/WebTools_workbench_icon.svg icons/workfeature_workbench_icon.svg icons/yaml-workspace_workbench_icon.svg + icons/expanded_view.svg + icons/compact_view.svg translations/AddonManager_af.qm translations/AddonManager_ar.qm translations/AddonManager_ca.qm diff --git a/src/Mod/AddonManager/Resources/icons/compact_view.svg b/src/Mod/AddonManager/Resources/icons/compact_view.svg new file mode 100644 index 0000000000..9923da9443 --- /dev/null +++ b/src/Mod/AddonManager/Resources/icons/compact_view.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/src/Mod/AddonManager/Resources/icons/expanded_view.svg b/src/Mod/AddonManager/Resources/icons/expanded_view.svg new file mode 100644 index 0000000000..ad0464a876 --- /dev/null +++ b/src/Mod/AddonManager/Resources/icons/expanded_view.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/src/Mod/AddonManager/addonmanager_macro.py b/src/Mod/AddonManager/addonmanager_macro.py index 88481da068..e2e46da249 100644 --- a/src/Mod/AddonManager/addonmanager_macro.py +++ b/src/Mod/AddonManager/addonmanager_macro.py @@ -26,6 +26,7 @@ import re import sys import codecs import shutil +from typing import Dict, Union import FreeCAD @@ -63,6 +64,18 @@ class Macro(object): def __eq__(self, other): return self.filename == other.filename + @classmethod + def from_cache (self, cache_dict:Dict): + instance = Macro(cache_dict["name"]) + for key,value in cache_dict.items(): + instance.__dict__[key] = value + return instance + + def to_cache (self) -> Dict: + """ For cache purposes this entire class is dumped directly """ + + return self.__dict__ + @property def filename(self): if self.on_git: diff --git a/src/Mod/AddonManager/addonmanager_metadata.py b/src/Mod/AddonManager/addonmanager_metadata.py index f72414866a..2084fe5166 100644 --- a/src/Mod/AddonManager/addonmanager_metadata.py +++ b/src/Mod/AddonManager/addonmanager_metadata.py @@ -52,7 +52,7 @@ class MetadataDownloadWorker(QObject): super().__init__(parent) self.repo = repo self.index = index - self.store = os.path.join(FreeCAD.getUserAppDataDir(), "AddonManager", "PackageMetadata") + self.store = os.path.join(FreeCAD.getUserCachePath(), "AddonManager", "PackageMetadata") self.last_sha1 = "" self.url = self.repo.metadata_url diff --git a/src/Mod/AddonManager/addonmanager_workers.py b/src/Mod/AddonManager/addonmanager_workers.py index 4712e3c94d..91661ff736 100644 --- a/src/Mod/AddonManager/addonmanager_workers.py +++ b/src/Mod/AddonManager/addonmanager_workers.py @@ -124,46 +124,11 @@ class UpdateWorker(QtCore.QThread): else: FreeCAD.Console.PrintWarning("Debug: addon_flags.json not found\n") - # Check the local package cache: basedir = FreeCAD.getUserAppDataDir() moddir = basedir + os.sep + "Mod" - cache_path = os.path.join(FreeCAD.getUserAppDataDir(), "AddonManager", "PackageMetadata") package_names = [] - if os.path.isdir(cache_path): - for dir in os.listdir(cache_path): - if self.current_thread.isInterruptionRequested(): - return - dir_path = os.path.join(cache_path, dir) - if not os.path.isdir(dir_path): - continue - xml_cache = os.path.join(dir_path, "package.xml") - try: - meta = FreeCAD.Metadata(xml_cache) - except Exception: - FreeCAD.Console.PrintWarning(f"Failed to create Metadata from {xml_cache}\n") - continue - name = dir # Do not use metadata name here, we want to match the legacy fetch code below - url = None - branch = None - for meta_url in meta.Urls: - if meta_url["type"] == "repository": - url = meta_url["location"] - branch = meta_url["branch"] - break - addondir = moddir + os.sep + name - if os.path.exists(addondir) and os.listdir(addondir): - state = AddonManagerRepo.UpdateStatus.UNCHECKED - else: - state = AddonManagerRepo.UpdateStatus.NOT_INSTALLED - cached_package = AddonManagerRepo(name, url, state, branch) - cached_package.metadata = meta - cached_package.icon = QtGui.QIcon(cached_package.get_cached_icon_filename()) - cached_package.repo_type = AddonManagerRepo.RepoType.PACKAGE - cached_package.description = meta.Description - self.addon_repo.emit(cached_package) - package_names.append(name) - # querying custom addons + # querying custom addons first addon_list = (FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") .GetString("CustomRepositories", "").split("\n")) custom_addons = [] @@ -205,7 +170,7 @@ class UpdateWorker(QtCore.QThread): p = re.findall((r'(?m)\[submodule\s*"(?P.*)"\]\s*' r"path\s*=\s*(?P.+)\s*" r"url\s*=\s*(?Phttps?://.*)\s*" - r"(branch\s*=\s*(?P.*)\s*)?"), p) + r"(branch\s*=\s*(?P[^\s]*)\s*)?"), p) for name, path, url, _, branch in p: if self.current_thread.isInterruptionRequested(): return @@ -229,6 +194,87 @@ class UpdateWorker(QtCore.QThread): self.done.emit() self.stop = True +class LoadPackagesFromCacheWorker(QtCore.QThread): + addon_repo = QtCore.Signal(object) + done = QtCore.Signal() + + def __init__(self, cache_file:str): + QtCore.QThread.__init__(self) + self.cache_file = cache_file + + def run(self): + with open(self.cache_file,"r") as f: + data = f.read() + dict_data = json.loads(data) + for item in dict_data: + if QtCore.QThread.currentThread().isInterruptionRequested(): + return + self.addon_repo.emit(AddonManagerRepo.from_cache(item)) + self.done.emit() + +class LoadMacrosFromCacheWorker(QtCore.QThread): + add_macro_signal = QtCore.Signal(object) + done = QtCore.Signal() + + def __init__(self, cache_file:str): + QtCore.QThread.__init__(self) + self.cache_file = cache_file + + def run(self): + with open(self.cache_file,"r") as f: + data = f.read() + dict_data = json.loads(data) + for item in dict_data: + if QtCore.QThread.currentThread().isInterruptionRequested(): + return + new_macro = Macro.from_cache(item) + self.add_macro_signal.emit(AddonManagerRepo.from_macro(new_macro)) + self.done.emit() + +class LoadMetadataFromCacheWorker(QtCore.QThread): + + done = QtCore.Signal() + package_updated = QtCore.Signal(AddonManagerRepo) + + def __init__(self): + QtCore.QThread.__init__(self) + + def run(self): + cache_path = os.path.join(FreeCAD.getUserCachePath(), "AddonManager", "PackageMetadata") + package_names = [] + if os.path.isdir(cache_path): + for dir in os.listdir(cache_path): + if QtCore.QThread.currentThread().isInterruptionRequested(): + return + dir_path = os.path.join(cache_path, dir) + if not os.path.isdir(dir_path): + continue + xml_cache = os.path.join(dir_path, "package.xml") + try: + meta = FreeCAD.Metadata(xml_cache) + except Exception: + FreeCAD.Console.PrintWarning(f"Failed to create Metadata from {xml_cache}\n") + continue + name = dir # Do not use metadata name here, we want to match the legacy fetch code + url = None + branch = None + for meta_url in meta.Urls: + if meta_url["type"] == "repository": + url = meta_url["location"] + branch = meta_url["branch"] + break + addondir = os.path.join(FreeCAD.getUserAppDataDir(),"Mod",name) + if os.path.exists(addondir) and os.listdir(addondir): + state = AddonManagerRepo.UpdateStatus.UNCHECKED + else: + state = AddonManagerRepo.UpdateStatus.NOT_INSTALLED + cached_package = AddonManagerRepo(name, url, state, branch) + cached_package.metadata = meta + cached_package.repo_type = AddonManagerRepo.RepoType.PACKAGE + cached_package.description = meta.Description + self.package_updated.emit(cached_package) + self.done.emit() + class CheckWorkbenchesForUpdatesWorker(QtCore.QThread): """This worker checks for available updates for all workbenches""" @@ -480,70 +526,68 @@ class ShowWorker(QtCore.QThread): """This worker retrieves info of a given workbench""" status_message = QtCore.Signal(str) - description_updated = QtCore.Signal(str) + readme_updated = QtCore.Signal(str) addon_repos = QtCore.Signal(object) done = QtCore.Signal() - def __init__(self, repo): + def __init__(self, repo, cache_path): QtCore.QThread.__init__(self) self.repo = repo + self.cache_path = cache_path def run(self): self.status_message.emit(translate("AddonsInstaller", "Retrieving description...")) - if self.repo.description is not None: - desc = self.repo.description - else: - u = None - url = self.repo.url - self.status_message.emit(translate("AddonsInstaller", "Retrieving info from") + " " + str(url)) - desc = "" - regex = utils.get_readme_regex(self.repo) - if regex: - # extract readme from html via regex - readmeurl = utils.get_readme_html_url(self.repo) - if not readmeurl: - FreeCAD.Console.PrintWarning(f"Debug: README not found for {url}\n") - u = utils.urlopen(readmeurl) - if not u: - FreeCAD.Console.PrintWarning(f"Debug: README not found at {readmeurl}\n") - u = utils.urlopen(readmeurl) - if u: - p = u.read() - if isinstance(p, bytes): - p = p.decode("utf-8") - u.close() - readme = re.findall(regex, p, flags=re.MULTILINE | re.DOTALL) - if readme: - desc = readme[0] - else: - FreeCAD.Console.PrintWarning(f"Debug: README not found at {readmeurl}\n") + u = None + url = self.repo.url + self.status_message.emit(translate("AddonsInstaller", "Retrieving info from") + " " + str(url)) + desc = "" + regex = utils.get_readme_regex(self.repo) + if regex: + # extract readme from html via regex + readmeurl = utils.get_readme_html_url(self.repo) + if not readmeurl: + FreeCAD.Console.PrintWarning(f"Debug: README not found for {url}\n") + u = utils.urlopen(readmeurl) + if not u: + FreeCAD.Console.PrintWarning(f"Debug: README not found at {readmeurl}\n") + u = utils.urlopen(readmeurl) + if u: + p = u.read() + if isinstance(p, bytes): + p = p.decode("utf-8") + u.close() + readme = re.findall(regex, p, flags=re.MULTILINE | re.DOTALL) + if readme: + desc = readme[0] else: - # convert raw markdown using lib - readmeurl = utils.get_readme_url(self.repo) - if not readmeurl: - FreeCAD.Console.PrintWarning(f"Debug: README not found for {url}\n") - u = utils.urlopen(readmeurl) - if u: - p = u.read() - if isinstance(p, bytes): - p = p.decode("utf-8") - u.close() - desc = utils.fix_relative_links(p, readmeurl.rsplit("/README.md")[0]) - if not NOMARKDOWN and have_markdown: - desc = markdown.markdown(desc, extensions=["md_in_html"]) - else: - message = """ -
- -""" - message += translate("AddonsInstaller", "Raw markdown displayed") - message += "

" - message += translate("AddonsInstaller", "Python Markdown library is missing.") - message += "

" + desc + "
" - desc = message + FreeCAD.Console.PrintWarning(f"Debug: README not found at {readmeurl}\n") + else: + # convert raw markdown using lib + readmeurl = utils.get_readme_url(self.repo) + if not readmeurl: + FreeCAD.Console.PrintWarning(f"Debug: README not found for {url}\n") + u = utils.urlopen(readmeurl) + if u: + p = u.read() + if isinstance(p, bytes): + p = p.decode("utf-8") + u.close() + desc = utils.fix_relative_links(p, readmeurl.rsplit("/README.md")[0]) + if not NOMARKDOWN and have_markdown: + desc = markdown.markdown(desc, extensions=["md_in_html"]) else: - FreeCAD.Console.PrintWarning("Debug: README not found at {readmeurl}\n") + message = """ +
+ +""" + message += translate("AddonsInstaller", "Raw markdown displayed") + message += "

" + message += translate("AddonsInstaller", "Python Markdown library is missing.") + message += "

" + desc + "
" + desc = message + else: + FreeCAD.Console.PrintWarning("Debug: README not found at {readmeurl}\n") if desc == "": # fall back to the description text u = utils.urlopen(url) @@ -686,11 +730,11 @@ class ShowWorker(QtCore.QThread): if QtCore.QThread.currentThread().isInterruptionRequested(): return - self.description_updated.emit(message) + self.readme_updated.emit(message) self.mustLoadImages = True label = self.loadImages(message, self.repo.url, self.repo.name) if label: - self.description_updated.emit(label) + self.readme_updated.emit(label) if QtCore.QThread.currentThread().isInterruptionRequested(): return self.done.emit() @@ -711,12 +755,12 @@ class ShowWorker(QtCore.QThread): imagepaths = re.findall("" + self.macro.name + "" + self.macro.desc + "

Macro location: " + self.macro.url + "") if QtCore.QThread.currentThread().isInterruptionRequested(): return - self.description_updated.emit(message) + self.readme_updated.emit(message) self.done.emit() self.stop = True @@ -1124,7 +1169,7 @@ class UpdateMetadataCacheWorker(QtCore.QThread): self.num_downloads_required = len(self.repos) self.progress_made.emit(0, self.num_downloads_required) self.status_message.emit(translate("AddonsInstaller", "Retrieving package metadata...")) - store = os.path.join(FreeCAD.getUserAppDataDir(), "AddonManager", "PackageMetadata") + store = os.path.join(FreeCAD.getUserCachePath(), "AddonManager", "PackageMetadata") index_file = os.path.join(store,"index.json") self.index = {} if os.path.isfile(index_file): @@ -1286,10 +1331,11 @@ class UpdateSingleWorker(QtCore.QThread): def update_macro(self, repo:AddonManagerRepo): """ Updating a macro happens in this function, in the current thread """ - with tempfile.TemporaryDirectory() as dir: - temp_install_succeeded = macro.install(dir) - if not temp_install_succeeded: - failed = True + cache_path = os.path.join(FreeCAD.getUserCachePath(), "AddonManager", "MacroCache") + os.makedirs(cache_path, exist_ok=True) + temp_install_succeeded = macro.install(cache_path) + if not temp_install_succeeded: + failed = True if not failed: failed = macro.install(self.macro_repo_dir) diff --git a/src/Mod/AddonManager/compact_view.py b/src/Mod/AddonManager/compact_view.py new file mode 100644 index 0000000000..f5b6bc3962 --- /dev/null +++ b/src/Mod/AddonManager/compact_view.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- + +################################################################################ +## Form generated from reading UI file 'compact_view.ui' +## +## Created by: Qt User Interface Compiler version 5.15.1 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide2.QtCore import * +from PySide2.QtGui import * +from PySide2.QtWidgets import * + + +class Ui_CompactView(object): + def setupUi(self, CompactView): + if not CompactView.objectName(): + CompactView.setObjectName(u"CompactView") + CompactView.resize(489, 16) + sizePolicy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(CompactView.sizePolicy().hasHeightForWidth()) + CompactView.setSizePolicy(sizePolicy) + self.horizontalLayout_2 = QHBoxLayout(CompactView) + self.horizontalLayout_2.setObjectName(u"horizontalLayout_2") + self.horizontalLayout_2.setSizeConstraint(QLayout.SetNoConstraint) + self.horizontalLayout_2.setContentsMargins(3, 0, 9, 0) + self.labelIcon = QLabel(CompactView) + self.labelIcon.setObjectName(u"labelIcon") + sizePolicy1 = QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + sizePolicy1.setHorizontalStretch(0) + sizePolicy1.setVerticalStretch(0) + sizePolicy1.setHeightForWidth(self.labelIcon.sizePolicy().hasHeightForWidth()) + self.labelIcon.setSizePolicy(sizePolicy1) + self.labelIcon.setMinimumSize(QSize(16, 16)) + self.labelIcon.setBaseSize(QSize(16, 16)) + + self.horizontalLayout_2.addWidget(self.labelIcon) + + self.labelPackageName = QLabel(CompactView) + self.labelPackageName.setObjectName(u"labelPackageName") + + self.horizontalLayout_2.addWidget(self.labelPackageName) + + self.labelVersion = QLabel(CompactView) + self.labelVersion.setObjectName(u"labelVersion") + + self.horizontalLayout_2.addWidget(self.labelVersion) + + self.labelDescription = QLabel(CompactView) + self.labelDescription.setObjectName(u"labelDescription") + sizePolicy2 = QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Preferred) + sizePolicy2.setHorizontalStretch(0) + sizePolicy2.setVerticalStretch(0) + sizePolicy2.setHeightForWidth(self.labelDescription.sizePolicy().hasHeightForWidth()) + self.labelDescription.setSizePolicy(sizePolicy2) + self.labelDescription.setTextFormat(Qt.PlainText) + self.labelDescription.setWordWrap(False) + self.labelDescription.setTextInteractionFlags(Qt.TextSelectableByMouse) + + self.horizontalLayout_2.addWidget(self.labelDescription) + + self.labelStatus = QLabel(CompactView) + self.labelStatus.setObjectName(u"labelStatus") + + self.horizontalLayout_2.addWidget(self.labelStatus) + + + self.retranslateUi(CompactView) + + QMetaObject.connectSlotsByName(CompactView) + # setupUi + + def retranslateUi(self, CompactView): + CompactView.setWindowTitle(QCoreApplication.translate("CompactView", u"Form", None)) + self.labelIcon.setText(QCoreApplication.translate("CompactView", u"Icon", None)) + self.labelPackageName.setText(QCoreApplication.translate("CompactView", u"Package Name", None)) + self.labelVersion.setText(QCoreApplication.translate("CompactView", u"Version", None)) + self.labelDescription.setText(QCoreApplication.translate("CompactView", u"Description", None)) + self.labelStatus.setText(QCoreApplication.translate("CompactView", u"UpdateAvailable", None)) + # retranslateUi + diff --git a/src/Mod/AddonManager/compact_view.ui b/src/Mod/AddonManager/compact_view.ui new file mode 100644 index 0000000000..d0b08c9c7c --- /dev/null +++ b/src/Mod/AddonManager/compact_view.ui @@ -0,0 +1,110 @@ + + + CompactView + + + + 0 + 0 + 489 + 16 + + + + + 0 + 0 + + + + Form + + + + QLayout::SetNoConstraint + + + 3 + + + 0 + + + 9 + + + 0 + + + + + + 0 + 0 + + + + + 16 + 16 + + + + + 16 + 16 + + + + Icon + + + + + + + <b>Package Name</b> + + + + + + + Version + + + + + + + + 0 + 0 + + + + Description + + + Qt::PlainText + + + false + + + Qt::TextSelectableByMouse + + + + + + + UpdateAvailable + + + + + + + + diff --git a/src/Mod/AddonManager/expanded_view.py b/src/Mod/AddonManager/expanded_view.py new file mode 100644 index 0000000000..12e4a56548 --- /dev/null +++ b/src/Mod/AddonManager/expanded_view.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- + +################################################################################ +## Form generated from reading UI file 'expanded_view.ui' +## +## Created by: Qt User Interface Compiler version 5.15.1 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide2.QtCore import * +from PySide2.QtGui import * +from PySide2.QtWidgets import * + + +class Ui_ExpandedView(object): + def setupUi(self, ExpandedView): + if not ExpandedView.objectName(): + ExpandedView.setObjectName(u"ExpandedView") + ExpandedView.resize(657, 64) + sizePolicy = QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(ExpandedView.sizePolicy().hasHeightForWidth()) + ExpandedView.setSizePolicy(sizePolicy) + self.horizontalLayout_2 = QHBoxLayout(ExpandedView) + self.horizontalLayout_2.setSpacing(2) + self.horizontalLayout_2.setObjectName(u"horizontalLayout_2") + self.horizontalLayout_2.setSizeConstraint(QLayout.SetNoConstraint) + self.horizontalLayout_2.setContentsMargins(2, 0, 2, 0) + self.labelIcon = QLabel(ExpandedView) + self.labelIcon.setObjectName(u"labelIcon") + sizePolicy1 = QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + sizePolicy1.setHorizontalStretch(0) + sizePolicy1.setVerticalStretch(0) + sizePolicy1.setHeightForWidth(self.labelIcon.sizePolicy().hasHeightForWidth()) + self.labelIcon.setSizePolicy(sizePolicy1) + self.labelIcon.setMinimumSize(QSize(48, 48)) + self.labelIcon.setMaximumSize(QSize(48, 48)) + self.labelIcon.setBaseSize(QSize(48, 48)) + + self.horizontalLayout_2.addWidget(self.labelIcon) + + self.horizontalSpacer = QSpacerItem(8, 20, QSizePolicy.Fixed, QSizePolicy.Minimum) + + self.horizontalLayout_2.addItem(self.horizontalSpacer) + + self.verticalLayout = QVBoxLayout() + self.verticalLayout.setSpacing(3) + self.verticalLayout.setObjectName(u"verticalLayout") + self.horizontalLayout = QHBoxLayout() + self.horizontalLayout.setSpacing(10) + self.horizontalLayout.setObjectName(u"horizontalLayout") + self.labelPackageName = QLabel(ExpandedView) + self.labelPackageName.setObjectName(u"labelPackageName") + + self.horizontalLayout.addWidget(self.labelPackageName) + + self.labelVersion = QLabel(ExpandedView) + self.labelVersion.setObjectName(u"labelVersion") + sizePolicy2 = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) + sizePolicy2.setHorizontalStretch(0) + sizePolicy2.setVerticalStretch(0) + sizePolicy2.setHeightForWidth(self.labelVersion.sizePolicy().hasHeightForWidth()) + self.labelVersion.setSizePolicy(sizePolicy2) + + self.horizontalLayout.addWidget(self.labelVersion) + + self.horizontalSpacer_2 = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) + + self.horizontalLayout.addItem(self.horizontalSpacer_2) + + + self.verticalLayout.addLayout(self.horizontalLayout) + + self.labelDescription = QLabel(ExpandedView) + self.labelDescription.setObjectName(u"labelDescription") + sizePolicy.setHeightForWidth(self.labelDescription.sizePolicy().hasHeightForWidth()) + self.labelDescription.setSizePolicy(sizePolicy) + self.labelDescription.setTextFormat(Qt.PlainText) + self.labelDescription.setAlignment(Qt.AlignLeading|Qt.AlignLeft|Qt.AlignTop) + self.labelDescription.setWordWrap(False) + + self.verticalLayout.addWidget(self.labelDescription) + + self.labelMaintainer = QLabel(ExpandedView) + self.labelMaintainer.setObjectName(u"labelMaintainer") + sizePolicy2.setHeightForWidth(self.labelMaintainer.sizePolicy().hasHeightForWidth()) + self.labelMaintainer.setSizePolicy(sizePolicy2) + self.labelMaintainer.setAlignment(Qt.AlignLeading|Qt.AlignLeft|Qt.AlignTop) + self.labelMaintainer.setWordWrap(False) + + self.verticalLayout.addWidget(self.labelMaintainer) + + + self.horizontalLayout_2.addLayout(self.verticalLayout) + + self.horizontalSpacer_3 = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) + + self.horizontalLayout_2.addItem(self.horizontalSpacer_3) + + self.labelStatus = QLabel(ExpandedView) + self.labelStatus.setObjectName(u"labelStatus") + self.labelStatus.setAlignment(Qt.AlignRight|Qt.AlignTrailing|Qt.AlignVCenter) + + self.horizontalLayout_2.addWidget(self.labelStatus) + + + self.retranslateUi(ExpandedView) + + QMetaObject.connectSlotsByName(ExpandedView) + # setupUi + + def retranslateUi(self, ExpandedView): + ExpandedView.setWindowTitle(QCoreApplication.translate("ExpandedView", u"Form", None)) + self.labelIcon.setText(QCoreApplication.translate("ExpandedView", u"Icon", None)) + self.labelPackageName.setText(QCoreApplication.translate("ExpandedView", u"

Package Name

", None)) + self.labelVersion.setText(QCoreApplication.translate("ExpandedView", u"Version", None)) + self.labelDescription.setText(QCoreApplication.translate("ExpandedView", u"Description", None)) + self.labelMaintainer.setText(QCoreApplication.translate("ExpandedView", u"Maintainer", None)) + self.labelStatus.setText(QCoreApplication.translate("ExpandedView", u"UpdateAvailable", None)) + # retranslateUi + diff --git a/src/Mod/AddonManager/expanded_view.ui b/src/Mod/AddonManager/expanded_view.ui new file mode 100644 index 0000000000..78934b525d --- /dev/null +++ b/src/Mod/AddonManager/expanded_view.ui @@ -0,0 +1,203 @@ + + + ExpandedView + + + + 0 + 0 + 657 + 64 + + + + + 0 + 0 + + + + Form + + + + 2 + + + QLayout::SetNoConstraint + + + 2 + + + 0 + + + 2 + + + 0 + + + + + + 0 + 0 + + + + + 48 + 48 + + + + + 48 + 48 + + + + + 48 + 48 + + + + Icon + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 8 + 20 + + + + + + + + 3 + + + + + 10 + + + + + <h1>Package Name</h1> + + + + + + + + 0 + 0 + + + + Version + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + 0 + 0 + + + + Description + + + Qt::PlainText + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + false + + + + + + + + 0 + 0 + + + + Maintainer + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + false + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + UpdateAvailable + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + diff --git a/src/Mod/AddonManager/package_details.py b/src/Mod/AddonManager/package_details.py new file mode 100644 index 0000000000..6828dad284 --- /dev/null +++ b/src/Mod/AddonManager/package_details.py @@ -0,0 +1,245 @@ +# -*- coding: utf-8 -*- + +#*************************************************************************** +#* * +#* Copyright (c) 2021 Chris Hennes * +#* * +#* This program is free software; you can redistribute it and/or modify * +#* it under the terms of the GNU Lesser General Public License (LGPL) * +#* as published by the Free Software Foundation; either version 2 of * +#* the License, or (at your option) any later version. * +#* for detail see the LICENCE text file. * +#* * +#* This program is distributed in the hope that it will be useful, * +#* but WITHOUT ANY WARRANTY; without even the implied warranty of * +#* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +#* GNU Library General Public License for more details. * +#* * +#* You should have received a copy of the GNU Library General Public * +#* License along with this program; if not, write to the Free Software * +#* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +#* USA * +#* * +#*************************************************************************** +from PySide2.QtCore import * +from PySide2.QtGui import * +from PySide2.QtWidgets import * + +import os +import shutil +from datetime import date, timedelta + +import FreeCAD + +from addonmanager_utilities import translate # this needs to be as is for pylupdate +from addonmanager_workers import ShowWorker, GetMacroDetailsWorker +from AddonManagerRepo import AddonManagerRepo + +class PackageDetails(QWidget): + + backClicked = Signal() + installClicked = Signal() + uninstallClicked = Signal() + updateClicked = Signal() + executeClicked = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.ui = Ui_PackageDetails() + self.ui.setupUi(self) + + self.worker = None + self.repo = None + + self.ui.buttonBack.clicked.connect(self.backClicked.emit) + self.ui.buttonRefresh.clicked.connect(self.refresh) + self.ui.buttonExecute.clicked.connect(self.executeClicked.emit) + self.ui.buttonInstall.clicked.connect(self.installClicked.emit) + self.ui.buttonUninstall.clicked.connect(self.uninstallClicked.emit) + self.ui.buttonUpdate.clicked.connect(self.updateClicked.emit) + + def show_repo(self, repo:AddonManagerRepo) -> None: + + self.repo = repo + + if self.worker is not None: + if not self.worker.isFinished(): + self.worker.requestInterruption() + self.worker.wait() + + self.check_and_clean_cache(repo) + + if repo.repo_type == AddonManagerRepo.RepoType.MACRO: + self.show_macro(repo) + self.ui.buttonExecute.show() + elif repo.repo_type == AddonManagerRepo.RepoType.WORKBENCH: + self.show_workbench(repo) + self.ui.buttonExecute.hide() + elif repo.repo_type == AddonManagerRepo.RepoType.PACKAGE: + self.show_package(repo) + self.ui.buttonExecute.hide() + + if repo.update_status == AddonManagerRepo.UpdateStatus.NOT_INSTALLED: + self.ui.buttonInstall.show() + self.ui.buttonUninstall.hide() + self.ui.buttonUpdate.hide() + elif repo.update_status == AddonManagerRepo.UpdateStatus.NO_UPDATE_AVAILABLE: + self.ui.buttonInstall.hide() + self.ui.buttonUninstall.show() + self.ui.buttonUpdate.hide() + elif repo.update_status == AddonManagerRepo.UpdateStatus.UPDATE_AVAILABLE: + self.ui.buttonInstall.hide() + self.ui.buttonUninstall.show() + self.ui.buttonUpdate.show() + elif repo.update_status == AddonManagerRepo.UpdateStatus.UNCHECKED: + self.ui.buttonInstall.hide() + self.ui.buttonUninstall.show() + self.ui.buttonUpdate.hide() + + @classmethod + def cache_path(self, repo:AddonManagerRepo) -> str: + cache_path = FreeCAD.getUserCachePath() + full_path = os.path.join(cache_path,"AddonManager",repo.name) + return full_path + + def check_and_clean_cache(self, force:bool = False) -> None: + cache_path = PackageDetails.cache_path(self.repo) + readme_cache_file = os.path.join(cache_path,"README.html") + readme_images_path = os.path.join(cache_path,"Images") + download_interrupted_sentinel = os.path.join(readme_images_path,"download_in_progress") + download_interrupted = os.path.isfile(download_interrupted_sentinel) + if os.path.isfile(readme_cache_file): + pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") + days_between_updates = pref.GetInt("DaysBetweenUpdates", 2^32) + timestamp = os.path.getmtime(readme_cache_file) + last_cache_update = date.fromtimestamp(timestamp) + delta_update = timedelta(days=days_between_updates) + if date.today() >= last_cache_update + delta_update or download_interrupted or force: + os.remove(readme_cache_file) + if os.path.isdir(readme_images_path): + shutil.rmtree(readme_images_path) + + def refresh(self): + self.check_and_clean_cache(force=True) + self.show_repo(self.repo) + + def show_cached_readme(self, repo:AddonManagerRepo) -> bool: + """ Attempts to show a cached readme, returns true if there was a cache, or false if not """ + + cache_path = PackageDetails.cache_path(repo) + readme_cache_file = os.path.join(cache_path,"README.html") + if os.path.isfile(readme_cache_file): + with open(readme_cache_file,"rb") as f: + data = f.read() + self.ui.textBrowserReadMe.setText(data.decode()) + return True + return False + + def show_workbench(self, repo:AddonManagerRepo) -> None: + """loads information of a given workbench""" + + if not self.show_cached_readme(repo): + self.ui.textBrowserReadMe.setText(translate("AddonsInstaller","Fetching README.md from package repository")) + self.worker = ShowWorker(repo, PackageDetails.cache_path(repo)) + self.worker.readme_updated.connect(lambda desc: self.cache_readme(repo, desc)) + self.worker.readme_updated.connect(lambda desc: self.ui.textBrowserReadMe.setText(desc)) + self.worker.start() + + def show_package(self, repo:AddonManagerRepo) -> None: + """ Show the details for a package (a repo with a package.xml metadata file) """ + + if not self.show_cached_readme(repo): + self.ui.textBrowserReadMe.setText(translate("AddonsInstaller","Fetching README.md from package repository")) + self.worker = ShowWorker(repo,PackageDetails.cache_path(repo)) + self.worker.readme_updated.connect(lambda desc: self.cache_readme(repo, desc)) + self.worker.readme_updated.connect(lambda desc: self.ui.textBrowserReadMe.setText(desc)) + self.worker.start() + + def show_macro(self, repo:AddonManagerRepo) -> None: + """loads information of a given macro""" + + if not self.show_cached_readme(repo): + self.ui.textBrowserReadMe.setText(translate("AddonsInstaller","Fetching README.md from package repository")) + self.worker = GetMacroDetailsWorker(repo) + self.worker.readme_updated.connect(lambda desc: self.cache_readme(repo, desc)) + self.worker.readme_updated.connect(lambda desc: self.ui.textBrowserReadMe.setText(desc)) + self.worker.start() + + def cache_readme(self, repo:AddonManagerRepo, readme:str) -> None: + cache_path = PackageDetails.cache_path(repo) + readme_cache_file = os.path.join(cache_path,"README.html") + os.makedirs(cache_path,exist_ok=True) + with open(readme_cache_file,"wb") as f: + f.write(readme.encode()) + +class Ui_PackageDetails(object): + def setupUi(self, PackageDetails): + if not PackageDetails.objectName(): + PackageDetails.setObjectName(u"PackageDetails") + self.verticalLayout_2 = QVBoxLayout(PackageDetails) + self.verticalLayout_2.setObjectName(u"verticalLayout_2") + self.layoutDetailsBackButton = QHBoxLayout() + self.layoutDetailsBackButton.setObjectName(u"layoutDetailsBackButton") + self.buttonBack = QToolButton(PackageDetails) + self.buttonBack.setObjectName(u"buttonBack") + self.buttonBack.setIcon(QIcon.fromTheme("back", QIcon(":/icons/button_left.svg"))) + self.buttonRefresh = QToolButton(PackageDetails) + self.buttonRefresh.setObjectName(u"buttonRefresh") + self.buttonRefresh.setIcon(QIcon.fromTheme("refresh", QIcon(":/icons/view-refresh.svg"))) + + self.layoutDetailsBackButton.addWidget(self.buttonBack) + self.layoutDetailsBackButton.addWidget(self.buttonRefresh) + + self.horizontalSpacer = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) + + self.layoutDetailsBackButton.addItem(self.horizontalSpacer) + + + self.verticalLayout_2.addLayout(self.layoutDetailsBackButton) + + self.layoutDetailsInstallButtons = QHBoxLayout() + self.layoutDetailsInstallButtons.setObjectName(u"layoutDetailsInstallButtons") + self.buttonInstall = QPushButton(PackageDetails) + self.buttonInstall.setObjectName(u"buttonInstall") + + self.layoutDetailsInstallButtons.addWidget(self.buttonInstall) + + self.buttonUninstall = QPushButton(PackageDetails) + self.buttonUninstall.setObjectName(u"buttonUninstall") + + self.layoutDetailsInstallButtons.addWidget(self.buttonUninstall) + + self.buttonUpdate = QPushButton(PackageDetails) + self.buttonUpdate.setObjectName(u"buttonUpdate") + + self.layoutDetailsInstallButtons.addWidget(self.buttonUpdate) + + self.buttonExecute = QPushButton(PackageDetails) + self.buttonExecute.setObjectName(u"buttonExecute") + + self.layoutDetailsInstallButtons.addWidget(self.buttonExecute) + + + self.verticalLayout_2.addLayout(self.layoutDetailsInstallButtons) + + self.textBrowserReadMe = QTextBrowser(PackageDetails) + self.textBrowserReadMe.setObjectName(u"textBrowserReadMe") + + self.verticalLayout_2.addWidget(self.textBrowserReadMe) + + + self.retranslateUi(PackageDetails) + + QMetaObject.connectSlotsByName(PackageDetails) + # setupUi + + def retranslateUi(self, PackageDetails): + self.buttonBack.setText("") + self.buttonInstall.setText(QCoreApplication.translate("AddonsInstaller", u"Install", None)) + self.buttonUninstall.setText(QCoreApplication.translate("AddonsInstaller", u"Uninstall", None)) + self.buttonUpdate.setText(QCoreApplication.translate("AddonsInstaller", u"Update", None)) + self.buttonExecute.setText(QCoreApplication.translate("AddonsInstaller", u"Run Macro", None)) + self.buttonBack.setToolTip(QCoreApplication.translate("AddonsInstaller", u"Return to package list", None)) + self.buttonRefresh.setToolTip(QCoreApplication.translate("AddonsInstaller", u"Delete cached version of this README and re-download", None)) + # retranslateUi + diff --git a/src/Mod/AddonManager/package_details.ui b/src/Mod/AddonManager/package_details.ui new file mode 100644 index 0000000000..a63345505c --- /dev/null +++ b/src/Mod/AddonManager/package_details.ui @@ -0,0 +1,90 @@ + + + PackageDetails + + + + 0 + 0 + 755 + 367 + + + + Form + + + + + + + + + + + + + + + ... + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Uninstalls a selected macro or workbench + + + Install + + + + + + + Uninstall + + + + + + + Update + + + + + + + Run Macro + + + + + + + + + + + + + diff --git a/src/Mod/AddonManager/package_list.py b/src/Mod/AddonManager/package_list.py new file mode 100644 index 0000000000..ce116be7cb --- /dev/null +++ b/src/Mod/AddonManager/package_list.py @@ -0,0 +1,439 @@ +# -*- coding: utf-8 -*- + +#*************************************************************************** +#* * +#* Copyright (c) 2021 Chris Hennes * +#* * +#* This program is free software; you can redistribute it and/or modify * +#* it under the terms of the GNU Lesser General Public License (LGPL) * +#* as published by the Free Software Foundation; either version 2 of * +#* the License, or (at your option) any later version. * +#* for detail see the LICENCE text file. * +#* * +#* This program is distributed in the hope that it will be useful, * +#* but WITHOUT ANY WARRANTY; without even the implied warranty of * +#* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +#* GNU Library General Public License for more details. * +#* * +#* You should have received a copy of the GNU Library General Public * +#* License along with this program; if not, write to the Free Software * +#* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +#* USA * +#* * +#*************************************************************************** + +import FreeCAD + +from PySide2.QtCore import * +from PySide2.QtGui import * +from PySide2.QtWidgets import * + +from typing import Dict, Union +from enum import IntEnum +import threading + +from addonmanager_utilities import translate # this needs to be as is for pylupdate +from AddonManagerRepo import AddonManagerRepo + +from compact_view import Ui_CompactView +from expanded_view import Ui_ExpandedView + +class ListDisplayStyle(IntEnum): + COMPACT = 0 + EXPANDED = 1 + +class PackageList(QWidget): + """ A widget that shows a list of packages and various widgets to control the display of the list """ + + itemSelected = Signal(AddonManagerRepo) + + def __init__(self,parent=None): + super().__init__(parent) + self.ui = Ui_PackageList() + self.ui.setupUi(self) + + self.item_filter = PackageListFilter() + self.ui.listPackages.setModel (self.item_filter) + self.item_delegate = PackageListItemDelegate() + self.ui.listPackages.setItemDelegate(self.item_delegate) + + self.ui.listPackages.clicked.connect(self.on_listPackages_clicked) + self.ui.comboPackageType.currentIndexChanged.connect(self.update_type_filter) + self.ui.lineEditFilter.textChanged.connect(self.update_text_filter) + self.ui.buttonCompactLayout.clicked.connect(lambda: self.set_view_style(ListDisplayStyle.COMPACT)) + self.ui.buttonExpandedLayout.clicked.connect(lambda: self.set_view_style(ListDisplayStyle.EXPANDED)) + + # Only shows when the user types in a filter + self.ui.labelFilterValidity.hide() + + # Set up the view the same as the last time: + pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") + package_type = pref.GetInt("PackageTypeSelection", 1) + self.ui.comboPackageType.setCurrentIndex(package_type) + + def setModel(self, model): + self.item_model = model + self.item_filter.setSourceModel(self.item_model) + self.item_filter.sort(0) + + pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") + style = pref.GetInt("ViewStyle", ListDisplayStyle.EXPANDED) + self.set_view_style(style) + if style == ListDisplayStyle.EXPANDED: + self.ui.buttonExpandedLayout.setChecked(True) + else: + self.ui.buttonCompactLayout.setChecked(True) + + def on_listPackages_clicked(self, index:QModelIndex): + source_selection = self.item_filter.mapToSource (index) + selected_repo = self.item_model.repos[source_selection.row()] + self.itemSelected.emit(selected_repo) + + def update_type_filter(self, type_filter:int) -> None: + """hide/show rows corresponding to the type filter + + type_filter is an integer: 0 for all, 1 for workbenches, 2 for macros, and 3 for preference packs + + """ + + self.item_filter.setPackageFilter(type_filter) + pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") + pref.SetInt("PackageTypeSelection", type_filter) + + def update_text_filter(self, text_filter:str) -> None: + """filter name and description by the regex specified by text_filter""" + + if text_filter: + test_regex = QRegularExpression(text_filter) + if test_regex.isValid(): + self.ui.labelFilterValidity.setToolTip(translate("AddonsInstaller","Filter is valid")) + icon = QIcon.fromTheme("ok", QIcon(":/icons/edit_OK.svg")) + self.ui.labelFilterValidity.setPixmap(icon.pixmap(16,16)) + else: + self.ui.labelFilterValidity.setToolTip(translate("AddonsInstaller","Filter regular expression is invalid")) + icon = QIcon.fromTheme("cancel", QIcon(":/icons/edit_Cancel.svg")) + self.ui.labelFilterValidity.setPixmap(icon.pixmap(16,16)) + self.ui.labelFilterValidity.show() + else: + self.ui.labelFilterValidity.hide() + self.item_filter.setFilterRegularExpression(text_filter) + + def set_view_style(self, style:ListDisplayStyle) -> None: + self.item_delegate.set_view(style) + if style == ListDisplayStyle.COMPACT: + self.ui.listPackages.setSpacing(2) + else: + self.ui.listPackages.setSpacing(5) + self.item_model.layoutChanged.emit() + + pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") + pref.SetInt("ViewStyle", style) + +class PackageListItemModel(QAbstractListModel): + + repos = [] + write_lock = threading.Lock() + + DataAccessRole = Qt.UserRole + StatusUpdateRole = Qt.UserRole + 1 + IconUpdateRole = Qt.UserRole + 2 + + def __init__(self, parent=None) -> None: + super().__init__(parent) + + def rowCount(self, parent:QModelIndex=QModelIndex()) -> int: + if parent.isValid(): + return 0 + return len(self.repos) + + def columnCount(self, parent:QModelIndex=QModelIndex()) -> int: + if parent.isValid(): + return 0 + return 1 + + def data(self, index:QModelIndex, role:int=Qt.DisplayRole): + if not index.isValid(): + return None + row = index.row() + if role == Qt.ToolTipRole: + tooltip = "" + if self.repos[row].repo_type == AddonManagerRepo.RepoType.PACKAGE: + tooltip = translate("AddonsInstaller","Click for details about package") + f" '{self.repos[row].name}'" + elif self.repos[row].repo_type == AddonManagerRepo.RepoType.WORKBENCH: + tooltip = translate("AddonsInstaller","Click for details about workbench") + f" '{self.repos[row].name}'" + elif self.repos[row].repo_type == AddonManagerRepo.RepoType.MACRO: + tooltip = translate("AddonsInstaller","Click for details about macro") + f" '{self.repos[row].name}'" + return tooltip + elif role == PackageListItemModel.DataAccessRole: + return self.repos[row] + + def headerData(self, section, orientation, role=Qt.DisplayRole): + return None + + def setData(self, index:QModelIndex, value, role=Qt.EditRole) -> None: + """ Set the data for this row. The column of the index is ignored. """ + + row = index.row() + self.write_lock.acquire() + if role == PackageListItemModel.StatusUpdateRole: + self.repos[row].update_status = value + self.dataChanged.emit(self.index(row,2), self.index(row,2), [PackageListItemModel.StatusUpdateRole]) + elif role == PackageListItemModel.IconUpdateRole: + self.repos[row].icon = value + self.dataChanged.emit(self.index(row,0), self.index(row,0), [PackageListItemModel.IconUpdateRole]) + self.write_lock.release() + + def append_item(self, repo:AddonManagerRepo) -> None: + if repo in self.repos: + # Cowardly refuse to insert the same repo a second time + return + self.write_lock.acquire() + self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount()) + self.repos.append(repo) + self.endInsertRows() + self.write_lock.release() + + def clear(self) -> None: + if self.rowCount() > 0: + self.write_lock.acquire() + self.beginRemoveRows(QModelIndex(), 0, self.rowCount()-1) + self.repos = [] + self.endRemoveRows() + self.write_lock.release() + + def update_item_status(self, name:str, status:AddonManagerRepo.UpdateStatus) -> None: + for row,item in enumerate(self.repos): + if item.name == name: + self.setData(self.index(row,0), status, PackageListItemModel.StatusUpdateRole) + return + + def update_item_icon(self, name:str, icon:QIcon) -> None: + for row,item in enumerate(self.repos): + if item.name == name: + self.setData(self.index(row,0), icon, PackageListItemModel.IconUpdateRole) + return + + def reload_item(self,repo:AddonManagerRepo) -> None: + for index,item in enumerate(self.repos): + if item.name == repo.name: + self.write_lock.acquire() + self.repos[index] = repo + self.write_lock.release() + return + +class CompactView(QWidget): + """ A single-line view of the package information """ + + from compact_view import Ui_CompactView + + def __init__(self,parent=None): + super().__init__(parent) + self.ui = Ui_CompactView() + self.ui.setupUi(self) + +class ExpandedView(QWidget): + """ A multi-line view of the package information """ + + from expanded_view import Ui_ExpandedView + + def __init__(self,parent=None): + super().__init__(parent) + self.ui = Ui_ExpandedView() + self.ui.setupUi(self) + +class PackageListItemDelegate(QStyledItemDelegate): + """ Render the repo data as a formatted region """ + + def __init__(self, parent=None): + super().__init__(parent) + self.displayStyle = ListDisplayStyle.EXPANDED + self.expanded = ExpandedView() + self.compact = CompactView() + self.widget = self.expanded + + def set_view (self, style:ListDisplayStyle) -> None: + if not self.displayStyle == style: + self.displayStyle = style + + def sizeHint(self, option, index): + self.update_content(index) + return self.widget.sizeHint() + + def update_content(self, index): + repo = index.data(PackageListItemModel.DataAccessRole) + if self.displayStyle == ListDisplayStyle.EXPANDED: + self.widget = self.expanded + self.widget.ui.labelPackageName.setText(f"

{repo.name}

") + self.widget.ui.labelIcon.setPixmap(repo.icon.pixmap(QSize(48,48))) + else: + self.widget = self.compact + self.widget.ui.labelPackageName.setText(f"{repo.name}") + self.widget.ui.labelIcon.setPixmap(repo.icon.pixmap(QSize(16,16))) + + self.widget.ui.labelIcon.setText("") + if repo.metadata: + self.widget.ui.labelDescription.setText(repo.metadata.Description) + self.widget.ui.labelVersion.setText(f"v{repo.metadata.Version}") + if self.displayStyle == ListDisplayStyle.EXPANDED: + maintainers = repo.metadata.Maintainer + maintainers_string = "" + if len(maintainers) == 1: + maintainers_string = translate("AddonsInstaller","Maintainer") + f": {maintainers[0]['name']} <{maintainers[0]['email']}>" + elif len(maintainers) > 1: + maintainers_string = translate("AddonsInstaller","Maintainers:") + for maintainer in maintainers: + maintainers_string += f"\n{maintainer['name']} <{maintainer['email']}>" + self.widget.ui.labelMaintainer.setText(maintainers_string) + else: + self.widget.ui.labelDescription.setText("") + self.widget.ui.labelVersion.setText("") + if self.displayStyle == ListDisplayStyle.EXPANDED: + self.widget.ui.labelMaintainer.setText("") + + # Update status + if repo.update_status == AddonManagerRepo.UpdateStatus.NOT_INSTALLED: + self.widget.ui.labelStatus.setText("") + elif repo.update_status == AddonManagerRepo.UpdateStatus.UNCHECKED: + self.widget.ui.labelStatus.setText(translate("AddonsInstaller","Installed")) + elif repo.update_status == AddonManagerRepo.UpdateStatus.NO_UPDATE_AVAILABLE: + self.widget.ui.labelStatus.setText(translate("AddonsInstaller","Up-to-date")) + elif repo.update_status == AddonManagerRepo.UpdateStatus.UPDATE_AVAILABLE: + self.widget.ui.labelStatus.setText(translate("AddonsInstaller","Update available")) + elif repo.update_status == AddonManagerRepo.UpdateStatus.PENDING_RESTART: + self.widget.ui.labelStatus.setText(translate("AddonsInstaller","Pending restart")) + self.widget.adjustSize() + + def paint(self, painter:QPainter, option:QStyleOptionViewItem, index:QModelIndex): + painter.save() + self.widget.resize(option.rect.size()) + painter.translate(option.rect.topLeft()) + self.widget.render(painter, QPoint(), QRegion(), QWidget.DrawChildren) + painter.restore() + +class PackageListFilter(QSortFilterProxyModel): + """ Handle filtering the item list on various criteria """ + + def __init__(self): + super().__init__() + self.package_type = 0 # Default to showing everything + self.setSortCaseSensitivity(Qt.CaseInsensitive) + + def setPackageFilter(self, type:int) -> None: # 0=All, 1=Workbenches, 2=Macros, 3=Preference Packs + self.package_type = type + self.invalidateFilter() + + def lessThan(self, left, right) -> bool: + l = self.sourceModel().data(left,PackageListItemModel.DataAccessRole) + r = self.sourceModel().data(right,PackageListItemModel.DataAccessRole) + + lname = l.name if l.metadata is None else l.metadata.Name + rname = r.name if r.metadata is None else r.metadata.Name + return lname.lower() < rname.lower() + + def filterAcceptsRow(self, row, parent=QModelIndex()): + index = self.sourceModel().createIndex(row, 0) + data = self.sourceModel().data(index,PackageListItemModel.DataAccessRole) + if self.package_type == 1: + if not data.contains_workbench(): + return False + elif self.package_type == 2: + if not data.contains_macro(): + return False + elif self.package_type == 3: + if not data.contains_preference_pack(): + return False + + name = data.name if data.metadata is None else data.metadata.Name + desc = data.description if not data.metadata else data.metadata.Description + re = self.filterRegularExpression() + if re.isValid(): + re.setPatternOptions(QRegularExpression.CaseInsensitiveOption) + if re.match(name).hasMatch(): + return True + if re.match(desc).hasMatch(): + return True + return False + else: + return False + +class Ui_PackageList(object): + """ The contents of the PackageList widget """ + + def setupUi(self, Form): + if not Form.objectName(): + Form.setObjectName(u"PackageList") + self.verticalLayout = QVBoxLayout(Form) + self.verticalLayout.setObjectName(u"verticalLayout") + self.horizontalLayout_6 = QHBoxLayout() + self.horizontalLayout_6.setObjectName(u"horizontalLayout_6") + self.buttonCompactLayout = QToolButton(Form) + self.buttonCompactLayout.setObjectName(u"buttonCompactLayout") + self.buttonCompactLayout.setCheckable(True) + self.buttonCompactLayout.setAutoExclusive(True) + self.buttonCompactLayout.setIcon(QIcon.fromTheme("expanded_view", QIcon(":/icons/compact_view.svg"))) + + self.horizontalLayout_6.addWidget(self.buttonCompactLayout) + + self.buttonExpandedLayout = QToolButton(Form) + self.buttonExpandedLayout.setObjectName(u"buttonExpandedLayout") + self.buttonExpandedLayout.setCheckable(True) + self.buttonExpandedLayout.setChecked(True) + self.buttonExpandedLayout.setAutoExclusive(True) + self.buttonExpandedLayout.setIcon(QIcon.fromTheme("expanded_view", QIcon(":/icons/expanded_view.svg"))) + + self.horizontalLayout_6.addWidget(self.buttonExpandedLayout) + + self.labelPackagesContaining = QLabel(Form) + self.labelPackagesContaining.setObjectName(u"labelPackagesContaining") + + self.horizontalLayout_6.addWidget(self.labelPackagesContaining) + + self.comboPackageType = QComboBox(Form) + self.comboPackageType.addItem("") + self.comboPackageType.addItem("") + self.comboPackageType.addItem("") + self.comboPackageType.addItem("") + self.comboPackageType.setObjectName(u"comboPackageType") + + self.horizontalLayout_6.addWidget(self.comboPackageType) + + self.lineEditFilter = QLineEdit(Form) + self.lineEditFilter.setObjectName(u"lineEditFilter") + self.lineEditFilter.setClearButtonEnabled(True) + + self.horizontalLayout_6.addWidget(self.lineEditFilter) + + self.labelFilterValidity = QLabel(Form) + self.labelFilterValidity.setObjectName(u"labelFilterValidity") + + self.horizontalLayout_6.addWidget(self.labelFilterValidity) + + self.verticalLayout.addLayout(self.horizontalLayout_6) + + self.listPackages = QListView(Form) + self.listPackages.setObjectName(u"listPackages") + self.listPackages.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.listPackages.setProperty("showDropIndicator", False) + self.listPackages.setSelectionMode(QAbstractItemView.NoSelection) + self.listPackages.setLayoutMode(QListView.Batched) + self.listPackages.setBatchSize(15) + self.listPackages.setResizeMode(QListView.Adjust) + self.listPackages.setUniformItemSizes(False) + self.listPackages.setAlternatingRowColors(True) + + self.verticalLayout.addWidget(self.listPackages) + + self.retranslateUi(Form) + + QMetaObject.connectSlotsByName(Form) + + def retranslateUi(self, Form): + self.labelPackagesContaining.setText(QCoreApplication.translate("AddonsInstaller", u"Show packages containing:", None)) + self.comboPackageType.setItemText(0, QCoreApplication.translate("AddonsInstaller", u"All", None)) + self.comboPackageType.setItemText(1, QCoreApplication.translate("AddonsInstaller", u"Workbenches", None)) + self.comboPackageType.setItemText(2, QCoreApplication.translate("AddonsInstaller", u"Macros", None)) + self.comboPackageType.setItemText(3, QCoreApplication.translate("AddonsInstaller", u"Preference Packs", None)) + + self.lineEditFilter.setPlaceholderText(QCoreApplication.translate("AddonsInstaller", u"Filter", None)) + self.labelFilterValidity.setText(QCoreApplication.translate("AddonsInstaller", u"OK", None)) +