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.
This commit is contained in:
Chris Hennes
2021-11-16 16:11:14 -06:00
parent f9a6310332
commit e42eec2558
17 changed files with 1786 additions and 847 deletions

View File

@@ -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"<h1>{repo.metadata.Name}</h1>")
# Description
self.dialog.labelDescription.setText(repo.metadata.Description)
# Version
self.dialog.labelVersion.setText(f"<h3>v{repo.metadata.Version}</h3>")
# 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"<h2>{content_type}</h2>"
content_string += "<ul>"
for item in item_list:
content_string += f"<li><b>{item.Name}</b> &ndash; {item.Description}</li>"
content_string += "</ul>"
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)
# @}

View File

@@ -6,8 +6,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>599</width>
<height>480</height>
<width>800</width>
<height>600</height>
</rect>
</property>
<property name="windowTitle">
@@ -15,282 +15,98 @@
</property>
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<widget class="QSplitter" name="splitter">
<widget class="QWidget" name="contentPlaceholder" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<widget class="QWidget" name="verticalLayoutWidget">
<layout class="QVBoxLayout" name="verticalLayout_2">
</widget>
</item>
<item>
<layout class="QVBoxLayout" name="layoutUpdateInProgress">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_7">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_6">
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Show packages containing:</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="comboPackageType">
<item>
<property name="text">
<string>All</string>
</property>
</item>
<item>
<property name="text">
<string>Workbenches</string>
</property>
</item>
<item>
<property name="text">
<string>Macros</string>
</property>
</item>
<item>
<property name="text">
<string>Preference Packs</string>
</property>
</item>
</widget>
</item>
</layout>
<widget class="QToolButton" name="buttonShowDetails">
<property name="toolTip">
<string>Show details</string>
</property>
<property name="text">
<string>...</string>
</property>
<property name="arrowType">
<enum>Qt::RightArrow</enum>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_5">
<item>
<widget class="QLineEdit" name="lineEditFilter">
<property name="placeholderText">
<string>Filter</string>
</property>
<property name="clearButtonEnabled">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="labelFilterValidity">
<property name="text">
<string>OK</string>
</property>
</widget>
</item>
</layout>
<widget class="QLabel" name="labelUpdateInProgress">
<property name="text">
<string>Loading...</string>
</property>
</widget>
</item>
<item>
<widget class="QTableView" name="tablePackages">
<property name="editTriggers">
<set>QAbstractItemView::NoEditTriggers</set>
<widget class="QProgressBar" name="progressBar">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="showDropIndicator" stdset="0">
<bool>false</bool>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::SingleSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="iconSize">
<property name="minimumSize">
<size>
<width>16</width>
<height>16</height>
<width>0</width>
<height>0</height>
</size>
</property>
<property name="showGrid">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>12</height>
</size>
</property>
<property name="baseSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<property name="value">
<number>0</number>
</property>
<property name="textVisible">
<bool>false</bool>
</property>
<property name="sortingEnabled">
<bool>true</bool>
</property>
<property name="wordWrap">
<bool>false</bool>
</property>
<attribute name="horizontalHeaderMinimumSectionSize">
<number>16</number>
</attribute>
<attribute name="verticalHeaderVisible">
<bool>false</bool>
</attribute>
<attribute name="verticalHeaderMinimumSectionSize">
<number>12</number>
</attribute>
<attribute name="verticalHeaderDefaultSectionSize">
<number>16</number>
</attribute>
<attribute name="verticalHeaderStretchLastSection">
<bool>false</bool>
</attribute>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="verticalLayoutWidget_2">
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="buttonConfigure">
<property name="toolTip">
<string>Sets configuration options for the Addon Manager</string>
</property>
<property name="text">
<string>Configure...</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_4">
<item>
<widget class="QLabel" name="labelIcon">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>64</width>
<height>64</height>
</size>
</property>
<property name="baseSize">
<size>
<width>64</width>
<height>64</height>
</size>
</property>
<property name="text">
<string>Icon</string>
</property>
</widget>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="spacing">
<number>0</number>
</property>
<property name="sizeConstraint">
<enum>QLayout::SetDefaultConstraint</enum>
</property>
<item>
<widget class="QLabel" name="labelPackageName">
<property name="text">
<string>&lt;h1&gt;Package Name&lt;/h1&gt;</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="labelVersion">
<property name="text">
<string>&lt;em&gt;Version&lt;/em&gt;</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="labelMaintainer">
<property name="text">
<string>Maintainer</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="labelDescription">
<property name="text">
<string>Description</string>
</property>
<property name="textFormat">
<enum>Qt::PlainText</enum>
</property>
<property name="wordWrap">
<bool>true</bool>
<property name="format">
<string>Downloading info...</string>
</property>
</widget>
</item>
<item>
<layout class="QGridLayout" name="urlGrid">
<item row="0" column="0">
<widget class="QLabel" name="labelUrlType">
<property name="text">
<string>URL Type</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLabel" name="labelUrl">
<property name="text">
<string>Url</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="labelContents">
<property name="text">
<string>Package contents:</string>
<widget class="QToolButton" name="buttonPauseUpdate">
<property name="toolTip">
<string>Pause cache update</string>
</property>
</widget>
</item>
<item>
<widget class="QTextBrowser" name="description"/>
</item>
<item>
<widget class="QPushButton" name="buttonExecute">
<property name="text">
<string>Run Macro</string>
<string/>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
<item>
<widget class="QLabel" name="labelStatusInfo">
<property name="text">
<string>labelStatusInfo</string>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
</item>
<item>
<widget class="QProgressBar" name="progressBar">
<property name="value">
<number>24</number>
<widget class="QLabel" name="labelStatusInfo">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="textVisible">
<bool>false</bool>
</property>
<property name="format">
<string>Downloading info...</string>
<property name="text">
<string>labelStatusInfo</string>
</property>
</widget>
</item>
@@ -302,22 +118,9 @@
<enum>QLayout::SetDefaultConstraint</enum>
</property>
<item>
<widget class="QPushButton" name="buttonUninstall">
<property name="toolTip">
<string>Uninstalls a selected macro or workbench</string>
</property>
<widget class="QPushButton" name="buttonUpdateCache">
<property name="text">
<string>Uninstall selected</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="buttonInstall">
<property name="toolTip">
<string>Installs or updates the selected macro or workbench</string>
</property>
<property name="text">
<string>Install/update selected</string>
<string>Refresh local cache</string>
</property>
</widget>
</item>
@@ -327,10 +130,23 @@
<string>Download and apply all available updates</string>
</property>
<property name="text">
<string>Update all</string>
<string>Update all packages</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_4">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="buttonClose">
<property name="toolTip">

