From 9812548b68d4f31efdd32872ff758173f6fc4658 Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Wed, 7 Feb 2024 22:08:42 -0600 Subject: [PATCH] Addon Manager: Break up ReadmeViewer into view and controller Addon Manager: Cleanup enable/disable message --- src/Mod/AddonManager/AddonManager.py | 26 +- src/Mod/AddonManager/CMakeLists.txt | 5 +- src/Mod/AddonManager/NetworkManager.py | 12 +- src/Mod/AddonManager/Widgets/CMakeLists.txt | 3 + .../Widgets/addonmanager_colors.py | 48 ++ .../addonmanager_widget_addon_buttons.py | 58 +- .../addonmanager_widget_global_buttons.py | 2 +- ...ddonmanager_widget_package_details_view.py | 286 ++++++++- .../addonmanager_widget_readme_browser.py | 111 ++++ .../addonmanager_widget_view_selector.py | 1 + src/Mod/AddonManager/__init__.py | 0 ...addonmanager_package_details_controller.py | 258 ++++++++ ...r.py => addonmanager_readme_controller.py} | 121 ++-- src/Mod/AddonManager/package_details.py | 559 ------------------ src/__init__.py | 0 15 files changed, 811 insertions(+), 679 deletions(-) create mode 100644 src/Mod/AddonManager/Widgets/addonmanager_colors.py create mode 100644 src/Mod/AddonManager/Widgets/addonmanager_widget_readme_browser.py create mode 100644 src/Mod/AddonManager/__init__.py create mode 100644 src/Mod/AddonManager/addonmanager_package_details_controller.py rename src/Mod/AddonManager/{addonmanager_readme_viewer.py => addonmanager_readme_controller.py} (73%) delete mode 100644 src/Mod/AddonManager/package_details.py create mode 100644 src/__init__.py 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