diff --git a/src/Mod/AddonManager/AddonManager.py b/src/Mod/AddonManager/AddonManager.py
index fac90e86ae..74333a3c51 100644
--- a/src/Mod/AddonManager/AddonManager.py
+++ b/src/Mod/AddonManager/AddonManager.py
@@ -52,7 +52,8 @@ from addonmanager_update_all_gui import UpdateAllGUI
import addonmanager_utilities as utils
import AddonManager_rc # This is required by Qt, it's not unused
from package_list import PackageList, PackageListItemModel
-from package_details import PackageDetails
+from addonmanager_package_details_controller import PackageDetailsController
+from Widgets.addonmanager_widget_package_details_view import PackageDetailsView
from Widgets.addonmanager_widget_global_buttons import WidgetGlobalButtonBar
from Addon import Addon
from manage_python_dependencies import (
@@ -204,7 +205,8 @@ class CommandAddonManager:
self.dialog.layout().addWidget(self.button_bar)
# Package details start out hidden
- self.packageDetails = PackageDetails(self.dialog)
+ self.packageDetails = PackageDetailsView(self.dialog)
+ self.package_details_controller = PackageDetailsController(self.packageDetails)
self.packageDetails.hide()
index = self.dialog.layout().indexOf(self.packageList)
self.dialog.layout().insertWidget(index, self.packageDetails)
@@ -238,12 +240,12 @@ class CommandAddonManager:
self.packageList.ui.progressBar.stop_clicked.connect(self.stop_update)
self.packageList.itemSelected.connect(self.table_row_activated)
self.packageList.setEnabled(False)
- self.packageDetails.execute.connect(self.executemacro)
- self.packageDetails.install.connect(self.launch_installer_gui)
- self.packageDetails.uninstall.connect(self.remove)
- self.packageDetails.update.connect(self.update)
- self.packageDetails.back.connect(self.on_buttonBack_clicked)
- self.packageDetails.update_status.connect(self.status_updated)
+ self.package_details_controller.execute.connect(self.executemacro)
+ self.package_details_controller.install.connect(self.launch_installer_gui)
+ self.package_details_controller.uninstall.connect(self.remove)
+ self.package_details_controller.update.connect(self.update)
+ self.package_details_controller.back.connect(self.on_buttonBack_clicked)
+ self.package_details_controller.update_status.connect(self.status_updated)
# center the dialog over the FreeCAD window
mw = FreeCADGui.getMainWindow()
@@ -735,7 +737,7 @@ class CommandAddonManager:
self.packageList.hide()
self.packageDetails.show()
- self.packageDetails.show_repo(selected_repo)
+ self.package_details_controller.show_repo(selected_repo)
def show_information(self, message: str) -> None:
"""shows generic text in the information pane"""
@@ -746,7 +748,7 @@ class CommandAddonManager:
def show_workbench(self, repo: Addon) -> None:
self.packageList.hide()
self.packageDetails.show()
- self.packageDetails.show_repo(repo)
+ self.package_details_controller.show_repo(repo)
def on_buttonBack_clicked(self) -> None:
self.packageDetails.hide()
@@ -765,7 +767,7 @@ class CommandAddonManager:
else:
repo.set_status(Addon.Status.NO_UPDATE_AVAILABLE)
self.item_model.reload_item(repo)
- self.packageDetails.show_repo(repo)
+ self.package_details_controller.show_repo(repo)
def launch_installer_gui(self, addon: Addon) -> None:
if self.installer_gui is not None:
@@ -860,7 +862,7 @@ class CommandAddonManager:
if repo.status() == Addon.Status.PENDING_RESTART:
self.restart_required = True
self.item_model.reload_item(repo)
- self.packageDetails.show_repo(repo)
+ self.package_details_controller.show_repo(repo)
if repo in self.packages_with_updates:
self.packages_with_updates.remove(repo)
self.enable_updates(len(self.packages_with_updates))
diff --git a/src/Mod/AddonManager/CMakeLists.txt b/src/Mod/AddonManager/CMakeLists.txt
index 8877a544be..042ff80363 100644
--- a/src/Mod/AddonManager/CMakeLists.txt
+++ b/src/Mod/AddonManager/CMakeLists.txt
@@ -30,8 +30,9 @@ SET(AddonManager_SRCS
addonmanager_macro.py
addonmanager_macro_parser.py
addonmanager_metadata.py
+ addonmanager_package_details_controller.py
addonmanager_pyside_interface.py
- addonmanager_readme_viewer.py
+ addonmanager_readme_controller.py
addonmanager_update_all_gui.py
addonmanager_uninstaller.py
addonmanager_uninstaller_gui.py
@@ -68,7 +69,7 @@ SET(AddonManager_SRCS
loading.html
manage_python_dependencies.py
NetworkManager.py
- package_details.py
+ addonmanager_package_details_controller.py
package_list.py
PythonDependencyUpdateDialog.ui
select_toolbar_dialog.ui
diff --git a/src/Mod/AddonManager/NetworkManager.py b/src/Mod/AddonManager/NetworkManager.py
index 8b1c1e6e1d..d3481187f8 100644
--- a/src/Mod/AddonManager/NetworkManager.py
+++ b/src/Mod/AddonManager/NetworkManager.py
@@ -472,19 +472,21 @@ if HAVE_QTNETWORK:
sender.abort()
self.__launch_request(current_index, self.__create_get_request(url))
- def __on_ssl_error(self, reply: str, errors: List[str]):
+ def __on_ssl_error(self, reply: str, errors: List[str] = None):
"""Called when an SSL error occurs: prints the error information."""
if HAVE_FREECAD:
FreeCAD.Console.PrintWarning(
translate("AddonsInstaller", "Error with encrypted connection") + "\n:"
)
FreeCAD.Console.PrintWarning(reply)
- for error in errors:
- FreeCAD.Console.PrintWarning(error)
+ if errors is not None:
+ for error in errors:
+ FreeCAD.Console.PrintWarning(error)
else:
print("Error with encrypted connection")
- for error in errors:
- print(error)
+ if errors is not None:
+ for error in errors:
+ print(error)
def __download_progress(self, bytesReceived: int, bytesTotal: int) -> None:
"""Monitors download progress and emits a progress_made signal"""
diff --git a/src/Mod/AddonManager/Widgets/CMakeLists.txt b/src/Mod/AddonManager/Widgets/CMakeLists.txt
index 6637dad646..939e257e2e 100644
--- a/src/Mod/AddonManager/Widgets/CMakeLists.txt
+++ b/src/Mod/AddonManager/Widgets/CMakeLists.txt
@@ -1,9 +1,12 @@
SET(AddonManagerWidget_SRCS
__init__.py
+ addonmanager_colors.py
addonmanager_widget_addon_buttons.py
addonmanager_widget_filter_selector.py
addonmanager_widget_global_buttons.py
+ addonmanager_widget_package_details_view.py
addonmanager_widget_progress_bar.py
+ addonmanager_widget_readme_browser.py
addonmanager_widget_search.py
addonmanager_widget_view_control_bar.py
addonmanager_widget_view_selector.py
diff --git a/src/Mod/AddonManager/Widgets/addonmanager_colors.py b/src/Mod/AddonManager/Widgets/addonmanager_colors.py
new file mode 100644
index 0000000000..7d08a07421
--- /dev/null
+++ b/src/Mod/AddonManager/Widgets/addonmanager_colors.py
@@ -0,0 +1,48 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# ***************************************************************************
+# * *
+# * Copyright (c) 2022-2024 FreeCAD Project Association *
+# * *
+# * This file is part of FreeCAD. *
+# * *
+# * FreeCAD is free software: you can redistribute it and/or modify it *
+# * under the terms of the GNU Lesser General Public License as *
+# * published by the Free Software Foundation, either version 2.1 of the *
+# * License, or (at your option) any later version. *
+# * *
+# * FreeCAD 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 *
+# * Lesser General Public License for more details. *
+# * *
+# * You should have received a copy of the GNU Lesser General Public *
+# * License along with FreeCAD. If not, see *
+# * . *
+# * *
+# ***************************************************************************
+
+from enum import Enum, auto
+
+import FreeCADGui
+from PySide import QtGui
+
+
+def is_darkmode() -> bool:
+ """Heuristics to determine if we are in a darkmode stylesheet"""
+ pl = FreeCADGui.getMainWindow().palette()
+ return pl.color(QtGui.QPalette.Window).lightness() < 128
+
+
+def warning_color_string() -> str:
+ """A shade of red, adapted to darkmode if possible. Targets a minimum 7:1 contrast ratio."""
+ return "rgb(255,105,97)" if is_darkmode() else "rgb(215,0,21)"
+
+
+def bright_color_string() -> str:
+ """A shade of green, adapted to darkmode if possible. Targets a minimum 7:1 contrast ratio."""
+ return "rgb(48,219,91)" if is_darkmode() else "rgb(36,138,61)"
+
+
+def attention_color_string() -> str:
+ """A shade of orange, adapted to darkmode if possible. Targets a minimum 7:1 contrast ratio."""
+ return "rgb(255,179,64)" if is_darkmode() else "rgb(255,149,0)"
diff --git a/src/Mod/AddonManager/Widgets/addonmanager_widget_addon_buttons.py b/src/Mod/AddonManager/Widgets/addonmanager_widget_addon_buttons.py
index 29086a9cd0..21583e8850 100644
--- a/src/Mod/AddonManager/Widgets/addonmanager_widget_addon_buttons.py
+++ b/src/Mod/AddonManager/Widgets/addonmanager_widget_addon_buttons.py
@@ -77,39 +77,37 @@ class WidgetAddonButtons(QtWidgets.QWidget):
def _setup_ui(self):
self.horizontal_layout = QtWidgets.QHBoxLayout()
self.horizontal_layout.setContentsMargins(0, 0, 0, 0)
- self.back_button = QtWidgets.QToolButton(self)
- self.install_button = QtWidgets.QPushButton(self)
- self.uninstall_button = QtWidgets.QPushButton(self)
- self.enable_button = QtWidgets.QPushButton(self)
- self.disable_button = QtWidgets.QPushButton(self)
- self.update_button = QtWidgets.QPushButton(self)
- self.run_macro_button = QtWidgets.QPushButton(self)
- self.change_branch_button = QtWidgets.QPushButton(self)
- self.check_for_update_button = QtWidgets.QPushButton(self)
- self.horizontal_layout.addWidget(self.back_button)
+ self.back = QtWidgets.QToolButton(self)
+ self.install = QtWidgets.QPushButton(self)
+ self.uninstall = QtWidgets.QPushButton(self)
+ self.enable = QtWidgets.QPushButton(self)
+ self.disable = QtWidgets.QPushButton(self)
+ self.update = QtWidgets.QPushButton(self)
+ self.run_macro = QtWidgets.QPushButton(self)
+ self.change_branch = QtWidgets.QPushButton(self)
+ self.check_for_update = QtWidgets.QPushButton(self)
+ self.horizontal_layout.addWidget(self.back)
self.horizontal_layout.addStretch()
- self.horizontal_layout.addWidget(self.check_for_update_button)
- self.horizontal_layout.addWidget(self.install_button)
- self.horizontal_layout.addWidget(self.uninstall_button)
- self.horizontal_layout.addWidget(self.enable_button)
- self.horizontal_layout.addWidget(self.disable_button)
- self.horizontal_layout.addWidget(self.update_button)
- self.horizontal_layout.addWidget(self.run_macro_button)
- self.horizontal_layout.addWidget(self.change_branch_button)
+ self.horizontal_layout.addWidget(self.check_for_update)
+ self.horizontal_layout.addWidget(self.install)
+ self.horizontal_layout.addWidget(self.uninstall)
+ self.horizontal_layout.addWidget(self.enable)
+ self.horizontal_layout.addWidget(self.disable)
+ self.horizontal_layout.addWidget(self.update)
+ self.horizontal_layout.addWidget(self.run_macro)
+ self.horizontal_layout.addWidget(self.change_branch)
self.setLayout(self.horizontal_layout)
def _set_icons(self):
- self.back_button.setIcon(
- QtGui.QIcon.fromTheme("back", QtGui.QIcon(":/icons/button_left.svg"))
- )
+ self.back.setIcon(QtGui.QIcon.fromTheme("back", QtGui.QIcon(":/icons/button_left.svg")))
def retranslateUi(self, _):
- self.check_for_update_button.setText(translate("AddonsInstaller", "Check for update"))
- self.install_button.setText(translate("AddonsInstaller", "Install"))
- self.uninstall_button.setText(translate("AddonsInstaller", "Uninstall"))
- self.disable_button.setText(translate("AddonsInstaller", "Disable"))
- self.enable_button.setText(translate("AddonsInstaller", "Enable"))
- self.update_button.setText(translate("AddonsInstaller", "Update"))
- self.run_macro_button.setText(translate("AddonsInstaller", "Run"))
- self.change_branch_button.setText(translate("AddonsInstaller", "Change branch..."))
- self.back_button.setToolTip(translate("AddonsInstaller", "Return to package list"))
+ self.check_for_update.setText(translate("AddonsInstaller", "Check for update"))
+ self.install.setText(translate("AddonsInstaller", "Install"))
+ self.uninstall.setText(translate("AddonsInstaller", "Uninstall"))
+ self.disable.setText(translate("AddonsInstaller", "Disable"))
+ self.enable.setText(translate("AddonsInstaller", "Enable"))
+ self.update.setText(translate("AddonsInstaller", "Update"))
+ self.run_macro.setText(translate("AddonsInstaller", "Run"))
+ self.change_branch.setText(translate("AddonsInstaller", "Change branch..."))
+ self.back.setToolTip(translate("AddonsInstaller", "Return to package list"))
diff --git a/src/Mod/AddonManager/Widgets/addonmanager_widget_global_buttons.py b/src/Mod/AddonManager/Widgets/addonmanager_widget_global_buttons.py
index f5660e7a89..1c293348aa 100644
--- a/src/Mod/AddonManager/Widgets/addonmanager_widget_global_buttons.py
+++ b/src/Mod/AddonManager/Widgets/addonmanager_widget_global_buttons.py
@@ -109,5 +109,5 @@ class WidgetGlobalButtonBar(QtWidgets.QWidget):
else:
self.update_all_addons.setEnabled(True)
self.update_all_addons.setText(
- translate("AddonsInstaller", "Apply %1 available updates").format(updates)
+ translate("AddonsInstaller", "Apply {} available updates").format(updates)
)
diff --git a/src/Mod/AddonManager/Widgets/addonmanager_widget_package_details_view.py b/src/Mod/AddonManager/Widgets/addonmanager_widget_package_details_view.py
index 2653e8b431..ce83b6af6e 100644
--- a/src/Mod/AddonManager/Widgets/addonmanager_widget_package_details_view.py
+++ b/src/Mod/AddonManager/Widgets/addonmanager_widget_package_details_view.py
@@ -1,7 +1,7 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
-# * Copyright (c) 2022-2024 FreeCAD Project Association *
+# * Copyright (c) 2022-2024 The FreeCAD Project Association AISBL *
# * *
# * This file is part of FreeCAD. *
# * *
@@ -21,6 +21,10 @@
# * *
# ***************************************************************************
+from dataclasses import dataclass
+from enum import Enum, auto
+import os
+from typing import Optional
try:
import FreeCAD
@@ -49,28 +53,286 @@ except ImportError:
from PySide import QtCore, QtWidgets
from .addonmanager_widget_addon_buttons import WidgetAddonButtons
+from .addonmanager_widget_readme_browser import WidgetReadmeBrowser
+from .addonmanager_colors import warning_color_string, attention_color_string, bright_color_string
+
+
+class MessageType(Enum):
+ Message = auto()
+ Warning = auto()
+ Error = auto()
+
+
+@dataclass
+class UpdateInformation:
+ check_in_progress: bool = False
+ update_available: bool = False
+ detached_head: bool = False
+ version: str = ""
+ tag: str = ""
+ branch: Optional[str] = None
+
+
+@dataclass
+class WarningFlags:
+ obsolete: bool = False
+ python2: bool = False
+ required_freecad_version: Optional[str] = None
+ non_osi_approved = False
+ non_fsf_libre = False
class PackageDetailsView(QtWidgets.QWidget):
"""The view class for the package details"""
- install_clicked = QtCore.Signal()
- uninstall_clicked = QtCore.Signal()
- enable_clicked = QtCore.Signal()
- disable_clicked = QtCore.Signal()
- update_clicked = QtCore.Signal()
- check_for_updates = QtCore.Signal()
- run_clicked = QtCore.Signal()
-
def __init__(self, parent: QtWidgets.QWidget = None):
super().__init__(parent)
self.button_bar = None
- self.text_browser = None
+ self.readme_browser = None
+ self.message_label = None
+ self.location_label = None
+ self.installed = False
+ self.disabled = False
+ self.update_info = UpdateInformation()
+ self.warning_flags = WarningFlags()
+ self.installed_version = None
+ self.installed_branch = None
+ self.installed_timestamp = None
+ self.can_disable = True
self._setup_ui()
def _setup_ui(self):
self.vertical_layout = QtWidgets.QVBoxLayout(self)
self.button_bar = WidgetAddonButtons(self)
- self.text_browser = QtWidgets.QTextBrowser(self)
+ self.readme_browser = WidgetReadmeBrowser(self)
+ self.message_label = QtWidgets.QLabel(self)
+ self.location_label = QtWidgets.QLabel(self)
+ self.location_label.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
self.vertical_layout.addWidget(self.button_bar)
- self.vertical_layout.addWidget(self.text_browser)
+ self.vertical_layout.addWidget(self.message_label)
+ self.vertical_layout.addWidget(self.location_label)
+ self.vertical_layout.addWidget(self.readme_browser)
+
+ def set_location(self, location: Optional[str]):
+ if location is not None:
+ text = (
+ translate("AddonsInstaller", "Installation location")
+ + ": "
+ + os.path.normpath(location)
+ )
+ self.location_label.setText(text)
+ self.location_label.show()
+ else:
+ self.location_label.hide()
+
+ def set_installed(
+ self,
+ installed: bool,
+ on_date: Optional[str] = None,
+ version: Optional[str] = None,
+ branch: Optional[str] = None,
+ ):
+ self.installed = installed
+ self.installed_timestamp = on_date
+ self.installed_version = version
+ self.installed_branch = branch
+ if not self.installed:
+ self.set_location(None)
+ self._sync_ui_state()
+
+ def set_update_available(self, info: UpdateInformation):
+ self.update_info = info
+ self._sync_ui_state()
+
+ def set_disabled(self, disabled: bool):
+ self.disabled = disabled
+ self._sync_ui_state()
+
+ def allow_disabling(self, allow: bool):
+ self.can_disable = allow
+ self._sync_ui_state()
+
+ def allow_running(self, show: bool):
+ self.button_bar.run_macro.setVisible(show)
+
+ def set_warning_flags(self, flags: WarningFlags):
+ self.warning_flags = flags
+ self._sync_ui_state()
+
+ def set_new_disabled_status(self, disabled: bool):
+ """If the user just changed the enabled/disabled state of the addon, display a message
+ indicating that will not take place until restart. Do not call except in a case of a
+ state change during this run."""
+
+ if disabled:
+ message = translate(
+ "AddonsInstaller", "This Addon will be disabled next time you restart FreeCAD."
+ )
+ else:
+ message = translate(
+ "AddonsInstaller", "This Addon will be enabled next time you restart FreeCAD."
+ )
+ self.message_label.setText(f"
{message}
")
+ self.message_label.setStyleSheet("color:" + attention_color_string())
+
+ def set_new_branch(self, branch: str):
+ """If the user just changed branches, update the message to show that a restart is
+ needed."""
+ message_string = ""
+ message_string += translate(
+ "AddonsInstaller", "Changed to branch '{}' -- please restart to use Addon."
+ ).format(branch)
+ message_string += "
"
+ self.message_label.setText(message_string)
+ self.message_label.setStyleSheet("color:" + attention_color_string())
+
+ def set_updated(self):
+ """If the user has just updated the addon but not yet restarted, show an indication that
+ we are awaiting a restart."""
+ message = translate(
+ "AddonsInstaller", "This Addon has been updated. Restart FreeCAD to see changes."
+ )
+ self.message_label.setText(f"{message}
")
+ self.message_label.setStyleSheet("color:" + attention_color_string())
+
+ def _sync_ui_state(self):
+ self._sync_button_state()
+ self._create_status_label_text()
+
+ def _sync_button_state(self):
+ self.button_bar.install.setVisible(not self.installed)
+ self.button_bar.uninstall.setVisible(self.installed)
+ if not self.installed:
+ self.button_bar.disable.hide()
+ self.button_bar.enable.hide()
+ self.button_bar.update.hide()
+ self.button_bar.check_for_update.hide()
+ else:
+ self.button_bar.update.setVisible(self.update_info.update_available)
+ if self.update_info.detached_head:
+ self.button_bar.check_for_update.hide()
+ else:
+ self.button_bar.check_for_update.setVisible(not self.update_info.update_available)
+ if self.can_disable:
+ self.button_bar.enable.setVisible(self.disabled)
+ self.button_bar.disable.setVisible(not self.disabled)
+ else:
+ self.button_bar.enable.hide()
+ self.button_bar.disable.hide()
+
+ def _create_status_label_text(self):
+ if self.installed:
+ installation_details = self._get_installation_details_string()
+ update_details = self._get_update_status_string()
+ message_text = f"{installation_details} {update_details}"
+ if self.disabled:
+ message_text += " [" + translate("AddonsInstaller", "Disabled") + "]"
+ self.message_label.setText(f"{message_text}
")
+ if self.disabled:
+ self.message_label.setStyleSheet("color:" + warning_color_string())
+ elif self.update_info.update_available:
+ self.message_label.setStyleSheet("color:" + attention_color_string())
+ else:
+ self.message_label.setStyleSheet("color:" + bright_color_string())
+ self.message_label.show()
+ elif self._there_are_warnings_to_show():
+ warnings = self._get_warning_string()
+ self.message_label.setText(f"{warnings}
")
+ self.message_label.setStyleSheet("color:" + warning_color_string())
+ self.message_label.show()
+ else:
+ self.message_label.hide()
+
+ def _get_installation_details_string(self) -> str:
+ version = self.installed_version
+ date = ""
+ installed_version_string = ""
+ if self.installed_timestamp:
+ date = QtCore.QLocale().toString(
+ QtCore.QDateTime.fromSecsSinceEpoch(int(round(self.installed_timestamp, 0))),
+ QtCore.QLocale.ShortFormat,
+ )
+ if version and date:
+ installed_version_string += (
+ translate("AddonsInstaller", "Version {version} installed on {date}").format(
+ version=version, date=date
+ )
+ + ". "
+ )
+ elif version:
+ installed_version_string += (
+ translate("AddonsInstaller", "Version {version} installed") + "."
+ ).format(version=version)
+ elif date:
+ installed_version_string += (
+ translate("AddonsInstaller", "Installed on {date}") + "."
+ ).format(date=date)
+ else:
+ installed_version_string += translate("AddonsInstaller", "Installed") + "."
+ return installed_version_string
+
+ def _get_update_status_string(self) -> str:
+ if self.update_info.check_in_progress:
+ return translate("AddonsInstaller", "Update check in progress") + "."
+ if self.update_info.detached_head:
+ return (
+ translate(
+ "AddonsInstaller", "Git tag '{}' checked out, no updates possible"
+ ).format(self.update_info.tag)
+ + "."
+ )
+ if self.update_info.update_available:
+ if self.installed_branch and self.update_info.branch:
+ if self.installed_branch != self.update_info.branch:
+ return (
+ translate(
+ "AddonsInstaller", "Currently on branch {}, name changed to {}"
+ ).format(self.installed_branch, self.update_info.branch)
+ + "."
+ )
+ if self.update_info.version:
+ return (
+ translate(
+ "AddonsInstaller",
+ "Currently on branch {}, update available to version {}",
+ ).format(self.installed_branch, str(self.update_info.version).strip())
+ + "."
+ )
+ return translate("AddonsInstaller", "Update available") + "."
+ if self.update_info.version:
+ return (
+ translate("AddonsInstaller", "Update available to version {}").format(
+ str(self.update_info.version).strip()
+ )
+ + "."
+ )
+ return translate("AddonsInstaller", "Update available") + "."
+ return translate("AddonsInstaller", "This is the latest version available") + "."
+
+ def _there_are_warnings_to_show(self) -> bool:
+ if self.disabled:
+ return True
+ if (
+ self.warning_flags.obsolete
+ or self.warning_flags.python2
+ or self.warning_flags.required_freecad_version
+ ):
+ return True
+ return False # TODO: Someday support optional warnings on license types
+
+ def _get_warning_string(self) -> str:
+ if self.installed and self.disabled:
+ return translate(
+ "AddonsInstaller",
+ "WARNING: This addon is currently installed, but disabled. Use the 'enable' "
+ "button to re-enable.",
+ )
+ if self.warning_flags.obsolete:
+ return translate("AddonsInstaller", "WARNING: This addon is obsolete")
+ if self.warning_flags.python2:
+ return translate("AddonsInstaller", "WARNING: This addon is Python 2 only")
+ if self.warning_flags.required_freecad_version:
+ return translate("AddonsInstaller", "WARNING: This addon requires FreeCAD {}").format(
+ self.warning_flags.required_freecad_version
+ )
+ return ""
diff --git a/src/Mod/AddonManager/Widgets/addonmanager_widget_readme_browser.py b/src/Mod/AddonManager/Widgets/addonmanager_widget_readme_browser.py
new file mode 100644
index 0000000000..8049a5a434
--- /dev/null
+++ b/src/Mod/AddonManager/Widgets/addonmanager_widget_readme_browser.py
@@ -0,0 +1,111 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# ***************************************************************************
+# * *
+# * Copyright (c) 2022-2024 The FreeCAD Project Association AISBL *
+# * *
+# * This file is part of FreeCAD. *
+# * *
+# * FreeCAD is free software: you can redistribute it and/or modify it *
+# * under the terms of the GNU Lesser General Public License as *
+# * published by the Free Software Foundation, either version 2.1 of the *
+# * License, or (at your option) any later version. *
+# * *
+# * FreeCAD 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 *
+# * Lesser General Public License for more details. *
+# * *
+# * You should have received a copy of the GNU Lesser General Public *
+# * License along with FreeCAD. If not, see *
+# * . *
+# * *
+# ***************************************************************************
+
+import FreeCAD
+
+# Get whatever version of PySide we can
+try:
+ import PySide # Use the FreeCAD wrapper
+except ImportError:
+ try:
+ import PySide6 # Outside FreeCAD, try Qt6 first
+
+ PySide = PySide6
+ except ImportError:
+ import PySide2 # Fall back to Qt5 (if this fails, Python will kill this module's import)
+
+ PySide = PySide2
+
+from PySide import QtCore, QtGui, QtWidgets
+
+from typing import Optional
+
+
+class WidgetReadmeBrowser(QtWidgets.QTextBrowser):
+ """A QTextBrowser widget that emits signals for each requested image resource, allowing an external controller
+ to load and re-deliver those images. Once all resources have been re-delivered, the original data is redisplayed
+ with the images in-line. Call setUrl prior to calling setMarkdown or setHtml to ensure URLs are resolved
+ correctly."""
+
+ load_resource = QtCore.Signal(str) # Str is a URL to a resource
+
+ def __init__(self, parent: QtWidgets.QWidget = None):
+ super().__init__(parent)
+ self.image_map = {}
+ self.url = ""
+ self.stop = False
+ self.setOpenExternalLinks(True)
+
+ def setUrl(self, url: str):
+ """Set the base URL of the page. Used to resolve relative URLs in the page source."""
+ self.url = url
+
+ def setMarkdown(self, md: str):
+ """Provides an optional fallback to the markdown library for older versions of Qt (prior to 5.15) that did not
+ have native markdown support. Lacking that, plaintext is displayed."""
+ if hasattr(super(), "setMarkdown"):
+ super().setMarkdown(md)
+ else:
+ try:
+ import markdown
+
+ html = markdown.markdown(md)
+ self.setHtml(html)
+ except ImportError:
+ self.setText(md)
+ FreeCAD.Console.Warning(
+ "Qt < 5.15 and no `import markdown` -- falling back to plain text display\n"
+ )
+
+ def set_resource(self, resource_url: str, image: Optional[QtGui.QImage]):
+ """Once a resource has been fetched (or the fetch has failed), this method should be used to inform the widget
+ that the resource has been loaded. Note that the incoming image is scaled to 97% of the widget width if it is
+ larger than that."""
+ self.image_map[resource_url] = self._ensure_appropriate_width(image)
+
+ def loadResource(self, resource_type: int, name: QtCore.QUrl) -> object:
+ """Callback for resource loading. Called automatically by underlying Qt
+ code when external resources are needed for rendering. In particular,
+ here it is used to download and cache (in RAM) the images needed for the
+ README and Wiki pages."""
+ if resource_type == QtGui.QTextDocument.ImageResource and not self.stop:
+ full_url = self._create_full_url(name.toString())
+ if full_url not in self.image_map:
+ self.load_resource.emit(full_url)
+ self.image_map[full_url] = None
+ return self.image_map[full_url]
+ return super().loadResource(resource_type, name)
+
+ def _ensure_appropriate_width(self, image: QtGui.QImage) -> QtGui.QImage:
+ ninety_seven_percent = self.width() * 0.97
+ if image.width() < ninety_seven_percent:
+ return image
+ return image.scaledToWidth(ninety_seven_percent)
+
+ def _create_full_url(self, url: str) -> str:
+ if url.startswith("http"):
+ return url
+ if not self.url:
+ return url
+ lhs, slash, _ = self.url.rpartition("/")
+ return lhs + slash + url
diff --git a/src/Mod/AddonManager/Widgets/addonmanager_widget_view_selector.py b/src/Mod/AddonManager/Widgets/addonmanager_widget_view_selector.py
index dca81ec6f8..2bfa935bd8 100644
--- a/src/Mod/AddonManager/Widgets/addonmanager_widget_view_selector.py
+++ b/src/Mod/AddonManager/Widgets/addonmanager_widget_view_selector.py
@@ -119,6 +119,7 @@ class WidgetViewSelector(QtWidgets.QWidget):
self.composite_button.setIcon(
QtGui.QIcon.fromTheme("composite_button", QtGui.QIcon(":/icons/composite_view.svg"))
)
+ self.composite_button.hide() # TODO: Implement this view
self.horizontal_layout.addWidget(self.compact_button)
self.horizontal_layout.addWidget(self.expanded_button)
diff --git a/src/Mod/AddonManager/__init__.py b/src/Mod/AddonManager/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/Mod/AddonManager/addonmanager_package_details_controller.py b/src/Mod/AddonManager/addonmanager_package_details_controller.py
new file mode 100644
index 0000000000..13cb5c90b4
--- /dev/null
+++ b/src/Mod/AddonManager/addonmanager_package_details_controller.py
@@ -0,0 +1,258 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# ***************************************************************************
+# * *
+# * Copyright (c) 2022-2024 The FreeCAD Project Association AISBL *
+# * *
+# * This file is part of FreeCAD. *
+# * *
+# * FreeCAD is free software: you can redistribute it and/or modify it *
+# * under the terms of the GNU Lesser General Public License as *
+# * published by the Free Software Foundation, either version 2.1 of the *
+# * License, or (at your option) any later version. *
+# * *
+# * FreeCAD 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 *
+# * Lesser General Public License for more details. *
+# * *
+# * You should have received a copy of the GNU Lesser General Public *
+# * License along with FreeCAD. If not, see *
+# * . *
+# * *
+# ***************************************************************************
+
+""" Provides the PackageDetails widget. """
+
+import os
+from typing import Optional
+
+from PySide import QtCore, QtWidgets
+
+import addonmanager_freecad_interface as fci
+
+import addonmanager_utilities as utils
+from addonmanager_metadata import (
+ Version,
+ get_first_supported_freecad_version,
+ get_branch_from_metadata,
+)
+from addonmanager_workers_startup import GetMacroDetailsWorker, CheckSingleUpdateWorker
+from addonmanager_git import GitManager, NoGitFound
+from Addon import Addon
+from change_branch import ChangeBranchDialog
+from addonmanager_readme_controller import ReadmeController
+from Widgets.addonmanager_widget_package_details_view import UpdateInformation, WarningFlags
+
+translate = fci.translate
+
+
+class PackageDetailsController(QtCore.QObject):
+ """Manages the display of the package README information."""
+
+ back = QtCore.Signal()
+ install = QtCore.Signal(Addon)
+ uninstall = QtCore.Signal(Addon)
+ update = QtCore.Signal(Addon)
+ execute = QtCore.Signal(Addon)
+ update_status = QtCore.Signal(Addon)
+ check_for_update = QtCore.Signal(Addon)
+
+ def __init__(self, widget=None):
+ super().__init__()
+ self.ui = widget
+ self.readme_controller = ReadmeController(self.ui.readme_browser)
+ self.worker = None
+ self.addon = None
+ self.status_update_thread = None
+ self.original_disabled_state = None
+ self.original_status = None
+ try:
+ self.git_manager = GitManager()
+ except NoGitFound:
+ self.git_manager = None
+
+ self.ui.button_bar.back.clicked.connect(self.back.emit)
+ self.ui.button_bar.run_macro.clicked.connect(lambda: self.execute.emit(self.addon))
+ self.ui.button_bar.install.clicked.connect(lambda: self.install.emit(self.addon))
+ self.ui.button_bar.uninstall.clicked.connect(lambda: self.uninstall.emit(self.addon))
+ self.ui.button_bar.update.clicked.connect(lambda: self.update.emit(self.addon))
+ self.ui.button_bar.check_for_update.clicked.connect(
+ lambda: self.check_for_update.emit(self.addon)
+ )
+ self.ui.button_bar.change_branch.clicked.connect(self.change_branch_clicked)
+ self.ui.button_bar.enable.clicked.connect(self.enable_clicked)
+ self.ui.button_bar.disable.clicked.connect(self.disable_clicked)
+
+ def show_repo(self, repo: Addon) -> None:
+ """The main entry point for this class, shows the package details and related buttons
+ for the provided repo."""
+ self.addon = repo
+ self.readme_controller.set_addon(repo)
+ self.original_disabled_state = self.addon.is_disabled()
+
+ if self.worker is not None:
+ if not self.worker.isFinished():
+ self.worker.requestInterruption()
+ self.worker.wait()
+
+ installed = self.addon.status() != Addon.Status.NOT_INSTALLED
+ self.ui.set_installed(installed)
+ update_info = UpdateInformation()
+ if installed:
+ update_info.update_available = self.addon.status() == Addon.Status.UPDATE_AVAILABLE
+ update_info.check_in_progress = False # TODO: Implement the "check in progress" status
+ if repo.metadata:
+ update_info.branch = get_branch_from_metadata(repo.metadata)
+ update_info.version = repo.metadata.version
+ elif repo.macro:
+ update_info.version = repo.macro.version
+ self.ui.set_update_available(update_info)
+ self.ui.set_location(os.path.join(self.addon.mod_directory, self.addon.name))
+ self.ui.set_location(os.path.join(self.addon.mod_directory, self.addon.name))
+ self.ui.set_disabled(self.addon.is_disabled())
+ self.ui.allow_running(repo.repo_type == Addon.Kind.MACRO)
+ self.ui.allow_disabling(repo.repo_type != Addon.Kind.MACRO)
+
+ if repo.repo_type == Addon.Kind.MACRO:
+ self.update_macro_info(repo)
+
+ if repo.status() == Addon.Status.UNCHECKED:
+ if not self.status_update_thread:
+ self.status_update_thread = QtCore.QThread()
+ self.status_create_addon_list_worker = CheckSingleUpdateWorker(repo)
+ self.status_create_addon_list_worker.moveToThread(self.status_update_thread)
+ self.status_update_thread.finished.connect(
+ self.status_create_addon_list_worker.deleteLater
+ )
+ self.check_for_update.connect(self.status_create_addon_list_worker.do_work)
+ self.status_create_addon_list_worker.update_status.connect(self.display_repo_status)
+ self.status_update_thread.start()
+ update_info.check_in_progress = True
+ self.ui.set_update_available(update_info)
+ self.check_for_update.emit(self.addon)
+
+ flags = WarningFlags()
+ flags.required_freecad_version = self.requires_newer_freecad()
+ flags.obsolete = repo.obsolete
+ flags.python2 = repo.python2
+ self.ui.set_warning_flags(flags)
+
+ def requires_newer_freecad(self) -> Optional[Version]:
+ """If the current package is not installed, returns the first supported version of
+ FreeCAD, if one is set, or None if no information is available (or if the package is
+ already installed)."""
+
+ # If it's not installed, check to see if it's for a newer version of FreeCAD
+ if self.addon.status() == Addon.Status.NOT_INSTALLED and self.addon.metadata:
+ # Only hide if ALL content items require a newer version, otherwise
+ # it's possible that this package actually provides versions of itself
+ # for newer and older versions
+
+ first_supported_version = get_first_supported_freecad_version(self.addon.metadata)
+ if first_supported_version is not None:
+ fc_version = Version(from_list=fci.Version())
+ if first_supported_version > fc_version:
+ return first_supported_version
+ return None
+
+ def set_change_branch_button_state(self):
+ """The change branch button is only available for installed Addons that have a .git directory
+ and in runs where the git is available."""
+
+ self.ui.button_bar.change_branch_button.hide()
+
+ pref = fci.ParamGet("User parameter:BaseApp/Preferences/Addons")
+ show_switcher = pref.GetBool("ShowBranchSwitcher", False)
+ if not show_switcher:
+ return
+
+ # Is this repo installed? If not, return.
+ if self.addon.status() == Addon.Status.NOT_INSTALLED:
+ return
+
+ # Is it a Macro? If so, return:
+ if self.addon.repo_type == Addon.Kind.MACRO:
+ return
+
+ # Can we actually switch branches? If not, return.
+ if not self.git_manager:
+ return
+
+ # Is there a .git subdirectory? If not, return.
+ basedir = fci.getUserAppDataDir()
+ path_to_git = os.path.join(basedir, "Mod", self.addon.name, ".git")
+ if not os.path.isdir(path_to_git):
+ return
+
+ # If all four above checks passed, then it's possible for us to switch
+ # branches, if there are any besides the one we are on: show the button
+ self.ui.button_bar.change_branch_button.show()
+
+ def update_macro_info(self, repo: Addon) -> None:
+ if not repo.macro.url:
+ # We need to populate the macro information... may as well do it while the user reads
+ # the wiki page
+ self.worker = GetMacroDetailsWorker(repo)
+ self.worker.readme_updated.connect(self.macro_readme_updated)
+ self.worker.start()
+
+ def change_branch_clicked(self) -> None:
+ """Loads the branch-switching dialog"""
+ basedir = fci.getUserAppDataDir()
+ path_to_repo = os.path.join(basedir, "Mod", self.addon.name)
+ change_branch_dialog = ChangeBranchDialog(path_to_repo, self.ui)
+ change_branch_dialog.branch_changed.connect(self.branch_changed)
+ change_branch_dialog.exec()
+
+ def enable_clicked(self) -> None:
+ """Called by the Enable button, enables this Addon and updates GUI to reflect
+ that status."""
+ self.addon.enable()
+ self.ui.set_disabled(False)
+ if self.original_disabled_state:
+ self.ui.set_new_disabled_status(False)
+ self.original_status = self.addon.status()
+ self.addon.set_status(Addon.Status.PENDING_RESTART)
+ else:
+ self.addon.set_status(self.original_status)
+ self.update_status.emit(self.addon)
+
+ def disable_clicked(self) -> None:
+ """Called by the Disable button, disables this Addon and updates the GUI to
+ reflect that status."""
+ self.addon.disable()
+ self.ui.set_disabled(True)
+ if not self.original_disabled_state:
+ self.ui.set_new_disabled_status(True)
+ self.original_status = self.addon.status()
+ self.addon.set_status(Addon.Status.PENDING_RESTART)
+ else:
+ self.addon.set_status(self.original_status)
+ self.update_status.emit(self.addon)
+
+ def branch_changed(self, name: str) -> None:
+ """Displays a dialog confirming the branch changed, and tries to access the
+ metadata file from that branch."""
+ QtWidgets.QMessageBox.information(
+ self.ui,
+ translate("AddonsInstaller", "Success"),
+ translate(
+ "AddonsInstaller",
+ "Branch change succeeded, please restart to use the new version.",
+ ),
+ )
+ # See if this branch has a package.xml file:
+ basedir = fci.getUserAppDataDir()
+ path_to_metadata = os.path.join(basedir, "Mod", self.addon.name, "package.xml")
+ if os.path.isfile(path_to_metadata):
+ self.addon.load_metadata_file(path_to_metadata)
+ self.addon.installed_version = self.addon.metadata.version
+ else:
+ self.addon.repo_type = Addon.Kind.WORKBENCH
+ self.addon.metadata = None
+ self.addon.installed_version = None
+ self.addon.updated_timestamp = QtCore.QDateTime.currentDateTime().toSecsSinceEpoch()
+ self.addon.branch = name
+ self.addon.set_status(Addon.Status.PENDING_RESTART)
+ self.ui.set_new_branch(name)
+ self.update_status.emit(self.addon)
diff --git a/src/Mod/AddonManager/addonmanager_readme_viewer.py b/src/Mod/AddonManager/addonmanager_readme_controller.py
similarity index 73%
rename from src/Mod/AddonManager/addonmanager_readme_viewer.py
rename to src/Mod/AddonManager/addonmanager_readme_controller.py
index fc3e9b19f6..1760a7ff6f 100644
--- a/src/Mod/AddonManager/addonmanager_readme_viewer.py
+++ b/src/Mod/AddonManager/addonmanager_readme_controller.py
@@ -23,50 +23,66 @@
""" A Qt Widget for displaying Addon README information """
-import Addon
-from PySide import QtCore, QtGui, QtWidgets
-from enum import Enum, auto
-from html.parser import HTMLParser
-
-import addonmanager_freecad_interface as fci
+import FreeCAD
+from Addon import Addon
import addonmanager_utilities as utils
+
+from enum import IntEnum, Enum, auto
+from html.parser import HTMLParser
+from typing import Optional
+
import NetworkManager
-translate = fci.translate
+translate = FreeCAD.Qt.translate
+
+from PySide import QtCore, QtGui
-class ReadmeViewer(QtWidgets.QTextBrowser):
+class ReadmeDataType(IntEnum):
+ PlainText = 0
+ Markdown = 1
+ Html = 2
- """A QTextBrowser widget that, when given an Addon, downloads the README data as appropriate
- and renders it with whatever technology is available (usually Qt's Markdown renderer for
- workbenches and its HTML renderer for Macros)."""
- def __init__(self, parent=None):
- super().__init__(parent)
+class ReadmeController(QtCore.QObject):
+
+ """A class that can provide README data from an Addon, possibly loading external resources such
+ as images"""
+
+ def __init__(self, widget):
+ super().__init__()
NetworkManager.InitializeNetworkManager()
NetworkManager.AM_NETWORK_MANAGER.completed.connect(self._download_completed)
self.readme_request_index = 0
self.resource_requests = {}
+ self.resource_failures = []
self.url = ""
- self.repo: Addon.Addon = None
- self.setOpenExternalLinks(True)
- self.setOpenLinks(True)
- self.image_map = {}
+ self.readme_data = None
+ self.readme_data_type = None
+ self.addon: Optional[Addon] = None
self.stop = True
+ self.widget = widget
+ self.widget.load_resource.connect(self.loadResource)
def set_addon(self, repo: Addon):
"""Set which Addon's information is displayed"""
- self.setPlainText(translate("AddonsInstaller", "Loading README data..."))
- self.repo = repo
+ self.addon = repo
self.stop = False
- if self.repo.repo_type == Addon.Addon.Kind.MACRO:
- self.url = self.repo.macro.wiki
+ self.readme_data = None
+ if self.addon.repo_type == Addon.Kind.MACRO:
+ self.url = self.addon.macro.wiki
if not self.url:
- self.url = self.repo.macro.url
+ self.url = self.addon.macro.url
else:
self.url = utils.get_readme_url(repo)
+ self.widget.setUrl(self.url)
+ self.widget.setText(
+ translate("AddonsInstaller", "Loading page for {} from {}...").format(
+ self.addon.display_name, self.url
+ )
+ )
self.readme_request_index = NetworkManager.AM_NETWORK_MANAGER.submit_unmonitored_get(
self.url
)
@@ -77,7 +93,7 @@ class ReadmeViewer(QtWidgets.QTextBrowser):
if code == 200: # HTTP success
self._process_package_download(data.data().decode("utf-8"))
else:
- self.setPlainText(
+ self.widget.setText(
translate(
"AddonsInstaller",
"Failed to download data from {} -- received response code {}.",
@@ -87,47 +103,42 @@ class ReadmeViewer(QtWidgets.QTextBrowser):
if code == 200:
self._process_resource_download(self.resource_requests[index], data.data())
else:
- self.image_map[self.resource_requests[index]] = None
+ FreeCAD.Console.PrintLog(f"Failed to load {self.resource_requests[index]}\n")
+ self.resource_failures.append(self.resource_requests[index])
del self.resource_requests[index]
if not self.resource_requests:
- self.set_addon(self.repo) # Trigger a reload of the page now with resources
+ if self.readme_data:
+ if self.readme_data_type == ReadmeDataType.Html:
+ self.widget.setHtml(self.readme_data)
+ elif self.readme_data_type == ReadmeDataType.Markdown:
+ self.widget.setMarkdown(self.readme_data)
+ else:
+ self.widget.setText(self.readme_data)
+ else:
+ self.set_addon(self.addon) # Trigger a reload of the page now with resources
def _process_package_download(self, data: str):
- if self.repo.repo_type == Addon.Addon.Kind.MACRO:
+ if self.addon.repo_type == Addon.Kind.MACRO:
parser = WikiCleaner()
parser.feed(data)
- self.setHtml(parser.final_html)
+ self.readme_data = parser.final_html
+ self.readme_data_type = ReadmeDataType.Html
+ self.widget.setHtml(parser.final_html)
else:
- # Check for recent Qt (e.g. Qt5.15 or later). Check can be removed when
- # we no longer support Ubuntu 20.04LTS for compiling.
- if hasattr(self, "setMarkdown"):
- self.setMarkdown(data)
- else:
- self.setPlainText(data)
+ self.readme_data = data
+ self.readme_data_type = ReadmeDataType.Markdown
+ self.widget.setMarkdown(data)
def _process_resource_download(self, resource_name: str, resource_data: bytes):
image = QtGui.QImage.fromData(resource_data)
- if image:
- self.image_map[resource_name] = self._ensure_appropriate_width(image)
- else:
- self.image_map[resource_name] = None
+ self.widget.set_resource(resource_name, image)
- def loadResource(self, resource_type: int, name: QtCore.QUrl) -> object:
- """Callback for resource loading. Called automatically by underlying Qt
- code when external resources are needed for rendering. In particular,
- here it is used to download and cache (in RAM) the images needed for the
- README and Wiki pages."""
- if resource_type == QtGui.QTextDocument.ImageResource and not self.stop:
- full_url = self._create_full_url(name.toString())
- if full_url not in self.image_map:
- self.image_map[full_url] = None
- fci.Console.PrintMessage(f"Downloading image from {full_url}...\n")
- index = NetworkManager.AM_NETWORK_MANAGER.submit_unmonitored_get(full_url)
- self.resource_requests[index] = full_url
- return self.image_map[full_url]
- return super().loadResource(resource_type, name)
+ def loadResource(self, full_url: str):
+ if full_url not in self.resource_failures:
+ index = NetworkManager.AM_NETWORK_MANAGER.submit_unmonitored_get(full_url)
+ self.resource_requests[index] = full_url
- def hideEvent(self, event: QtGui.QHideEvent):
+ def cancel_resource_loading(self):
self.stop = True
for request in self.resource_requests:
NetworkManager.AM_NETWORK_MANAGER.abort(request)
@@ -141,12 +152,6 @@ class ReadmeViewer(QtWidgets.QTextBrowser):
lhs, slash, _ = self.url.rpartition("/")
return lhs + slash + url
- def _ensure_appropriate_width(self, image: QtGui.QImage) -> QtGui.QImage:
- ninety_seven_percent = self.width() * 0.97
- if image.width() < ninety_seven_percent:
- return image
- return image.scaledToWidth(ninety_seven_percent)
-
class WikiCleaner(HTMLParser):
"""This HTML parser cleans up FreeCAD Macro Wiki Page for display in a
diff --git a/src/Mod/AddonManager/package_details.py b/src/Mod/AddonManager/package_details.py
deleted file mode 100644
index 2b6e4a824c..0000000000
--- a/src/Mod/AddonManager/package_details.py
+++ /dev/null
@@ -1,559 +0,0 @@
-# SPDX-License-Identifier: LGPL-2.1-or-later
-# ***************************************************************************
-# * *
-# * Copyright (c) 2022-2023 FreeCAD Project Association *
-# * *
-# * This file is part of FreeCAD. *
-# * *
-# * FreeCAD is free software: you can redistribute it and/or modify it *
-# * under the terms of the GNU Lesser General Public License as *
-# * published by the Free Software Foundation, either version 2.1 of the *
-# * License, or (at your option) any later version. *
-# * *
-# * FreeCAD 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 *
-# * Lesser General Public License for more details. *
-# * *
-# * You should have received a copy of the GNU Lesser General Public *
-# * License along with FreeCAD. If not, see *
-# * . *
-# * *
-# ***************************************************************************
-
-""" Provides the PackageDetails widget. """
-
-import os
-from typing import Optional
-
-from PySide import QtCore, QtGui, QtWidgets
-
-import addonmanager_freecad_interface as fci
-
-import addonmanager_utilities as utils
-from addonmanager_metadata import (
- Version,
- get_first_supported_freecad_version,
- get_branch_from_metadata,
-)
-from addonmanager_workers_startup import GetMacroDetailsWorker, CheckSingleUpdateWorker
-from addonmanager_readme_viewer import ReadmeViewer
-from addonmanager_git import GitManager, NoGitFound
-from Addon import Addon
-from change_branch import ChangeBranchDialog
-from Widgets.addonmanager_widget_addon_buttons import WidgetAddonButtons
-
-translate = fci.translate
-
-
-class PackageDetails(QtWidgets.QWidget):
- """The PackageDetails QWidget shows package README information and provides
- install, uninstall, and update buttons."""
-
- back = QtCore.Signal()
- install = QtCore.Signal(Addon)
- uninstall = QtCore.Signal(Addon)
- update = QtCore.Signal(Addon)
- execute = QtCore.Signal(Addon)
- update_status = QtCore.Signal(Addon)
- check_for_update = QtCore.Signal(Addon)
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self.ui = Ui_PackageDetails()
- self.ui.setupUi(self)
-
- self.worker = None
- self.repo = None
- self.status_update_thread = None
- try:
- self.git_manager = GitManager()
- except NoGitFound:
- self.git_manager = None
-
- self.ui.button_bar.back_button.clicked.connect(self.back.emit)
- self.ui.button_bar.run_macro_button.clicked.connect(lambda: self.execute.emit(self.repo))
- self.ui.button_bar.install_button.clicked.connect(lambda: self.install.emit(self.repo))
- self.ui.button_bar.uninstall_button.clicked.connect(lambda: self.uninstall.emit(self.repo))
- self.ui.button_bar.update_button.clicked.connect(lambda: self.update.emit(self.repo))
- self.ui.button_bar.check_for_update_button.clicked.connect(
- lambda: self.check_for_update.emit(self.repo)
- )
- self.ui.button_bar.change_branch_button.clicked.connect(self.change_branch_clicked)
- self.ui.button_bar.enable_button.clicked.connect(self.enable_clicked)
- self.ui.button_bar.disable_button.clicked.connect(self.disable_clicked)
-
- def show_repo(self, repo: Addon, reload: bool = False) -> None:
- """The main entry point for this class, shows the package details and related buttons
- for the provided repo. If reload is true, then even if this is already the current repo
- the data is reloaded."""
-
- # If this is the same repo we were already showing, we do not have to do the
- # expensive refetch unless reload is true
- if True or self.repo != repo or reload:
- self.repo = repo
-
- if self.worker is not None:
- if not self.worker.isFinished():
- self.worker.requestInterruption()
- self.worker.wait()
-
- if repo.repo_type == Addon.Kind.MACRO:
- self.show_macro(repo)
- self.ui.button_bar.run_macro_button.show()
- elif repo.repo_type == Addon.Kind.WORKBENCH:
- self.show_workbench(repo)
- self.ui.button_bar.run_macro_button.hide()
- elif repo.repo_type == Addon.Kind.PACKAGE:
- self.show_package(repo)
- self.ui.button_bar.run_macro_button.hide()
-
- if repo.status() == Addon.Status.UNCHECKED:
- if not self.status_update_thread:
- self.status_update_thread = QtCore.QThread()
- self.status_create_addon_list_worker = CheckSingleUpdateWorker(repo)
- self.status_create_addon_list_worker.moveToThread(self.status_update_thread)
- self.status_update_thread.finished.connect(
- self.status_create_addon_list_worker.deleteLater
- )
- self.check_for_update.connect(self.status_create_addon_list_worker.do_work)
- self.status_create_addon_list_worker.update_status.connect(self.display_repo_status)
- self.status_update_thread.start()
- self.check_for_update.emit(self.repo)
-
- self.display_repo_status(self.repo.update_status)
-
- def display_repo_status(self, status):
- """Updates the contents of the widget to display the current install status of the widget."""
- repo = self.repo
- self.set_change_branch_button_state()
- self.set_disable_button_state()
- if status != Addon.Status.NOT_INSTALLED:
- version = repo.installed_version
- date = ""
- installed_version_string = ""
- if repo.updated_timestamp:
- date = QtCore.QLocale().toString(
- QtCore.QDateTime.fromSecsSinceEpoch(int(round(repo.updated_timestamp, 0))),
- QtCore.QLocale.ShortFormat,
- )
- if version and date:
- installed_version_string += (
- translate("AddonsInstaller", "Version {version} installed on {date}").format(
- version=version, date=date
- )
- + ". "
- )
- elif version:
- installed_version_string += (
- translate("AddonsInstaller", "Version {version} installed") + ". "
- ).format(version=version)
- elif date:
- installed_version_string += (
- translate("AddonsInstaller", "Installed on {date}") + ". "
- ).format(date=date)
- else:
- installed_version_string += translate("AddonsInstaller", "Installed") + ". "
-
- if status == Addon.Status.UPDATE_AVAILABLE:
- if repo.metadata:
- name_change = False
- if repo.installed_metadata:
- old_branch = get_branch_from_metadata(repo.installed_metadata)
- new_branch = get_branch_from_metadata(repo.metadata)
- if old_branch != new_branch:
- installed_version_string += (
- ""
- + translate(
- "AddonsInstaller",
- "Currently on branch {}, name changed to {}",
- ).format(old_branch, new_branch)
- ) + ". "
- name_change = True
- if not name_change:
- installed_version_string += (
- ""
- + translate(
- "AddonsInstaller",
- "On branch {}, update available to version",
- ).format(repo.branch)
- + " "
- )
- installed_version_string += str(repo.metadata.version)
- installed_version_string += "."
- elif repo.macro and repo.macro.version:
- installed_version_string += (
- "" + translate("AddonsInstaller", "Update available to version") + " "
- )
- installed_version_string += repo.macro.version
- installed_version_string += "."
- else:
- installed_version_string += (
- ""
- + translate(
- "AddonsInstaller",
- "An update is available",
- )
- + "."
- )
- elif status == Addon.Status.NO_UPDATE_AVAILABLE:
- detached_head = False
- branch = repo.branch
- if self.git_manager and repo.repo_type != Addon.Kind.MACRO:
- basedir = fci.getUserAppDataDir()
- moddir = os.path.join(basedir, "Mod", repo.name)
- repo_path = os.path.join(moddir, ".git")
- if os.path.exists(repo_path):
- branch = self.git_manager.current_branch(repo_path)
- if self.git_manager.detached_head(repo_path):
- tag = self.git_manager.current_tag(repo_path)
- branch = tag
- detached_head = True
- if detached_head:
- installed_version_string += (
- translate(
- "AddonsInstaller",
- "Git tag '{}' checked out, no updates possible",
- ).format(branch)
- + "."
- )
- else:
- installed_version_string += (
- translate(
- "AddonsInstaller",
- "This is the latest version available for branch {}",
- ).format(branch)
- + "."
- )
- elif status == Addon.Status.PENDING_RESTART:
- installed_version_string += (
- translate("AddonsInstaller", "Updated, please restart FreeCAD to use") + "."
- )
- elif status == Addon.Status.UNCHECKED:
- pref = fci.ParamGet("User parameter:BaseApp/Preferences/Addons")
- autocheck = pref.GetBool("AutoCheck", False)
- if autocheck:
- installed_version_string += (
- translate("AddonsInstaller", "Update check in progress") + "."
- )
- else:
- installed_version_string += (
- translate("AddonsInstaller", "Automatic update checks disabled") + "."
- )
-
- installed_version_string += "
"
- self.ui.labelPackageDetails.setText(installed_version_string)
- if repo.status() == Addon.Status.UPDATE_AVAILABLE:
- self.ui.labelPackageDetails.setStyleSheet("color:" + utils.attention_color_string())
- else:
- self.ui.labelPackageDetails.setStyleSheet("color:" + utils.bright_color_string())
- self.ui.labelPackageDetails.show()
-
- if repo.macro is not None:
- moddir = fci.getUserMacroDir(True)
- else:
- basedir = fci.getUserAppDataDir()
- moddir = os.path.join(basedir, "Mod", repo.name)
- installationLocationString = (
- translate("AddonsInstaller", "Installation location")
- + ": "
- + os.path.normpath(moddir)
- )
-
- self.ui.labelInstallationLocation.setText(installationLocationString)
- self.ui.labelInstallationLocation.show()
- else:
- self.ui.labelPackageDetails.hide()
- self.ui.labelInstallationLocation.hide()
-
- if status == Addon.Status.NOT_INSTALLED:
- self.ui.button_bar.install_button.show()
- self.ui.button_bar.uninstall_button.hide()
- self.ui.button_bar.update_button.hide()
- self.ui.button_bar.check_for_update_button.hide()
- elif status == Addon.Status.NO_UPDATE_AVAILABLE:
- self.ui.button_bar.install_button.hide()
- self.ui.button_bar.uninstall_button.show()
- self.ui.button_bar.update_button.hide()
- self.ui.button_bar.check_for_update_button.hide()
- elif status == Addon.Status.UPDATE_AVAILABLE:
- self.ui.button_bar.install_button.hide()
- self.ui.button_bar.uninstall_button.show()
- self.ui.button_bar.update_button.show()
- self.ui.button_bar.check_for_update_button.hide()
- elif status == Addon.Status.UNCHECKED:
- self.ui.button_bar.install_button.hide()
- self.ui.button_bar.uninstall_button.show()
- self.ui.button_bar.update_button.hide()
- self.ui.button_bar.check_for_update_button.show()
- elif status == Addon.Status.PENDING_RESTART:
- self.ui.button_bar.install_button.hide()
- self.ui.button_bar.uninstall_button.show()
- self.ui.button_bar.update_button.hide()
- self.ui.button_bar.check_for_update_button.hide()
- elif status == Addon.Status.CANNOT_CHECK:
- self.ui.button_bar.install_button.hide()
- self.ui.button_bar.uninstall_button.show()
- self.ui.button_bar.update_button.show()
- self.ui.button_bar.check_for_update_button.hide()
-
- required_version = self.requires_newer_freecad()
- if repo.obsolete:
- self.ui.labelWarningInfo.show()
- self.ui.labelWarningInfo.setText(
- "" + translate("AddonsInstaller", "WARNING: This addon is obsolete") + "
"
- )
- self.ui.labelWarningInfo.setStyleSheet("color:" + utils.warning_color_string())
- elif repo.python2:
- self.ui.labelWarningInfo.show()
- self.ui.labelWarningInfo.setText(
- ""
- + translate("AddonsInstaller", "WARNING: This addon is Python 2 Only")
- + "
"
- )
- self.ui.labelWarningInfo.setStyleSheet("color:" + utils.warning_color_string())
- elif required_version:
- self.ui.labelWarningInfo.show()
- self.ui.labelWarningInfo.setText(
- ""
- + translate("AddonsInstaller", "WARNING: This addon requires FreeCAD ")
- + required_version
- + "
"
- )
- self.ui.labelWarningInfo.setStyleSheet("color:" + utils.warning_color_string())
- elif repo.is_disabled():
- self.ui.labelWarningInfo.show()
- self.ui.labelWarningInfo.setText(
- ""
- + translate(
- "AddonsInstaller",
- "WARNING: This addon is currently installed, but disabled. Use the 'enable' button to re-enable.",
- )
- + "
"
- )
- self.ui.labelWarningInfo.setStyleSheet("color:" + utils.warning_color_string())
-
- else:
- self.ui.labelWarningInfo.hide()
-
- def requires_newer_freecad(self) -> Optional[Version]:
- """If the current package is not installed, returns the first supported version of
- FreeCAD, if one is set, or None if no information is available (or if the package is
- already installed)."""
-
- # If it's not installed, check to see if it's for a newer version of FreeCAD
- if self.repo.status() == Addon.Status.NOT_INSTALLED and self.repo.metadata:
- # Only hide if ALL content items require a newer version, otherwise
- # it's possible that this package actually provides versions of itself
- # for newer and older versions
-
- first_supported_version = get_first_supported_freecad_version(self.repo.metadata)
- if first_supported_version is not None:
- fc_version = Version(from_list=fci.Version())
- if first_supported_version > fc_version:
- return first_supported_version
- return None
-
- def set_change_branch_button_state(self):
- """The change branch button is only available for installed Addons that have a .git directory
- and in runs where the git is available."""
-
- self.ui.button_bar.change_branch_button.hide()
-
- pref = fci.ParamGet("User parameter:BaseApp/Preferences/Addons")
- show_switcher = pref.GetBool("ShowBranchSwitcher", False)
- if not show_switcher:
- return
-
- # Is this repo installed? If not, return.
- if self.repo.status() == Addon.Status.NOT_INSTALLED:
- return
-
- # Is it a Macro? If so, return:
- if self.repo.repo_type == Addon.Kind.MACRO:
- return
-
- # Can we actually switch branches? If not, return.
- if not self.git_manager:
- return
-
- # Is there a .git subdirectory? If not, return.
- basedir = fci.getUserAppDataDir()
- path_to_git = os.path.join(basedir, "Mod", self.repo.name, ".git")
- if not os.path.isdir(path_to_git):
- return
-
- # If all four above checks passed, then it's possible for us to switch
- # branches, if there are any besides the one we are on: show the button
- self.ui.button_bar.change_branch_button.show()
-
- def set_disable_button_state(self):
- """Set up the enable/disable button based on the enabled/disabled state of the addon"""
- self.ui.button_bar.enable_button.hide()
- self.ui.button_bar.disable_button.hide()
- status = self.repo.status()
- if status != Addon.Status.NOT_INSTALLED:
- disabled = self.repo.is_disabled()
- if disabled:
- self.ui.button_bar.enable_button.show()
- else:
- self.ui.button_bar.disable_button.show()
-
- def show_workbench(self, repo: Addon) -> None:
- """loads information of a given workbench"""
-
- self.ui.textBrowserReadMe.set_addon(repo)
-
- def show_package(self, repo: Addon) -> None:
- """Show the details for a package (a repo with a package.xml metadata file)"""
-
- self.ui.textBrowserReadMe.set_addon(repo)
-
- def show_macro(self, repo: Addon) -> None:
- """loads information of a given macro"""
-
- if not repo.macro.url:
- # We need to populate the macro information... may as well do it while the user reads the wiki page
- self.worker = GetMacroDetailsWorker(repo)
- self.worker.readme_updated.connect(self.macro_readme_updated)
- self.worker.start()
- else:
- self.macro_readme_updated()
-
- def macro_readme_updated(self):
- """Update the display of a Macro's README data."""
-
- self.ui.textBrowserReadMe.set_addon(self.repo)
-
- def change_branch_clicked(self) -> None:
- """Loads the branch-switching dialog"""
- basedir = fci.getUserAppDataDir()
- path_to_repo = os.path.join(basedir, "Mod", self.repo.name)
- change_branch_dialog = ChangeBranchDialog(path_to_repo, self)
- change_branch_dialog.branch_changed.connect(self.branch_changed)
- change_branch_dialog.exec()
-
- def enable_clicked(self) -> None:
- """Called by the Enable button, enables this Addon and updates GUI to reflect
- that status."""
- self.repo.enable()
- self.repo.set_status(Addon.Status.PENDING_RESTART)
- self.set_disable_button_state()
- self.update_status.emit(self.repo)
- self.ui.labelWarningInfo.show()
- self.ui.labelWarningInfo.setText(
- ""
- + translate(
- "AddonsInstaller",
- "This Addon will be enabled next time you restart FreeCAD.",
- )
- + "
"
- )
- self.ui.labelWarningInfo.setStyleSheet("color:" + utils.bright_color_string())
-
- def disable_clicked(self) -> None:
- """Called by the Disable button, disables this Addon and updates the GUI to
- reflect that status."""
- self.repo.disable()
- self.repo.set_status(Addon.Status.PENDING_RESTART)
- self.set_disable_button_state()
- self.update_status.emit(self.repo)
- self.ui.labelWarningInfo.show()
- self.ui.labelWarningInfo.setText(
- ""
- + translate(
- "AddonsInstaller",
- "This Addon will be disabled next time you restart FreeCAD.",
- )
- + "
"
- )
- self.ui.labelWarningInfo.setStyleSheet("color:" + utils.attention_color_string())
-
- def branch_changed(self, name: str) -> None:
- """Displays a dialog confirming the branch changed, and tries to access the
- metadata file from that branch."""
- QtWidgets.QMessageBox.information(
- self,
- translate("AddonsInstaller", "Success"),
- translate(
- "AddonsInstaller",
- "Branch change succeeded, please restart to use the new version.",
- ),
- )
- # See if this branch has a package.xml file:
- basedir = fci.getUserAppDataDir()
- path_to_metadata = os.path.join(basedir, "Mod", self.repo.name, "package.xml")
- if os.path.isfile(path_to_metadata):
- self.repo.load_metadata_file(path_to_metadata)
- self.repo.installed_version = self.repo.metadata.version
- else:
- self.repo.repo_type = Addon.Kind.WORKBENCH
- self.repo.metadata = None
- self.repo.installed_version = None
- self.repo.updated_timestamp = QtCore.QDateTime.currentDateTime().toSecsSinceEpoch()
- self.repo.branch = name
- self.repo.set_status(Addon.Status.PENDING_RESTART)
-
- installed_version_string = ""
- installed_version_string += translate(
- "AddonsInstaller", "Changed to git ref '{}' -- please restart to use Addon."
- ).format(name)
- installed_version_string += "
"
- self.ui.labelPackageDetails.setText(installed_version_string)
- self.ui.labelPackageDetails.setStyleSheet("color:" + utils.attention_color_string())
- self.update_status.emit(self.repo)
-
-
-class Ui_PackageDetails(object):
- """The generated UI from the Qt Designer UI file"""
-
- def setupUi(self, PackageDetails):
- if not PackageDetails.objectName():
- PackageDetails.setObjectName("PackageDetails")
- self.verticalLayout_2 = QtWidgets.QVBoxLayout(PackageDetails)
- self.verticalLayout_2.setObjectName("verticalLayout_2")
- self.layoutDetailsBackButton = QtWidgets.QHBoxLayout()
- self.layoutDetailsBackButton.setObjectName("layoutDetailsBackButton")
-
- self.button_bar = WidgetAddonButtons(PackageDetails)
- self.layoutDetailsBackButton.addWidget(self.button_bar)
-
- self.verticalLayout_2.addLayout(self.layoutDetailsBackButton)
-
- self.labelPackageDetails = QtWidgets.QLabel(PackageDetails)
- self.labelPackageDetails.hide()
-
- self.verticalLayout_2.addWidget(self.labelPackageDetails)
-
- self.labelInstallationLocation = QtWidgets.QLabel(PackageDetails)
- self.labelInstallationLocation.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
- self.labelInstallationLocation.hide()
-
- self.verticalLayout_2.addWidget(self.labelInstallationLocation)
-
- self.labelWarningInfo = QtWidgets.QLabel(PackageDetails)
- self.labelWarningInfo.hide()
-
- self.verticalLayout_2.addWidget(self.labelWarningInfo)
-
- sizePolicy1 = QtWidgets.QSizePolicy(
- QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding
- )
- sizePolicy1.setHorizontalStretch(0)
- sizePolicy1.setVerticalStretch(0)
-
- self.textBrowserReadMe = ReadmeViewer(PackageDetails)
- self.textBrowserReadMe.setObjectName("textBrowserReadMe")
-
- self.verticalLayout_2.addWidget(self.textBrowserReadMe)
-
- self.retranslateUi(PackageDetails)
-
- QtCore.QMetaObject.connectSlotsByName(PackageDetails)
-
- # setupUi
-
- def retranslateUi(self, _):
- pass
-
- # retranslateUi
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 0000000000..e69de29bb2