diff --git a/src/Mod/AddonManager/AddonManager.py b/src/Mod/AddonManager/AddonManager.py
index 13fcfb6e0f..fad624cbdb 100644
--- a/src/Mod/AddonManager/AddonManager.py
+++ b/src/Mod/AddonManager/AddonManager.py
@@ -481,7 +481,7 @@ class CommandAddonManager:
def activate_table_widgets(self) -> None:
self.packageList.setEnabled(True)
- self.packageList.ui.lineEditFilter.setFocus()
+ self.packageList.ui.search_box.setFocus()
self.do_next_startup_phase()
def populate_macros(self) -> None:
@@ -824,7 +824,7 @@ class CommandAddonManager:
self.dialog.labelStatusInfo.hide()
self.dialog.progressBar.hide()
self.dialog.buttonPauseUpdate.hide()
- self.packageList.ui.lineEditFilter.setFocus()
+ self.packageList.ui.search_box.setFocus()
def show_progress_widgets(self) -> None:
if self.dialog.progressBar.isHidden():
diff --git a/src/Mod/AddonManager/CMakeLists.txt b/src/Mod/AddonManager/CMakeLists.txt
index bedb988620..1948b69d14 100644
--- a/src/Mod/AddonManager/CMakeLists.txt
+++ b/src/Mod/AddonManager/CMakeLists.txt
@@ -1,5 +1,6 @@
IF (BUILD_GUI)
PYSIDE_WRAP_RC(AddonManager_QRC_SRCS Resources/AddonManager.qrc)
+ add_subdirectory(Widgets)
ENDIF (BUILD_GUI)
SET(AddonManager_SRCS
diff --git a/src/Mod/AddonManager/Resources/AddonManager.qrc b/src/Mod/AddonManager/Resources/AddonManager.qrc
index 2d006a46b0..e9b5a90e90 100644
--- a/src/Mod/AddonManager/Resources/AddonManager.qrc
+++ b/src/Mod/AddonManager/Resources/AddonManager.qrc
@@ -64,6 +64,7 @@
icons/workfeature_workbench_icon.svg
icons/yaml-workspace_workbench_icon.svg
icons/compact_view.svg
+ icons/composite_view.svg
icons/expanded_view.svg
licenses/Apache-2.0.txt
licenses/BSD-2-Clause.txt
diff --git a/src/Mod/AddonManager/Resources/icons/composite_view.svg b/src/Mod/AddonManager/Resources/icons/composite_view.svg
new file mode 100644
index 0000000000..7f1bf475be
--- /dev/null
+++ b/src/Mod/AddonManager/Resources/icons/composite_view.svg
@@ -0,0 +1,13 @@
+
+
+
diff --git a/src/Mod/AddonManager/Widgets/CMakeLists.txt b/src/Mod/AddonManager/Widgets/CMakeLists.txt
new file mode 100644
index 0000000000..2f1f6762a5
--- /dev/null
+++ b/src/Mod/AddonManager/Widgets/CMakeLists.txt
@@ -0,0 +1,22 @@
+SET(AddonManagerWidget_SRCS
+ __init__.py
+ addonmanager_widget_filter_selector.py
+ addonmanager_widget_search.py
+ addonmanager_widget_top_bar.py
+ addonmanager_widget_view_selector.py
+)
+
+SOURCE_GROUP("" FILES ${AddonManagerWidget_SRCS})
+
+ADD_CUSTOM_TARGET(AddonManagerWidget ALL
+ SOURCES ${AddonManagerWidget_SRCS}
+)
+
+fc_copy_sources(AddonManagerWidget "${CMAKE_BINARY_DIR}/Mod/AddonManager/Widgets" ${AddonManagerWidget_SRCS})
+
+INSTALL(
+ FILES
+ ${AddonManagerWidget_SRCS}
+ DESTINATION
+ Mod/AddonManager/Widgets
+)
diff --git a/src/Mod/AddonManager/Widgets/__init__.py b/src/Mod/AddonManager/Widgets/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/Mod/AddonManager/Widgets/addonmanager_widget_filter_selector.py b/src/Mod/AddonManager/Widgets/addonmanager_widget_filter_selector.py
new file mode 100644
index 0000000000..906dc02e57
--- /dev/null
+++ b/src/Mod/AddonManager/Widgets/addonmanager_widget_filter_selector.py
@@ -0,0 +1,243 @@
+# 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 *
+# * . *
+# * *
+# ***************************************************************************
+
+""" Defines a QWidget-derived class for displaying the view selection buttons. """
+
+from enum import IntEnum
+
+try:
+ import FreeCAD
+
+ translate = FreeCAD.Qt.translate
+except ImportError:
+ FreeCAD = None
+
+ def translate(_: str, text: str):
+ return text
+
+
+# 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, QtWidgets
+
+
+class FilterType(IntEnum):
+ """There are currently two sections in this drop down, for two different types of filters."""
+
+ PACKAGE_CONTENTS = 0
+ INSTALLATION_STATUS = 1
+
+
+class StatusFilter(IntEnum):
+ """Predefined filters for status"""
+
+ ANY = 0
+ INSTALLED = 1
+ NOT_INSTALLED = 2
+ UPDATE_AVAILABLE = 3
+
+
+class ContentFilter(IntEnum):
+ """Predefined filters for addon content type"""
+
+ ANY = 0
+ WORKBENCH = 1
+ MACRO = 2
+ PREFERENCE_PACK = 3
+
+
+class Filter:
+ def __init__(self):
+ self.status_filter = StatusFilter.ANY
+ self.content_filter = ContentFilter.ANY
+
+
+class WidgetFilterSelector(QtWidgets.QComboBox):
+ """A label and menu for selecting what sort of addons are displayed"""
+
+ filter_changed = QtCore.Signal(object) # technically, actually class Filter
+
+ def __init__(self, parent: QtWidgets.QWidget = None):
+ super().__init__(parent)
+ self.addon_type_index = 0
+ self.installation_status_index = 0
+ self._setup_ui()
+ self._setup_connections()
+ self.retranslateUi(None)
+ self.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContents)
+
+ def _setup_ui(self):
+ self._build_menu()
+
+ def _build_menu(self):
+ self.clear()
+ self.addItem(translate("AddonsInstaller", "Filter by..."))
+ self.insertSeparator(self.count())
+ self.addItem(translate("AddonsInstaller", "Addon Type"))
+ self.addon_type_index = self.count() - 1
+ self.addItem(
+ translate("AddonsInstaller", "Any"), (FilterType.PACKAGE_CONTENTS, ContentFilter.ANY)
+ )
+ self.addItem(
+ translate("AddonsInstaller", "Workbench"),
+ (FilterType.PACKAGE_CONTENTS, ContentFilter.WORKBENCH),
+ )
+ self.addItem(
+ translate("AddonsInstaller", "Macro"),
+ (FilterType.PACKAGE_CONTENTS, ContentFilter.MACRO),
+ )
+ self.addItem(
+ translate("AddonsInstaller", "Preference Pack"),
+ (FilterType.PACKAGE_CONTENTS, ContentFilter.PREFERENCE_PACK),
+ )
+ self.insertSeparator(self.count())
+ self.addItem(translate("AddonsInstaller", "Installation Status"))
+ self.installation_status_index = self.count() - 1
+ self.addItem(
+ translate("AddonsInstaller", "Any"), (FilterType.INSTALLATION_STATUS, StatusFilter.ANY)
+ )
+ self.addItem(
+ translate("AddonsInstaller", "Not installed"),
+ (FilterType.INSTALLATION_STATUS, StatusFilter.NOT_INSTALLED),
+ )
+ self.addItem(
+ translate("AddonsInstaller", "Installed"),
+ (FilterType.INSTALLATION_STATUS, StatusFilter.INSTALLED),
+ )
+ self.addItem(
+ translate("AddonsInstaller", "Update available"),
+ (FilterType.INSTALLATION_STATUS, StatusFilter.UPDATE_AVAILABLE),
+ )
+ model: QtCore.QAbstractItemModel = self.model()
+ for row in range(model.rowCount()):
+ if row <= self.addon_type_index:
+ model.item(row).setEnabled(False)
+ elif row < self.installation_status_index:
+ item = model.item(row)
+ item.setCheckState(QtCore.Qt.Unchecked)
+ elif row == self.installation_status_index:
+ model.item(row).setEnabled(False)
+ else:
+ item = model.item(row)
+ item.setCheckState(QtCore.Qt.Unchecked)
+
+ for row in range(model.rowCount()):
+ data = self.itemData(row)
+ if data:
+ item = model.item(row)
+ if data[0] == FilterType.PACKAGE_CONTENTS and data[1] == ContentFilter.ANY:
+ item.setCheckState(QtCore.Qt.Checked)
+ elif data[0] == FilterType.INSTALLATION_STATUS and data[1] == StatusFilter.ANY:
+ item.setCheckState(QtCore.Qt.Checked)
+ else:
+ item.setCheckState(QtCore.Qt.Unchecked)
+
+ def set_contents_filter(self, contents_filter: ContentFilter):
+ model = self.model()
+ for row in range(model.rowCount()):
+ item = model.item(row)
+ user_data = self.itemData(row)
+ if user_data and user_data[0] == FilterType.PACKAGE_CONTENTS:
+ if user_data[1] == contents_filter:
+ item.setCheckState(QtCore.Qt.Checked)
+ else:
+ item.setCheckState(QtCore.Qt.Unchecked)
+
+ def set_status_filter(self, status_filter: StatusFilter):
+ model = self.model()
+ for row in range(model.rowCount()):
+ item = model.item(row)
+ user_data = self.itemData(row)
+ if user_data and user_data[0] == FilterType.INSTALLATION_STATUS:
+ if user_data[1] == status_filter:
+ item.setCheckState(QtCore.Qt.Checked)
+ else:
+ item.setCheckState(QtCore.Qt.Unchecked)
+
+ def _setup_connections(self):
+ self.activated.connect(self._selected)
+
+ def retranslateUi(self, _):
+ self._build_menu()
+
+ def _selected(self, row: int):
+ if row == 0:
+ return
+ if row == self.installation_status_index or row == self.addon_type_index:
+ self.setCurrentIndex(0)
+ return
+ model = self.model()
+ selected_data = self.itemData(row)
+ if not selected_data:
+ return
+ selected_row_type = selected_data[0]
+
+ for row in range(model.rowCount()):
+ item = model.item(row)
+ user_data = self.itemData(row)
+ if user_data and user_data[0] == selected_row_type:
+ if user_data[1] == selected_data[1]:
+ item.setCheckState(QtCore.Qt.Checked)
+ else:
+ item.setCheckState(QtCore.Qt.Unchecked)
+ self._emit_current_filter()
+ self.setCurrentIndex(0)
+ self._update_first_row_text()
+
+ def _emit_current_filter(self):
+ model = self.model()
+ new_filter = Filter()
+ for row in range(model.rowCount()):
+ item = model.item(row)
+ data = self.itemData(row)
+ if data and item.checkState() == QtCore.Qt.Checked:
+ if data[0] == FilterType.INSTALLATION_STATUS:
+ new_filter.status_filter = data[1]
+ elif data[0] == FilterType.PACKAGE_CONTENTS:
+ new_filter.content_filter = data[1]
+ self.filter_changed.emit(new_filter)
+
+ def _update_first_row_text(self):
+ model = self.model()
+ state1 = ""
+ state2 = ""
+ for row in range(model.rowCount()):
+ item = model.item(row)
+ if item.checkState() == QtCore.Qt.Checked:
+ if not state1:
+ state1 = item.text()
+ else:
+ state2 = item.text()
+ break
+ model.item(0).setText(translate("AddonsInstaller", "Filter") + f": {state1}, {state2}")
diff --git a/src/Mod/AddonManager/Widgets/addonmanager_widget_search.py b/src/Mod/AddonManager/Widgets/addonmanager_widget_search.py
new file mode 100644
index 0000000000..12c0ffd52b
--- /dev/null
+++ b/src/Mod/AddonManager/Widgets/addonmanager_widget_search.py
@@ -0,0 +1,103 @@
+# 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 *
+# * . *
+# * *
+# ***************************************************************************
+
+""" Defines a QWidget-derived class for displaying the view selection buttons. """
+
+try:
+ import FreeCAD
+
+ translate = FreeCAD.Qt.translate
+except ImportError:
+ FreeCAD = None
+
+ def translate(_: str, text: str):
+ return text
+
+
+# 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
+
+
+class WidgetSearch(QtWidgets.QWidget):
+ """A widget for selecting the Addon Manager's primary view mode"""
+
+ search_changed = QtCore.Signal(str)
+
+ def __init__(self, parent: QtWidgets.QWidget = None):
+ super().__init__(parent)
+ self._setup_ui()
+ self._setup_connections()
+ self.retranslateUi(None)
+
+ def _setup_ui(self):
+ self.horizontal_layout = QtWidgets.QHBoxLayout()
+ self.filter_line_edit = QtWidgets.QLineEdit(self)
+ self.filter_line_edit.setClearButtonEnabled(True)
+ self.horizontal_layout.addWidget(self.filter_line_edit)
+ self.filter_validity_label = QtWidgets.QLabel(self)
+ self.horizontal_layout.addWidget(self.filter_validity_label)
+ self.filter_validity_label.hide() # This widget starts hidden
+ self.setLayout(self.horizontal_layout)
+
+ def _setup_connections(self):
+ self.filter_line_edit.textChanged.connect(self.set_text_filter)
+
+ def set_text_filter(self, text_filter: str) -> None:
+ """Set the current filter. If the filter is valid, this will emit a filter_changed
+ signal. text_filter may be regular expression."""
+
+ if text_filter:
+ test_regex = QtCore.QRegularExpression(text_filter)
+ if test_regex.isValid():
+ self.filter_validity_label.setToolTip(
+ translate("AddonsInstaller", "Filter is valid")
+ )
+ icon = QtGui.QIcon.fromTheme("ok", QtGui.QIcon(":/icons/edit_OK.svg"))
+ self.filter_validity_label.setPixmap(icon.pixmap(16, 16))
+ else:
+ self.filter_validity_label.setToolTip(
+ translate("AddonsInstaller", "Filter regular expression is invalid")
+ )
+ icon = QtGui.QIcon.fromTheme("cancel", QtGui.QIcon(":/icons/edit_Cancel.svg"))
+ self.filter_validity_label.setPixmap(icon.pixmap(16, 16))
+ self.filter_validity_label.show()
+ else:
+ self.filter_validity_label.hide()
+ self.search_changed.emit(text_filter)
+
+ def retranslateUi(self, _):
+ self.filter_line_edit.setPlaceholderText(
+ QtCore.QCoreApplication.translate("AddonsInstaller", "Filter", None)
+ )
diff --git a/src/Mod/AddonManager/Widgets/addonmanager_widget_top_bar.py b/src/Mod/AddonManager/Widgets/addonmanager_widget_top_bar.py
new file mode 100644
index 0000000000..d1c6993d7e
--- /dev/null
+++ b/src/Mod/AddonManager/Widgets/addonmanager_widget_top_bar.py
@@ -0,0 +1,159 @@
+# 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 *
+# * . *
+# * *
+# ***************************************************************************
+
+""" Defines a class derived from QWidget for displaying the bar at the top of the addons list. """
+
+from enum import IntEnum
+from addonmanager_widget_view_selector import WidgetViewSelector
+
+# 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, QtWidgets
+
+translate = FreeCAD.Qt.translate
+
+
+class StatusFilter(IntEnum):
+ """Predefined filers"""
+
+ ANY = 0
+ INSTALLED = 1
+ NOT_INSTALLED = 2
+ UPDATE_AVAILABLE = 3
+
+
+# pylint: disable=too-few-public-methods
+
+
+class WidgetTopBar(QtWidgets.QWidget):
+ """A widget to display the buttons at the top of the Addon manager, for changing the view,
+ filtering, and sorting."""
+
+ view_changed = QtCore.Signal(int)
+ filter_changed = QtCore.Signal(str)
+ search_changed = QtCore.Signal(str)
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.horizontal_layout = None
+ self.package_type_label = None
+ self.package_type_combobox = None
+ self._setup_ui()
+ self._setup_connections()
+ self.retranslateUi()
+
+ def _setup_ui(self):
+ self.horizontal_layout = QtWidgets.QHBoxLayout()
+
+ self.view_selector = WidgetViewSelector(self)
+ self.horizontal_layout.addWidget(self.view_selector)
+
+ self.package_type_label = QtWidgets.QLabel(self)
+ self.horizontal_layout.addWidget(self.package_type_label)
+
+ self.package_type_combobox = QtWidgets.QComboBox(self)
+ self.horizontal_layout.addWidget(self.package_type_combobox)
+
+ self.status_combo_box = QtWidgets.QComboBox(self)
+ self.horizontal_layout.addWidget(self.status_combo_box)
+
+ self.filter_line_edit = QtWidgets.QLineEdit(self)
+ self.horizontal_layout.addWidget(self.filter_line_edit)
+
+ # Only shows when the user types in a filter
+ self.ui.filter_validity_label = QtWidgets.QLabel(self)
+ self.horizontal_layout.addWidget(self.filter_validity_label)
+ self.ui.filter_validity_label.hide()
+
+ def _setup_connections(self):
+ self.ui.view_selector.view_changed.connect(self.view_changed.emit)
+
+ # Set up the view the same as the last time:
+ pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
+ package_type = pref.GetInt("PackageTypeSelection", 1)
+ self.ui.comboPackageType.setCurrentIndex(package_type)
+ status = pref.GetInt("StatusSelection", 0)
+ self.ui.comboStatus.setCurrentIndex(status)
+
+ def update_type_filter(self, type_filter: int) -> None:
+ """hide/show rows corresponding to the type filter
+
+ type_filter is an integer: 0 for all, 1 for workbenches, 2 for macros,
+ and 3 for preference packs
+
+ """
+
+ self.item_filter.setPackageFilter(type_filter)
+ pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
+ pref.SetInt("PackageTypeSelection", type_filter)
+
+ def update_status_filter(self, status_filter: int) -> None:
+ """hide/show rows corresponding to the status filter
+
+ status_filter is an integer: 0 for any, 1 for installed, 2 for not installed,
+ and 3 for update available
+
+ """
+
+ self.item_filter.setStatusFilter(status_filter)
+ pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
+ pref.SetInt("StatusSelection", status_filter)
+
+ def update_text_filter(self, text_filter: str) -> None:
+ """filter name and description by the regex specified by text_filter"""
+
+ if text_filter:
+ if hasattr(self.item_filter, "setFilterRegularExpression"): # Added in Qt 5.12
+ test_regex = QtCore.QRegularExpression(text_filter)
+ else:
+ test_regex = QtCore.QRegExp(text_filter)
+ if test_regex.isValid():
+ self.ui.labelFilterValidity.setToolTip(
+ translate("AddonsInstaller", "Filter is valid")
+ )
+ icon = QtGui.QIcon.fromTheme("ok", QtGui.QIcon(":/icons/edit_OK.svg"))
+ self.ui.labelFilterValidity.setPixmap(icon.pixmap(16, 16))
+ else:
+ self.ui.labelFilterValidity.setToolTip(
+ translate("AddonsInstaller", "Filter regular expression is invalid")
+ )
+ icon = QtGui.QIcon.fromTheme("cancel", QtGui.QIcon(":/icons/edit_Cancel.svg"))
+ self.ui.labelFilterValidity.setPixmap(icon.pixmap(16, 16))
+ self.ui.labelFilterValidity.show()
+ else:
+ self.ui.labelFilterValidity.hide()
+ if hasattr(self.item_filter, "setFilterRegularExpression"): # Added in Qt 5.12
+ self.item_filter.setFilterRegularExpression(text_filter)
+ else:
+ self.item_filter.setFilterRegExp(text_filter)
diff --git a/src/Mod/AddonManager/Widgets/addonmanager_widget_view_selector.py b/src/Mod/AddonManager/Widgets/addonmanager_widget_view_selector.py
new file mode 100644
index 0000000000..7bcb9ff7ba
--- /dev/null
+++ b/src/Mod/AddonManager/Widgets/addonmanager_widget_view_selector.py
@@ -0,0 +1,152 @@
+# 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 *
+# * . *
+# * *
+# ***************************************************************************
+
+""" Defines a QWidget-derived class for displaying the view selection buttons. """
+
+from enum import IntEnum
+
+try:
+ import FreeCAD
+
+ translate = FreeCAD.Qt.translate
+except ImportError:
+ FreeCAD = None
+
+ def translate(context: str, text: str):
+ return text
+
+
+# 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
+
+
+class AddonManagerDisplayStyle(IntEnum):
+ """The display mode of the Addon Manager"""
+
+ COMPACT = 0
+ EXPANDED = 1
+ COMPOSITE = 2
+
+
+class WidgetViewSelector(QtWidgets.QWidget):
+ """A widget for selecting the Addon Manager's primary view mode"""
+
+ view_changed = QtCore.Signal(int)
+
+ def __init__(self, parent: QtWidgets.QWidget = None):
+ super().__init__(parent)
+ self.horizontal_layout = None
+ self.composite_button = None
+ self.expanded_button = None
+ self.compact_button = None
+ self._setup_ui()
+ self._setup_connections()
+
+ def set_current_view(self, view: AddonManagerDisplayStyle):
+ """Set the current selection. Does NOT emit a view_changed signal, only changes the
+ interface display."""
+ self.compact_button.setChecked(False)
+ self.expanded_button.setChecked(False)
+ self.composite_button.setChecked(False)
+ if view == AddonManagerDisplayStyle.COMPACT:
+ self.compact_button.setChecked(True)
+ elif view == AddonManagerDisplayStyle.EXPANDED:
+ self.expanded_button.setChecked(True)
+ elif view == AddonManagerDisplayStyle.COMPOSITE:
+ self.composite_button.setChecked(True)
+ else:
+ if FreeCAD is not None:
+ FreeCAD.Console.PrintWarning(f"Unrecognized display style {view}")
+
+ def _setup_ui(self):
+ self.horizontal_layout = QtWidgets.QHBoxLayout()
+ self.compact_button = QtWidgets.QToolButton(self)
+ self.compact_button.setObjectName("compact_button")
+ self.compact_button.setIcon(
+ QtGui.QIcon.fromTheme("back", QtGui.QIcon(":/icons/compact_view.svg"))
+ )
+ self.compact_button.setCheckable(True)
+ self.compact_button.setAutoExclusive(True)
+
+ self.expanded_button = QtWidgets.QToolButton(self)
+ self.expanded_button.setObjectName("expanded_button")
+ self.expanded_button.setCheckable(True)
+ self.expanded_button.setChecked(True)
+ self.expanded_button.setAutoExclusive(True)
+ self.expanded_button.setIcon(
+ QtGui.QIcon.fromTheme("expanded_view", QtGui.QIcon(":/icons/expanded_view.svg"))
+ )
+
+ self.composite_button = QtWidgets.QToolButton(self)
+ self.composite_button.setObjectName("expanded_button")
+ self.composite_button.setCheckable(True)
+ self.composite_button.setChecked(True)
+ self.composite_button.setAutoExclusive(True)
+ self.composite_button.setIcon(
+ QtGui.QIcon.fromTheme("composite_button", QtGui.QIcon(":/icons/composite_view.svg"))
+ )
+
+ self.horizontal_layout.addWidget(self.compact_button)
+ self.horizontal_layout.addWidget(self.expanded_button)
+ self.horizontal_layout.addWidget(self.composite_button)
+
+ self.compact_button.clicked.connect(
+ lambda: self.view_changed.emit(AddonManagerDisplayStyle.COMPACT)
+ )
+ self.expanded_button.clicked.connect(
+ lambda: self.view_changed.emit(AddonManagerDisplayStyle.EXPANDED)
+ )
+ self.composite_button.clicked.connect(
+ lambda: self.view_changed.emit(AddonManagerDisplayStyle.COMPOSITE)
+ )
+
+ self.setLayout(self.horizontal_layout)
+ self.retranslateUi(None)
+
+ def _setup_connections(self):
+ self.compact_button.clicked.connect(
+ lambda: self.view_changed.emit(AddonManagerDisplayStyle.COMPACT)
+ )
+ self.expanded_button.clicked.connect(
+ lambda: self.view_changed.emit(AddonManagerDisplayStyle.EXPANDED)
+ )
+ self.composite_button.clicked.connect(
+ lambda: self.view_changed.emit(AddonManagerDisplayStyle.COMPOSITE)
+ )
+
+ def retranslateUi(self, _):
+ self.composite_button.setToolTip(translate("AddonsInstaller", "Composite view"))
+ self.expanded_button.setToolTip(translate("AddonsInstaller", "Expanded view"))
+ self.compact_button.setToolTip(translate("AddonsInstaller", "Compact view"))
diff --git a/src/Mod/AddonManager/compact_view.py b/src/Mod/AddonManager/compact_view.py
index 2f9d549910..d92e6c4b8a 100644
--- a/src/Mod/AddonManager/compact_view.py
+++ b/src/Mod/AddonManager/compact_view.py
@@ -42,7 +42,12 @@ class Ui_CompactView(object):
self.labelPackageName = QLabel(CompactView)
self.labelPackageName.setObjectName("labelPackageName")
+ self.labelPackageNameSpacer = QLabel(CompactView)
+ self.labelPackageNameSpacer.setText(" — ")
+ self.labelPackageNameSpacer.setObjectName("labelPackageNameSpacer")
+
self.horizontalLayout_2.addWidget(self.labelPackageName)
+ self.horizontalLayout_2.addWidget(self.labelPackageNameSpacer)
self.labelVersion = QLabel(CompactView)
self.labelVersion.setObjectName("labelVersion")
diff --git a/src/Mod/AddonManager/composite_view.py b/src/Mod/AddonManager/composite_view.py
new file mode 100644
index 0000000000..b89041adad
--- /dev/null
+++ b/src/Mod/AddonManager/composite_view.py
@@ -0,0 +1,56 @@
+# 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 *
+# * . *
+# * *
+# ***************************************************************************
+
+""" Provides a class for showing the list view and detail view at the same time. """
+
+import addonmanager_freecad_interface
+
+# 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, QtWidgets
+
+
+class CompositeView(QtWidgets.QWidget):
+ """A widget that displays the Addon Manager's top bar, the list of Addons, and the detail
+ view, all on a single pane (with no switching). Detail view is shown in its "icon-only" mode
+ for the installation, etc. buttons. The bottom bar remains visible throughout."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ # TODO: Refactor the Addon Manager's display into four custom widgets:
+ # 1) The top bar showing the filter and search
+ # 2) The package list widget, which can take three forms (expanded, compact, and list)
+ # 3) The installer bar, which can take two forms (text and icon)
+ # 4) The bottom bar
diff --git a/src/Mod/AddonManager/package_list.py b/src/Mod/AddonManager/package_list.py
index f6676feaa5..6279990c9c 100644
--- a/src/Mod/AddonManager/package_list.py
+++ b/src/Mod/AddonManager/package_list.py
@@ -37,6 +37,9 @@ from expanded_view import Ui_ExpandedView
import addonmanager_utilities as utils
from addonmanager_metadata import get_first_supported_freecad_version, Version
+from Widgets.addonmanager_widget_view_selector import WidgetViewSelector, AddonManagerDisplayStyle
+from Widgets.addonmanager_widget_search import WidgetSearch
+from Widgets.addonmanager_widget_filter_selector import WidgetFilterSelector, StatusFilter, Filter
translate = FreeCAD.Qt.translate
@@ -44,22 +47,6 @@ translate = FreeCAD.Qt.translate
# pylint: disable=too-few-public-methods
-class ListDisplayStyle(IntEnum):
- """The display mode of the list"""
-
- COMPACT = 0
- EXPANDED = 1
-
-
-class StatusFilter(IntEnum):
- """Predefined filers"""
-
- ANY = 0
- INSTALLED = 1
- NOT_INSTALLED = 2
- UPDATE_AVAILABLE = 3
-
-
class PackageList(QtWidgets.QWidget):
"""A widget that shows a list of packages and various widgets to control the
display of the list"""
@@ -77,25 +64,16 @@ class PackageList(QtWidgets.QWidget):
self.ui.listPackages.setItemDelegate(self.item_delegate)
self.ui.listPackages.clicked.connect(self.on_listPackages_clicked)
- self.ui.comboPackageType.currentIndexChanged.connect(self.update_type_filter)
- self.ui.comboStatus.currentIndexChanged.connect(self.update_status_filter)
- self.ui.lineEditFilter.textChanged.connect(self.update_text_filter)
- self.ui.buttonCompactLayout.clicked.connect(
- lambda: self.set_view_style(ListDisplayStyle.COMPACT)
- )
- self.ui.buttonExpandedLayout.clicked.connect(
- lambda: self.set_view_style(ListDisplayStyle.EXPANDED)
- )
-
- # Only shows when the user types in a filter
- self.ui.labelFilterValidity.hide()
+ self.ui.filter_selector.filter_changed.connect(self.update_status_filter)
+ self.ui.search_box.search_changed.connect(self.item_filter.setFilterRegularExpression)
+ self.ui.view_selector.view_changed.connect(self.set_view_style)
# Set up the view the same as the last time:
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
package_type = pref.GetInt("PackageTypeSelection", 1)
- self.ui.comboPackageType.setCurrentIndex(package_type)
status = pref.GetInt("StatusSelection", 0)
- self.ui.comboStatus.setCurrentIndex(status)
+ self.ui.filter_selector.set_contents_filter(package_type)
+ self.ui.filter_selector.set_status_filter(status)
# Pre-init of other members:
self.item_model = None
@@ -107,12 +85,9 @@ class PackageList(QtWidgets.QWidget):
self.item_filter.sort(0)
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
- style = pref.GetInt("ViewStyle", ListDisplayStyle.EXPANDED)
+ style = pref.GetInt("ViewStyle", AddonManagerDisplayStyle.EXPANDED)
self.set_view_style(style)
- if style == ListDisplayStyle.EXPANDED:
- self.ui.buttonExpandedLayout.setChecked(True)
- else:
- self.ui.buttonCompactLayout.setChecked(True)
+ self.ui.view_selector.set_current_view(style)
self.item_filter.setHidePy2(pref.GetBool("HidePy2", True))
self.item_filter.setHideObsolete(pref.GetBool("HideObsolete", True))
@@ -125,63 +100,21 @@ class PackageList(QtWidgets.QWidget):
selected_repo = self.item_model.repos[source_selection.row()]
self.itemSelected.emit(selected_repo)
- def update_type_filter(self, type_filter: int) -> None:
- """hide/show rows corresponding to the type filter
+ def update_status_filter(self, new_filter: Filter) -> None:
+ """hide/show rows corresponding to the specified filter"""
- type_filter is an integer: 0 for all, 1 for workbenches, 2 for macros,
- and 3 for preference packs
-
- """
-
- self.item_filter.setPackageFilter(type_filter)
+ self.item_filter.setStatusFilter(new_filter.status_filter)
+ self.item_filter.setPackageFilter(new_filter.content_filter)
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
- pref.SetInt("PackageTypeSelection", type_filter)
+ pref.SetInt("StatusSelection", new_filter.status_filter)
+ pref.SetInt("PackageTypeSelection", new_filter.content_filter)
- def update_status_filter(self, status_filter: int) -> None:
- """hide/show rows corresponding to the status filter
-
- status_filter is an integer: 0 for any, 1 for installed, 2 for not installed,
- and 3 for update available
-
- """
-
- self.item_filter.setStatusFilter(status_filter)
- pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
- pref.SetInt("StatusSelection", status_filter)
-
- def update_text_filter(self, text_filter: str) -> None:
- """filter name and description by the regex specified by text_filter"""
-
- if text_filter:
- if hasattr(self.item_filter, "setFilterRegularExpression"): # Added in Qt 5.12
- test_regex = QtCore.QRegularExpression(text_filter)
- else:
- test_regex = QtCore.QRegExp(text_filter)
- if test_regex.isValid():
- self.ui.labelFilterValidity.setToolTip(
- translate("AddonsInstaller", "Filter is valid")
- )
- icon = QtGui.QIcon.fromTheme("ok", QtGui.QIcon(":/icons/edit_OK.svg"))
- self.ui.labelFilterValidity.setPixmap(icon.pixmap(16, 16))
- else:
- self.ui.labelFilterValidity.setToolTip(
- translate("AddonsInstaller", "Filter regular expression is invalid")
- )
- icon = QtGui.QIcon.fromTheme("cancel", QtGui.QIcon(":/icons/edit_Cancel.svg"))
- self.ui.labelFilterValidity.setPixmap(icon.pixmap(16, 16))
- self.ui.labelFilterValidity.show()
- else:
- self.ui.labelFilterValidity.hide()
- if hasattr(self.item_filter, "setFilterRegularExpression"): # Added in Qt 5.12
- self.item_filter.setFilterRegularExpression(text_filter)
- else:
- self.item_filter.setFilterRegExp(text_filter)
-
- def set_view_style(self, style: ListDisplayStyle) -> None:
+ def set_view_style(self, style: AddonManagerDisplayStyle) -> None:
"""Set the style (compact or expanded) of the list"""
self.item_model.layoutAboutToBeChanged.emit()
self.item_delegate.set_view(style)
- if style == ListDisplayStyle.COMPACT:
+ # TODO: Update to support composite
+ if style == AddonManagerDisplayStyle.COMPACT:
self.ui.listPackages.setSpacing(2)
else:
self.ui.listPackages.setSpacing(5)
@@ -324,12 +257,12 @@ class PackageListItemDelegate(QtWidgets.QStyledItemDelegate):
def __init__(self, parent=None):
super().__init__(parent)
- self.displayStyle = ListDisplayStyle.EXPANDED
+ self.displayStyle = AddonManagerDisplayStyle.EXPANDED
self.expanded = ExpandedView()
self.compact = CompactView()
self.widget = self.expanded
- def set_view(self, style: ListDisplayStyle) -> None:
+ def set_view(self, style: AddonManagerDisplayStyle) -> None:
"""Set the view of to style"""
if not self.displayStyle == style:
self.displayStyle = style
@@ -343,7 +276,7 @@ class PackageListItemDelegate(QtWidgets.QStyledItemDelegate):
def update_content(self, index):
"""Creates the display of the content for a given index."""
repo = index.data(PackageListItemModel.DataAccessRole)
- if self.displayStyle == ListDisplayStyle.EXPANDED:
+ if self.displayStyle == AddonManagerDisplayStyle.EXPANDED:
self.widget = self.expanded
self.widget.ui.labelPackageName.setText(f"
{repo.display_name}
")
self.widget.ui.labelIcon.setPixmap(repo.icon.pixmap(QtCore.QSize(48, 48)))
@@ -353,23 +286,23 @@ class PackageListItemDelegate(QtWidgets.QStyledItemDelegate):
self.widget.ui.labelIcon.setPixmap(repo.icon.pixmap(QtCore.QSize(16, 16)))
self.widget.ui.labelIcon.setText("")
- if self.displayStyle == ListDisplayStyle.EXPANDED:
+ if self.displayStyle == AddonManagerDisplayStyle.EXPANDED:
self.widget.ui.labelTags.setText("")
if repo.metadata:
self.widget.ui.labelDescription.setText(repo.metadata.description)
self.widget.ui.labelVersion.setText(f"v{repo.metadata.version}")
- if self.displayStyle == ListDisplayStyle.EXPANDED:
+ if self.displayStyle == AddonManagerDisplayStyle.EXPANDED:
self._setup_expanded_package(repo)
elif repo.macro and repo.macro.parsed:
self._setup_macro(repo)
else:
self.widget.ui.labelDescription.setText("")
self.widget.ui.labelVersion.setText("")
- if self.displayStyle == ListDisplayStyle.EXPANDED:
+ if self.displayStyle == AddonManagerDisplayStyle.EXPANDED:
self.widget.ui.labelMaintainer.setText("")
# Update status
- if self.displayStyle == ListDisplayStyle.EXPANDED:
+ if self.displayStyle == AddonManagerDisplayStyle.EXPANDED:
self.widget.ui.labelStatus.setText(self.get_expanded_update_string(repo))
else:
self.widget.ui.labelStatus.setText(self.get_compact_update_string(repo))
@@ -417,7 +350,7 @@ class PackageListItemDelegate(QtWidgets.QStyledItemDelegate):
+ repo.macro.date
)
self.widget.ui.labelVersion.setText("" + version_string + "")
- if self.displayStyle == ListDisplayStyle.EXPANDED:
+ if self.displayStyle == AddonManagerDisplayStyle.EXPANDED:
if repo.macro.author:
caption = translate("AddonsInstaller", "Author")
self.widget.ui.labelMaintainer.setText(caption + ": " + repo.macro.author)
@@ -665,65 +598,23 @@ class Ui_PackageList:
self.verticalLayout.setObjectName("verticalLayout")
self.horizontalLayout_6 = QtWidgets.QHBoxLayout()
self.horizontalLayout_6.setObjectName("horizontalLayout_6")
- self.buttonCompactLayout = QtWidgets.QToolButton(form)
- self.buttonCompactLayout.setObjectName("buttonCompactLayout")
- self.buttonCompactLayout.setCheckable(True)
- self.buttonCompactLayout.setAutoExclusive(True)
- self.buttonCompactLayout.setIcon(
- QtGui.QIcon.fromTheme("expanded_view", QtGui.QIcon(":/icons/compact_view.svg"))
- )
- self.horizontalLayout_6.addWidget(self.buttonCompactLayout)
-
- self.buttonExpandedLayout = QtWidgets.QToolButton(form)
- self.buttonExpandedLayout.setObjectName("buttonExpandedLayout")
- self.buttonExpandedLayout.setCheckable(True)
- self.buttonExpandedLayout.setChecked(True)
- self.buttonExpandedLayout.setAutoExclusive(True)
- self.buttonExpandedLayout.setIcon(
- QtGui.QIcon.fromTheme("expanded_view", QtGui.QIcon(":/icons/expanded_view.svg"))
- )
-
- self.horizontalLayout_6.addWidget(self.buttonExpandedLayout)
+ self.view_selector = WidgetViewSelector(form)
+ self.horizontalLayout_6.addWidget(self.view_selector)
self.labelPackagesContaining = QtWidgets.QLabel(form)
self.labelPackagesContaining.setObjectName("labelPackagesContaining")
self.horizontalLayout_6.addWidget(self.labelPackagesContaining)
- self.comboPackageType = QtWidgets.QComboBox(form)
- self.comboPackageType.addItem("")
- self.comboPackageType.addItem("")
- self.comboPackageType.addItem("")
- self.comboPackageType.addItem("")
- self.comboPackageType.setObjectName("comboPackageType")
+ self.filter_selector = WidgetFilterSelector(form)
+ self.filter_selector.setObjectName("filter_selector")
+ self.horizontalLayout_6.addWidget(self.filter_selector)
- self.horizontalLayout_6.addWidget(self.comboPackageType)
+ self.search_box = WidgetSearch(form)
+ self.search_box.setObjectName("search_box")
- self.labelStatus = QtWidgets.QLabel(form)
- self.labelStatus.setObjectName("labelStatus")
-
- self.horizontalLayout_6.addWidget(self.labelStatus)
-
- self.comboStatus = QtWidgets.QComboBox(form)
- self.comboStatus.addItem("")
- self.comboStatus.addItem("")
- self.comboStatus.addItem("")
- self.comboStatus.addItem("")
- self.comboStatus.setObjectName("comboStatus")
-
- self.horizontalLayout_6.addWidget(self.comboStatus)
-
- self.lineEditFilter = QtWidgets.QLineEdit(form)
- self.lineEditFilter.setObjectName("lineEditFilter")
- self.lineEditFilter.setClearButtonEnabled(True)
-
- self.horizontalLayout_6.addWidget(self.lineEditFilter)
-
- self.labelFilterValidity = QtWidgets.QLabel(form)
- self.labelFilterValidity.setObjectName("labelFilterValidity")
-
- self.horizontalLayout_6.addWidget(self.labelFilterValidity)
+ self.horizontalLayout_6.addWidget(self.search_box)
self.verticalLayout.addLayout(self.horizontalLayout_6)
@@ -744,44 +635,4 @@ class Ui_PackageList:
QtCore.QMetaObject.connectSlotsByName(form)
def retranslateUi(self, _):
- self.labelPackagesContaining.setText(
- QtCore.QCoreApplication.translate("AddonsInstaller", "Show Addons containing:", None)
- )
- self.comboPackageType.setItemText(
- 0, QtCore.QCoreApplication.translate("AddonsInstaller", "All", None)
- )
- self.comboPackageType.setItemText(
- 1, QtCore.QCoreApplication.translate("AddonsInstaller", "Workbenches", None)
- )
- self.comboPackageType.setItemText(
- 2, QtCore.QCoreApplication.translate("AddonsInstaller", "Macros", None)
- )
- self.comboPackageType.setItemText(
- 3,
- QtCore.QCoreApplication.translate("AddonsInstaller", "Preference Packs", None),
- )
- self.labelStatus.setText(
- QtCore.QCoreApplication.translate("AddonsInstaller", "Status:", None)
- )
- self.comboStatus.setItemText(
- StatusFilter.ANY,
- QtCore.QCoreApplication.translate("AddonsInstaller", "Any", None),
- )
- self.comboStatus.setItemText(
- StatusFilter.INSTALLED,
- QtCore.QCoreApplication.translate("AddonsInstaller", "Installed", None),
- )
- self.comboStatus.setItemText(
- StatusFilter.NOT_INSTALLED,
- QtCore.QCoreApplication.translate("AddonsInstaller", "Not installed", None),
- )
- self.comboStatus.setItemText(
- StatusFilter.UPDATE_AVAILABLE,
- QtCore.QCoreApplication.translate("AddonsInstaller", "Update available", None),
- )
- self.lineEditFilter.setPlaceholderText(
- QtCore.QCoreApplication.translate("AddonsInstaller", "Filter", None)
- )
- self.labelFilterValidity.setText(
- QtCore.QCoreApplication.translate("AddonsInstaller", "OK", None)
- )
+ pass