Addon Manager: Break up ReadmeViewer into view and controller

Addon Manager: Cleanup enable/disable message
This commit is contained in:
Chris Hennes
2024-02-07 22:08:42 -06:00
committed by Chris Hennes
parent 20a01cfc9c
commit 9812548b68
15 changed files with 811 additions and 679 deletions

View File

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

View File

@@ -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 *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
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)"

View File

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

View File

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

View File

@@ -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"<h3>{message}</h3>")
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 = "<h3>"
message_string += translate(
"AddonsInstaller", "Changed to branch '{}' -- please restart to use Addon."
).format(branch)
message_string += "</h3>"
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"<h3>{message}</h3>")
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"<h3>{message_text}</h3>")
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"<h3>{warnings}</h3>")
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 ""

View File

@@ -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 *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
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

View File

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