View File

@@ -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

View File

@@ -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})

View File

@@ -61,6 +61,8 @@
<file>icons/WebTools_workbench_icon.svg</file>
<file>icons/workfeature_workbench_icon.svg</file>
<file>icons/yaml-workspace_workbench_icon.svg</file>
<file>icons/expanded_view.svg</file>
<file>icons/compact_view.svg</file>
<file>translations/AddonManager_af.qm</file>
<file>translations/AddonManager_ar.qm</file>
<file>translations/AddonManager_ca.qm</file>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 16 16" style="enable-background:new 0 0 16 16;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;stroke:#000000;stroke-miterlimit:10;}
.st1{fill:none;stroke:#000000;stroke-miterlimit:10;}
</style>
<rect x="0.5" y="1.5" class="st0" width="3" height="3"/>
<rect x="0.5" y="6.5" class="st0" width="3" height="3"/>
<rect x="0.5" y="11.5" class="st0" width="3" height="3"/>
<line class="st1" x1="6" y1="3.5" x2="16" y2="3.5"/>
<line class="st1" x1="6" y1="8.5" x2="16" y2="8.5"/>
<line class="st1" x1="6" y1="13.5" x2="16" y2="13.5"/>
</svg>

After

Width:  |  Height:  |  Size: 829 B

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 16 16" style="enable-background:new 0 0 16 16;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;stroke:#000000;stroke-miterlimit:10;}
.st1{fill:none;stroke:#000000;stroke-miterlimit:10;}
</style>
<rect x="0.5" y="1.5" class="st0" width="5" height="5"/>
<line class="st1" x1="7" y1="2.5" x2="16" y2="2.5"/>
<line class="st1" x1="7" y1="10.5" x2="16" y2="10.5"/>
<line class="st1" x1="7" y1="13.5" x2="16" y2="13.5"/>
<rect x="0.5" y="9.5" class="st0" width="5" height="5"/>
<line class="st1" x1="7" y1="5.5" x2="16" y2="5.5"/>
</svg>

After

Width:  |  Height:  |  Size: 826 B

View File

@@ -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:

View File

@@ -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

View File

@@ -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<name>.*)"\]\s*'
r"path\s*=\s*(?P<path>.+)\s*"
r"url\s*=\s*(?P<url>https?://.*)\s*"
r"(branch\s*=\s*(?P<branch>.*)\s*)?"), p)
r"(branch\s*=\s*(?P<branch>[^\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 = """
<div style="width: 100%; text-align:center;background: #91bbe0;">
<strong style="color: #FFFFFF;">
"""
message += translate("AddonsInstaller", "Raw markdown displayed")
message += "</strong><br/><br/>"
message += translate("AddonsInstaller", "Python Markdown library is missing.")
message += "<br/></div><hr/><pre>" + desc + "</pre>"
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 = """
<div style="width: 100%; text-align:center;background: #91bbe0;">
<strong style="color: #FFFFFF;">
"""
message += translate("AddonsInstaller", "Raw markdown displayed")
message += "</strong><br/><br/>"
message += translate("AddonsInstaller", "Python Markdown library is missing.")
message += "<br/></div><hr/><pre>" + desc + "</pre>"
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("<img.*?src=\"(.*?)\"", message)
if imagepaths:
storedimages = []
store = os.path.join(FreeCAD.getUserAppDataDir(), "AddonManager", "Images")
store = os.path.join(self.cache_path, "Images")
if not os.path.exists(store):
os.makedirs(store)
with open(os.path.join(store,"download_in_progress"),"w") as f:
f.write("If this file still exists, it's because a download was interrupted. It can be safely ignored.")
for path in imagepaths:
if QtCore.QThread.currentThread().isInterruptionRequested():
return
if QtCore.QThread.currentThread().isInterruptionRequested():
return message
if not self.mustLoadImages:
@@ -759,6 +803,7 @@ class ShowWorker(QtCore.QThread):
QtCore.Qt.FastTransformation))
pix.save(storename, "jpeg", 100)
message = message.replace("src=\"" + origpath, "src=\"file:///" + storename.replace("\\", "/"))
os.remove(os.path.join(store,"download_in_progress"))
return message
return None
@@ -767,7 +812,7 @@ class GetMacroDetailsWorker(QtCore.QThread):
"""Retrieve the macro details for a macro"""
status_message = QtCore.Signal(str)
description_updated = QtCore.Signal(str)
readme_updated = QtCore.Signal(str)
done = QtCore.Signal()
def __init__(self, repo):
@@ -795,7 +840,7 @@ class GetMacroDetailsWorker(QtCore.QThread):
message = (already_installed_msg + "<h1>" + self.macro.name + "</h1>" + self.macro.desc + "<br/><br/>Macro location: <a href=\"" + self.macro.url + "\">" + self.macro.url + "</a>")
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)

View File

@@ -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"<b>Package Name</b>", 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

View File

@@ -0,0 +1,110 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>CompactView</class>
<widget class="QWidget" name="CompactView">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>489</width>
<height>16</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<property name="sizeConstraint">
<enum>QLayout::SetNoConstraint</enum>
</property>
<property name="leftMargin">
<number>3</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>9</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="labelIcon">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>16</width>
<height>16</height>
</size>
</property>
<property name="baseSize">
<size>
<width>16</width>
<height>16</height>
</size>
</property>
<property name="text">
<string>Icon</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="labelPackageName">
<property name="text">
<string>&lt;b&gt;Package Name&lt;/b&gt;</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="labelVersion">
<property name="text">
<string>Version</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="labelDescription">
<property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Description</string>
</property>
<property name="textFormat">
<enum>Qt::PlainText</enum>
</property>
<property name="wordWrap">
<bool>false</bool>
</property>
<property name="textInteractionFlags">
<set>Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="labelStatus">
<property name="text">
<string>UpdateAvailable</string>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -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"<h1>Package Name</h1>", 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

View File

@@ -0,0 +1,203 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>ExpandedView</class>
<widget class="QWidget" name="ExpandedView">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>657</width>
<height>64</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<property name="spacing">
<number>2</number>
</property>
<property name="sizeConstraint">
<enum>QLayout::SetNoConstraint</enum>
</property>
<property name="leftMargin">
<number>2</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>2</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="labelIcon">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>48</width>
<height>48</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>48</width>
<height>48</height>
</size>
</property>
<property name="baseSize">
<size>
<width>48</width>
<height>48</height>
</size>
</property>
<property name="text">
<string>Icon</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>8</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="spacing">
<number>3</number>
</property>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="spacing">
<number>10</number>
</property>
<item>
<widget class="QLabel" name="labelPackageName">
<property name="text">
<string>&lt;h1&gt;Package Name&lt;/h1&gt;</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="labelVersion">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Version</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="labelDescription">
<property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Description</string>
</property>
<property name="textFormat">
<enum>Qt::PlainText</enum>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
</property>
<property name="wordWrap">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="labelMaintainer">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Maintainer</string>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
</property>
<property name="wordWrap">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</item>
<item>
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="labelStatus">
<property name="text">
<string>UpdateAvailable</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,245 @@
# -*- coding: utf-8 -*-
#***************************************************************************
#* *
#* Copyright (c) 2021 Chris Hennes <chennes@pioneerlibrarysystem.org> *
#* *
#* 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

View File

@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>PackageDetails</class>
<widget class="QWidget" name="PackageDetails">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>755</width>
<height>367</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<layout class="QHBoxLayout" name="layoutDetailsBackButton">
<item>
<widget class="QToolButton" name="buttonBack">
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="buttonRefresh">
<property name="text">
<string>...</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="layoutDetailsInstallButtons">
<item>
<widget class="QPushButton" name="buttonInstall">
<property name="toolTip">
<string>Uninstalls a selected macro or workbench</string>
</property>
<property name="text">
<string>Install</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="buttonUninstall">
<property name="text">
<string>Uninstall</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="buttonUpdate">
<property name="text">
<string>Update</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="buttonExecute">
<property name="text">
<string>Run Macro</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QTextBrowser" name="textBrowserReadMe"/>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,439 @@
# -*- coding: utf-8 -*-
#***************************************************************************
#* *
#* Copyright (c) 2021 Chris Hennes <chennes@pioneerlibrarysystem.org> *
#* *
#* 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"<h1>{repo.name}</h1>")
self.widget.ui.labelIcon.setPixmap(repo.icon.pixmap(QSize(48,48)))
else:
self.widget = self.compact
self.widget.ui.labelPackageName.setText(f"<b>{repo.name}</b>")
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"<i>v{repo.metadata.Version}</i>")
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))