diff --git a/.gitignore b/.gitignore index 6cb10c8..af3b66b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,142 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +.vscode/ __pycache__ -/gif +.pre-commit-config.yaml +.pre-commit-search-and-replace.yaml +.pttx +*.pptx +/CreateTranslations.bat +*.bak +Backups/ +RibbonDataFile*.dat +RibbonStructure.json +RibbonStructure_default.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..13a3961 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +# .pre-commit-config.yaml +repos: +- repo: https://github.com/mattlqx/pre-commit-search-and-replace + rev: v1.1.8 + hooks: + - id: search-and-replace +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 # this is optional, use `pre-commit autoupdate` to get the latest rev! + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace +- repo: https://github.com/psf/black + rev: 24.10.0 + hooks: + - id: black diff --git a/.pre-commit-search-and-replace.yaml b/.pre-commit-search-and-replace.yaml new file mode 100644 index 0000000..383d55e --- /dev/null +++ b/.pre-commit-search-and-replace.yaml @@ -0,0 +1,6 @@ +- search: PySide6 + replacement: PySide +- search: PySide2 + replacement: PySide +- search: pyqtribbon as + replacement: pyqtribbon_local as diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..febb024 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "githubPullRequests.ignoredPullRequestBranches": ["main"] +} diff --git a/BuiltInSearchResults.py b/BuiltInSearchResults.py index c35be2d..50af4cb 100644 --- a/BuiltInSearchResults.py +++ b/BuiltInSearchResults.py @@ -3,40 +3,84 @@ import SearchResults -SearchResults.registerResultProvider('refreshTools', - getItemGroupsCached = lambda: __import__('ResultsRefreshTools').refreshToolsResultsProvider(), - getItemGroupsUncached = lambda: []) -SearchResults.registerResultProvider('document', - getItemGroupsCached = lambda: [], - getItemGroupsUncached = lambda: __import__('ResultsDocument').documentResultsProvider()) -SearchResults.registerResultProvider('toolbar', - getItemGroupsCached = lambda: __import__('ResultsToolbar').toolbarResultsProvider(), - getItemGroupsUncached = lambda: []) -SearchResults.registerResultProvider('param', - getItemGroupsCached = lambda: __import__('ResultsPreferences').paramResultsProvider(), - getItemGroupsUncached = lambda: []) +SearchResults.registerResultProvider( + "refreshTools", + getItemGroupsCached=lambda: __import__( + "ResultsRefreshTools" + ).refreshToolsResultsProvider(), + getItemGroupsUncached=lambda: [], +) +SearchResults.registerResultProvider( + "document", + getItemGroupsCached=lambda: [], + getItemGroupsUncached=lambda: __import__( + "ResultsDocument" + ).documentResultsProvider(), +) +SearchResults.registerResultProvider( + "toolbar", + getItemGroupsCached=lambda: __import__("ResultsToolbar").toolbarResultsProvider(), + getItemGroupsUncached=lambda: [], +) +SearchResults.registerResultProvider( + "param", + getItemGroupsCached=lambda: __import__("ResultsPreferences").paramResultsProvider(), + getItemGroupsUncached=lambda: [], +) -SearchResults.registerResultHandler('refreshTools', - action = lambda nfo: __import__('ResultsRefreshTools').refreshToolsAction(nfo), - toolTip = lambda nfo, setParent: __import__('ResultsRefreshTools').refreshToolsToolTip(nfo, setParent)) -SearchResults.registerResultHandler('toolbar', - action = lambda nfo: __import__('ResultsToolbar').toolbarAction(nfo), - toolTip = lambda nfo, setParent: __import__('ResultsToolbar').toolbarToolTip(nfo, setParent)) -SearchResults.registerResultHandler('tool', - action = lambda nfo : __import__('ResultsToolbar').subToolAction(nfo), - toolTip = lambda nfo, setParent: __import__('ResultsToolbar').subToolToolTip(nfo, setParent)) -SearchResults.registerResultHandler('subTool', - action = lambda nfo : __import__('ResultsToolbar').subToolAction(nfo), - toolTip = lambda nfo, setParent: __import__('ResultsToolbar').subToolToolTip(nfo, setParent)) -SearchResults.registerResultHandler('document', - action = lambda nfo : __import__('ResultsDocument').documentAction(nfo), - toolTip = lambda nfo, setParent: __import__('ResultsDocument').documentToolTip(nfo, setParent)) -SearchResults.registerResultHandler('documentObject', - action = lambda nfo : __import__('ResultsDocument').documentObjectAction(nfo), - toolTip = lambda nfo, setParent: __import__('ResultsDocument').documentObjectToolTip(nfo, setParent)) -SearchResults.registerResultHandler('param', - action = lambda nfo : __import__('ResultsPreferences').paramAction(nfo), - toolTip = lambda nfo, setParent: __import__('ResultsPreferences').paramToolTip(nfo, setParent)) -SearchResults.registerResultHandler('paramGroup', - action = lambda nfo : __import__('ResultsPreferences').paramGroupAction(nfo), - toolTip = lambda nfo, setParent: __import__('ResultsPreferences').paramGroupToolTip(nfo, setParent)) +SearchResults.registerResultHandler( + "refreshTools", + action=lambda nfo: __import__("ResultsRefreshTools").refreshToolsAction(nfo), + toolTip=lambda nfo, setParent: __import__( + "ResultsRefreshTools" + ).refreshToolsToolTip(nfo, setParent), +) +SearchResults.registerResultHandler( + "toolbar", + action=lambda nfo: __import__("ResultsToolbar").toolbarAction(nfo), + toolTip=lambda nfo, setParent: __import__("ResultsToolbar").toolbarToolTip( + nfo, setParent + ), +) +SearchResults.registerResultHandler( + "tool", + action=lambda nfo: __import__("ResultsToolbar").subToolAction(nfo), + toolTip=lambda nfo, setParent: __import__("ResultsToolbar").subToolToolTip( + nfo, setParent + ), +) +SearchResults.registerResultHandler( + "subTool", + action=lambda nfo: __import__("ResultsToolbar").subToolAction(nfo), + toolTip=lambda nfo, setParent: __import__("ResultsToolbar").subToolToolTip( + nfo, setParent + ), +) +SearchResults.registerResultHandler( + "document", + action=lambda nfo: __import__("ResultsDocument").documentAction(nfo), + toolTip=lambda nfo, setParent: __import__("ResultsDocument").documentToolTip( + nfo, setParent + ), +) +SearchResults.registerResultHandler( + "documentObject", + action=lambda nfo: __import__("ResultsDocument").documentObjectAction(nfo), + toolTip=lambda nfo, setParent: __import__("ResultsDocument").documentObjectToolTip( + nfo, setParent + ), +) +SearchResults.registerResultHandler( + "param", + action=lambda nfo: __import__("ResultsPreferences").paramAction(nfo), + toolTip=lambda nfo, setParent: __import__("ResultsPreferences").paramToolTip( + nfo, setParent + ), +) +SearchResults.registerResultHandler( + "paramGroup", + action=lambda nfo: __import__("ResultsPreferences").paramGroupAction(nfo), + toolTip=lambda nfo, setParent: __import__("ResultsPreferences").paramGroupToolTip( + nfo, setParent + ), +) diff --git a/GetItemGroups.py b/GetItemGroups.py index 42b1bd3..940e319 100644 --- a/GetItemGroups.py +++ b/GetItemGroups.py @@ -1,57 +1,84 @@ +import FreeCAD as App +import FreeCADGui as Gui + globalGroups = [] itemGroups = None serializedItemGroups = None +# Define the translation +translate = App.Qt.translate + + def onResultSelected(index, groupId): - global globalGroups - nfo = globalGroups[groupId] - handlerName = nfo['action']['handler'] - import SearchResults - if handlerName in SearchResults.actionHandlers: - SearchResults.actionHandlers[handlerName](nfo) - else: - from PySide import QtGui - QtGui.QMessageBox.warning(None, 'Could not execute this action', 'Could not execute this action, it could be from a Mod that has been uninstalled. Try refreshing the list of tools.') + global globalGroups + nfo = globalGroups[groupId] + handlerName = nfo["action"]["handler"] + import SearchResults + + if handlerName in SearchResults.actionHandlers: + SearchResults.actionHandlers[handlerName](nfo) + else: + from PySide import QtGui + + QtGui.QMessageBox.warning( + None, + translate("SearchBar", "Could not execute this action"), + translate( + "SearchBar", + "Could not execute this action, it could be from a Mod that has been uninstalled. Try refreshing the list of tools.", + ), + ) + def getToolTip(groupId, setParent): - global globalGroups - nfo = globalGroups[int(groupId)] - handlerName = nfo['action']['handler'] - import SearchResults - if handlerName in SearchResults.toolTipHandlers: - return SearchResults.toolTipHandlers[handlerName](nfo, setParent) - else: - return 'Could not load tooltip for this tool, it could be from a Mod that has been uninstalled. Try refreshing the list of tools.' + global globalGroups + nfo = globalGroups[int(groupId)] + handlerName = nfo["action"]["handler"] + import SearchResults + + if handlerName in SearchResults.toolTipHandlers: + return SearchResults.toolTipHandlers[handlerName](nfo, setParent) + else: + return translate( + "SearchBar", + "Could not load tooltip for this tool, it could be from a Mod that has been uninstalled. Try refreshing the list of tools.", + ) + def getItemGroups(): - global itemGroups, serializedItemGroups, globalGroups - - # Import the tooltip+action handlers and search result providers that are bundled with this Mod. - # Other providers should import SearchResults and register their handlers and providers - import BuiltInSearchResults + global itemGroups, serializedItemGroups, globalGroups - # Load the list of tools, preferably from the cache, if it has not already been loaded: - if itemGroups is None: - if serializedItemGroups is None: - import RefreshTools - itemGroups = RefreshTools.refreshToolbars(doLoadAllWorkbenches = False) - else: - import Serialize - itemGroups = Serialize.deserialize(serializedItemGroups) + # Import the tooltip+action handlers and search result providers that are bundled with this Mod. + # Other providers should import SearchResults and register their handlers and providers + import BuiltInSearchResults - # Aggregate the tools (cached) and document objects (not cached), and assign an index to each - import SearchResults - igs = itemGroups - for providerName, provider in SearchResults.resultProvidersUncached.items(): - igs = igs + provider() - globalGroups = [] - def addId(group): - globalGroups.append(group) - group['id'] = len(globalGroups) - 1 - for subitem in group['subitems']: - addId(subitem) - for ig in igs: - addId(ig) - - return igs + # Load the list of tools, preferably from the cache, if it has not already been loaded: + if itemGroups is None: + if serializedItemGroups is None: + import RefreshTools + + itemGroups = RefreshTools.refreshToolbars(doLoadAllWorkbenches=False) + else: + import Serialize_SearchBar + + itemGroups = Serialize_SearchBar.deserialize(serializedItemGroups) + + # Aggregate the tools (cached) and document objects (not cached), and assign an index to each + import SearchResults + + igs = itemGroups + for providerName, provider in SearchResults.resultProvidersUncached.items(): + igs = igs + provider() + globalGroups = [] + + def addId(group): + globalGroups.append(group) + group["id"] = len(globalGroups) - 1 + for subitem in group["subitems"]: + addId(subitem) + + for ig in igs: + addId(ig) + + return igs diff --git a/IndentedItemDelegate.py b/IndentedItemDelegate.py index aff7046..b283a8e 100644 --- a/IndentedItemDelegate.py +++ b/IndentedItemDelegate.py @@ -1,12 +1,14 @@ from PySide import QtGui + # Inspired by https://stackoverflow.com/a/5443220/324969 # Inspired by https://forum.qt.io/topic/69807/qtreeview-indent-entire-row class IndentedItemDelegate(QtGui.QStyledItemDelegate): - def __init__(self): - super(IndentedItemDelegate, self).__init__() - def paint(self, painter, option, index): - depth = int(option.widget.model().itemData(index.siblingAtColumn(1))[0]) - indent = 16 * depth - option.rect.adjust(indent, 0, 0, 0) - super(IndentedItemDelegate, self).paint(painter, option, index) + def __init__(self): + super(IndentedItemDelegate, self).__init__() + + def paint(self, painter, option, index): + depth = int(option.widget.model().itemData(index.siblingAtColumn(1))[0]) + indent = 16 * depth + option.rect.adjust(indent, 0, 0, 0) + super(IndentedItemDelegate, self).paint(painter, option, index) diff --git a/InitGui.py b/InitGui.py index 94a8b25..d5517b1 100644 --- a/InitGui.py +++ b/InitGui.py @@ -1,37 +1,39 @@ +import FreeCAD as App +import FreeCADGui as Gui + # Avoid garbage collection by storing the action in a global variable wax = None sea = None tbr = None +# Define the translation +translate = App.Qt.translate + + +def QT_TRANSLATE_NOOP(context, text): + return text + + def addToolSearchBox(): - import FreeCADGui - from PySide import QtGui - import SearchBoxLight - global wax, sea, tbr - mw = FreeCADGui.getMainWindow() - if mw: - if sea is None: - sea = SearchBoxLight.SearchBoxLight(getItemGroups = lambda: __import__('GetItemGroups').getItemGroups(), - getToolTip = lambda groupId, setParent: __import__('GetItemGroups').getToolTip(groupId, setParent), - getItemDelegate = lambda: __import__('IndentedItemDelegate').IndentedItemDelegate()) - sea.resultSelected.connect(lambda index, groupId: __import__('GetItemGroups').onResultSelected(index, groupId)) + global wax, sea, tbr + mw = Gui.getMainWindow() + import SearchBox + from PySide.QtWidgets import QToolBar + from PySide.QtGui import QShortcut, QKeySequence - if wax is None: - wax = QtGui.QWidgetAction(None) - wax.setWhatsThis('Use this search bar to find tools, document objects, preferences and more') + if mw: + if sea is None: + wax = SearchBox.SearchBoxFunction(mw) + if tbr is None: + tbr = QToolBar("SearchBar") # QtGui.QDockWidget() + # Include FreeCAD in the name so that one can find windows labeled with + # FreeCAD easily in window managers which allow search through the list of open windows. + tbr.setObjectName("SearchBar") + tbr.addAction(wax) + mw.addToolBar(tbr) + tbr.show() + return - sea.setWhatsThis('Use this search bar to find tools, document objects, preferences and more') - wax.setDefaultWidget(sea) - ##mbr.addWidget(sea) - #mbr.addAction(wax) - if tbr is None: - tbr = QtGui.QToolBar("SearchBar") #QtGui.QDockWidget() - # Include FreeCAD in the name so that one can find windows labeled with FreeCAD easily in window managers which allow search through the list of open windows. - tbr.setObjectName("SearchBar") - tbr.addAction(wax) - mw.addToolBar(tbr) - tbr.show() addToolSearchBox() -import FreeCADGui -FreeCADGui.getMainWindow().workbenchActivated.connect(addToolSearchBox) +Gui.getMainWindow().workbenchActivated.connect(addToolSearchBox) diff --git a/Parameters_SearchBar.py b/Parameters_SearchBar.py new file mode 100644 index 0000000..33d7abb --- /dev/null +++ b/Parameters_SearchBar.py @@ -0,0 +1,80 @@ +import FreeCAD as App +import FreeCADGui as Gui +from PySide.QtGui import QColor +import os +import sys + +# Define the translation +translate = App.Qt.translate + +preferences = App.ParamGet("User parameter:BaseApp/Preferences/Mod/FreeCAD-Ribbon") + + +class Settings: + + # region -- Functions to read the settings from the FreeCAD Parameters + # and make sure that a None type result is "" + def GetStringSetting(settingName: str) -> str: + result = preferences.GetString(settingName) + + if result.lower() == "none": + result = "" + return result + + def GetIntSetting(settingName: str) -> int: + result = preferences.GetInt(settingName) + if result == "": + result = None + return result + + def GetFloatSetting(settingName: str) -> int: + result = preferences.GetFloat(settingName) + if result == "": + result = None + return result + + def GetBoolSetting(settingName: str) -> bool: + result = preferences.GetBool(settingName) + if str(result).lower() == "none": + result = False + return result + + def GetColorSetting(settingName: str) -> object: + # Create a tuple from the int value of the color + result = QColor.fromRgba(preferences.GetUnsigned(settingName)).toTuple() + + # correct the order of the tuple and divide them by 255 + result = (result[3] / 255, result[0] / 255, result[1] / 255, result[2] / 255) + + return result + + # endregion + + # region - Functions to write settings to the FreeCAD Parameters + # + # + def SetStringSetting(settingName: str, value: str): + if value.lower() == "none": + value = "" + preferences.SetString(settingName, value) + return + + def SetBoolSetting(settingName: str, value): + if str(value).lower() == "true": + Bool = True + if str(value).lower() == "none" or str(value).lower() != "true": + Bool = False + preferences.SetBool(settingName, Bool) + return + + def SetIntSetting(settingName: str, value: int): + if str(value).lower() != "": + preferences.SetInt(settingName, value) + + +# region - Define the resources ---------------------------------------------------------------------------------------- +ICON_LOCATION = os.path.join(os.path.dirname(__file__), "Resources", "Icons") +# endregion ------------------------------------------------------------------------------------------------------------ + +# The pixmap for the general tool icon +genericToolIcon_Pixmap = os.path.join(ICON_LOCATION, "Tango-Tools-spanner-hammer.svg") diff --git a/README.md b/README.md index 064f6ac..b64a694 100644 --- a/README.md +++ b/README.md @@ -14,28 +14,26 @@ It can be extended by other mods, by adding a new result provider. The search bar appears next to the [`What's this?`](https://wiki.freecad.org/Std_WhatsThis) tool <a href="https://wiki.freecad.org/Std_WhatsThis"><img src="https://user-images.githubusercontent.com/4140247/156215976-5dfadb0c-cac4-44b2-8ad4-b67462a5f7fa.png" alt="drawing" width="20px" height="20px"/></a> in FreeCAD's default File toolbar. - + When using the search bar for the first time, it will contain only the tools of the workbenches which have already been loaded in FreeCAD. -To include results from other workbenches, select the first search result "Refresh list of tools" which will load all FreeCAD workbenches +To include results from other workbenches, select the first search result "Refresh cached results" which will load all FreeCAD workbenches and memorize their tools. After restarting FreeCAD, the search result will include the memorized tools, even if the workbenches have not been loaded yet. When selecting a tool from the search results, SearchBar will attempt to automatically load the workbenches which could have provided that tool. - + To navigate the search results, use the up and down arrows. Typing characters will filter the results on the fly. The extended information -panel next to the search results provides further documentation about the results, e.g. Python snippets which can be copy-pasted (note: -currently a bug crashes FreeCAD if using the context menu to perform the copy, please do not use the context menu until -https://github.com/SuzanneSoy/SearchBar/issues/12 is fixed. +panel next to the search results provides further documentation about the results, e.g. Python snippets which can be copy-pasted. - + ### Installation #### Automatic Install -Install **SearchBar** addon via the FreeCAD Addon Manager from the **Tools** :arrow_right: **Addon Manager** dropdown menu. +Install **SearchBar** addon via the FreeCAD Addon Manager from the **Tools** :arrow_right: **Addon Manager** dropdown menu. #### Manual Install @@ -64,7 +62,7 @@ Clone the GIT repository or extract the `.zip` downloaded from GitHub to the fol ### Feedback -To report bugs or feature enhancements, please open a ticket in the [issue queue](https://github.com/SuzanneSoy/SearchBar/issues). Best place to discuss feedback or issues in on the [dedicated FreeCAD forum discussion]() for SearchBar. +To report bugs or feature enhancements, please open a ticket in the [issue queue](https://github.com/APEbbers/SearchBar/issues). Best place to discuss feedback or issues in on the [dedicated FreeCAD forum discussion]() for SearchBar. ### License [](https://creativecommons.org/publicdomain/zero/1.0/) See [LICENSE](LICENSE). diff --git a/RefreshTools.py b/RefreshTools.py index 1acdad8..acd8d15 100644 --- a/RefreshTools.py +++ b/RefreshTools.py @@ -1,81 +1,123 @@ import os import FreeCAD as App +import FreeCADGui as Gui +import StyleMapping_SearchBar + +# Define the translation +translate = App.Qt.translate + def loadAllWorkbenches(): - from PySide import QtGui - import FreeCADGui - activeWorkbench = FreeCADGui.activeWorkbench().name() - lbl = QtGui.QLabel('Loading workbench … (…/…)') - lbl.show() - lst = FreeCADGui.listWorkbenches() - for i, wb in enumerate(lst): - msg = 'Loading workbench ' + wb + ' (' + str(i) + '/' + str(len(lst)) + ')' - print(msg) - lbl.setText(msg) - geo = lbl.geometry() - geo.setSize(lbl.sizeHint()) - lbl.setGeometry(geo) - lbl.repaint() - FreeCADGui.updateGui() # Probably slower with this, because it redraws the entire GUI with all tool buttons changed etc. but allows the label to actually be updated, and it looks nice and gives a quick overview of all the workbenches… - try: - FreeCADGui.activateWorkbench(wb) - except: - pass - lbl.hide() - FreeCADGui.activateWorkbench(activeWorkbench) + import FreeCADGui as Gui + from PySide.QtGui import QLabel + from PySide.QtCore import Qt, SIGNAL, Signal, QObject, QThread, QSize + from PySide.QtGui import QIcon, QPixmap, QAction, QGuiApplication + + activeWorkbench = Gui.activeWorkbench().name() + lbl = QLabel(translate("SearchBar", "Loading workbench … (…/…)")) + lbl.setWindowFlags(Qt.WindowType.WindowStaysOnTopHint) + + # Get the stylesheet from the main window and use it for this form + lbl.setStyleSheet("background-color: " + StyleMapping_SearchBar.ReturnStyleItem("Background_Color") + ";") + + # # Get the main window from FreeCAD + # mw = Gui.getMainWindow() + # # Center the widget + # cp = QGuiApplication.screenAt(mw.pos()).geometry().center() + # lbl.move(cp) + + lbl.show() + lst = Gui.listWorkbenches() + for i, wb in enumerate(lst): + msg = translate("SearchBar", "Loading workbench ") + wb + " (" + str(i + 1) + "/" + str(len(lst)) + ")" + print(msg) + lbl.setText(msg) + geo = lbl.geometry() + geo.setSize(lbl.sizeHint()) + lbl.setGeometry(geo) + lbl.repaint() + Gui.updateGui() # Probably slower with this, because it redraws the entire GUI with all tool buttons changed etc. but allows the label to actually be updated, and it looks nice and gives a quick overview of all the workbenches… + try: + Gui.activateWorkbench(wb) + except Exception: + pass + lbl.hide() + Gui.activateWorkbench(activeWorkbench) + return + def cachePath(): - return os.path.join(App.getUserAppDataDir(), 'Cache_SearchBarMod') + return os.path.join(App.getUserAppDataDir(), "Cache_SearchBarMod") + def gatherTools(): - itemGroups = [] - import SearchResults - for providerName, provider in SearchResults.resultProvidersCached.items(): - itemGroups = itemGroups + provider() - return itemGroups + itemGroups = [] + import SearchResults + + for providerName, provider in SearchResults.resultProvidersCached.items(): + itemGroups = itemGroups + provider() + return itemGroups + def writeCacheTools(): - import Serialize - serializedItemGroups = Serialize.serialize(gatherTools()) - # Todo: use wb and a specific encoding. - with open(cachePath(), 'w') as cache: - cache.write(serializedItemGroups) - # I prefer to systematically deserialize, instead of taking the original version, - # this avoids possible inconsistencies between the original and the cache and - # makes sure cache-related bugs are noticed quickly. - import Serialize - itemGroups = Serialize.deserialize(serializedItemGroups) - print('SearchBox: Cache has been written.') - return itemGroups + import Serialize_SearchBar + + serializedItemGroups = Serialize_SearchBar.serialize(gatherTools()) + # Todo: use wb and a specific encoding. + with open(cachePath(), "w") as cache: + cache.write(serializedItemGroups) + # I prefer to systematically deserialize, instead of taking the original version, + # this avoids possible inconsistencies between the original and the cache and + # makes sure cache-related bugs are noticed quickly. + import Serialize_SearchBar + + itemGroups = Serialize_SearchBar.deserialize(serializedItemGroups) + print("SearchBox: Cache has been written.") + return itemGroups + def readCacheTools(): - # Todo: use rb and a specific encoding. - with open(cachePath(), 'r') as cache: - serializedItemGroups = cache.read() - import Serialize - itemGroups = Serialize.deserialize(serializedItemGroups) - print('SearchBox: Tools were loaded from the cache.') - return itemGroups + # Todo: use rb and a specific encoding. + with open(cachePath(), "r") as cache: + serializedItemGroups = cache.read() + import Serialize_SearchBar + + itemGroups = Serialize_SearchBar.deserialize(serializedItemGroups) + print("SearchBox: Tools were loaded from the cache.") + return itemGroups -def refreshToolbars(doLoadAllWorkbenches = True): - if doLoadAllWorkbenches: - loadAllWorkbenches() - return writeCacheTools() - else: - try: - return readCacheTools() - except: - return writeCacheTools() +def refreshToolbars(doLoadAllWorkbenches=True): + if doLoadAllWorkbenches: + loadAllWorkbenches() + return writeCacheTools() + else: + try: + return readCacheTools() + except: + return writeCacheTools() + def refreshToolsAction(): - from PySide import QtGui - print('Refresh list of tools') - fw = QtGui.QApplication.focusWidget() - if fw is not None: - fw.clearFocus() - reply = QtGui.QMessageBox.question(None, "Load all workbenches?", "Load all workbenches? This can cause FreeCAD to become unstable, and this \"reload tools\" feature contained a bug that crashed freecad systematically, so please make sure you save your work before. It's a good idea to restart FreeCAD after this operation.", QtGui.QMessageBox.Yes, QtGui.QMessageBox.No) - if reply == QtGui.QMessageBox.Yes: - refreshToolbars() - else: - print('cancelled') + from PySide.QtWidgets import QApplication, QMessageBox + from PySide.QtCore import Qt + + print("Refresh cached results") + msgBox = QMessageBox() + msgBox.setWindowFlags(Qt.WindowType.WindowStaysOnTopHint) + # Get the main window from FreeCAD + mw = Gui.getMainWindow() + reply = msgBox.question( + mw, + translate("SearchBar", "Load all workbenches?"), + translate( + "SearchBar", + """Load all workbenches? This can cause FreeCAD to become unstable, and this "reload tools" feature contained a bug that crashed freecad systematically, so please make sure you save your work before. It\'s a good idea to restart FreeCAD after this operation.""", + ), + QMessageBox.Yes, + QMessageBox.No, + ) + if reply == QMessageBox.Yes: + refreshToolbars() + else: + print("cancelled") diff --git a/Tango-System-search.svg b/Resources/Icons/Tango-System-search.svg similarity index 99% rename from Tango-System-search.svg rename to Resources/Icons/Tango-System-search.svg index dee0fdc..192bc55 100644 --- a/Tango-System-search.svg +++ b/Resources/Icons/Tango-System-search.svg @@ -42,5 +42,5 @@ <ellipse rx="8.23" ry="2.4" cy="38.77" cx="20.99" fill="url(#s)"/> <circle cy="17.55" stroke="#3063a3" cx="18.38" r="11.62" fill="url(#r)"/> <path opacity=".83" d="m18.2 7.4c-5.21 0-9.43 4.21-9.43 9.42 0 1.51 0.42 2.89 1.05 4.15 1.25 0.46 2.58 0.78 3.99 0.78 6.18 0 11.1-4.86 11.48-10.94-1.73-2.05-4.21-3.41-7.09-3.41" fill="url(#g)"/> -<rect opacity="0.43" rx="2.468" transform="matrix(.7530 .6580 -.6489 .7609 0 0)" height="5" width="19" stroke="#fff" y="-.13" x="40.5" fill="none"/> -</svg> \ No newline at end of file +<rect opacity="0.43" rx="2.468" transform="matrix(.7530 .6580 -.6489 .7609 0 0)" height="5" width="19" stroke="#fff" y="-.13" x="40.5" fill="none"/> +</svg> diff --git a/Tango-System-search.svg.url b/Resources/Icons/Tango-System-search.svg.url similarity index 100% rename from Tango-System-search.svg.url rename to Resources/Icons/Tango-System-search.svg.url diff --git a/Tango-Tools-spanner-hammer.svg b/Resources/Icons/Tango-Tools-spanner-hammer.svg similarity index 100% rename from Tango-Tools-spanner-hammer.svg rename to Resources/Icons/Tango-Tools-spanner-hammer.svg diff --git a/Tango-Tools-spanner-hammer.svg.url b/Resources/Icons/Tango-Tools-spanner-hammer.svg.url similarity index 100% rename from Tango-Tools-spanner-hammer.svg.url rename to Resources/Icons/Tango-Tools-spanner-hammer.svg.url diff --git a/animAopt.gif b/Resources/Images/animAopt.gif similarity index 100% rename from animAopt.gif rename to Resources/Images/animAopt.gif diff --git a/animB2op.gif b/Resources/Images/animB2op.gif similarity index 100% rename from animB2op.gif rename to Resources/Images/animB2op.gif diff --git a/screenshot.png b/Resources/Images/screenshot.png similarity index 100% rename from screenshot.png rename to Resources/Images/screenshot.png diff --git a/ResultsDocument.py b/ResultsDocument.py index e726594..0d98edb 100644 --- a/ResultsDocument.py +++ b/ResultsDocument.py @@ -5,112 +5,157 @@ import FreeCADGui import SafeViewer import SearchBox +# Define the translation +translate = App.Qt.translate + + def documentAction(nfo): - act = nfo['action'] + act = nfo["action"] # Todo: this should also select the document in the tree view - print('switch to document ' + act['document']) - App.setActiveDocument(act['document']) + print("switch to document " + act["document"]) + App.setActiveDocument(act["document"]) + def documentObjectAction(nfo): - act = nfo['action'] - print('select object ' + act['document'] + '.' + act['object']) - FreeCADGui.Selection.addSelection(act['document'], act['object']) + act = nfo["action"] + print("select object " + act["document"] + "." + act["object"]) + FreeCADGui.Selection.addSelection(act["document"], act["object"]) + # For some reason, the viewer always works except when used for two consecutive items in the search results: it then disappears after a short zoom-in+zoom-out animation. # I'm giving up on getting this viewer to work in a clean way, and will try swapping two instances so that the same one is never used twice in a row. # Also, in order to avoid segfaults when the module is reloaded (which causes the previous viewer to be garbage collected at some point), we're using a global property that will survive module reloads. -if not hasattr(App, '_SearchBar3DViewer'): - # Toggle between - App._SearchBar3DViewer = None - App._SearchBar3DViewerB = None +if not hasattr(App, "_SearchBar3DViewer"): + # Toggle between + App._SearchBar3DViewer = None + App._SearchBar3DViewerB = None + class DocumentObjectToolTipWidget(QtGui.QWidget): - def __init__(self, nfo, setParent): - import pivy - super(DocumentObjectToolTipWidget, self).__init__() - html = '<p>' + nfo['toolTip']['label'] + '</p><p><code>App.getDocument(' + repr(str(nfo['toolTip']['docName'])) + ').getObject(' + repr(str(nfo['toolTip']['name'])) + ')</code></p>' - description = QtGui.QTextEdit() - description.setReadOnly(True) - description.setAlignment(QtCore.Qt.AlignTop) - description.setText(html) + def __init__(self, nfo, setParent): + import pivy - if App._SearchBar3DViewer is None: - oldFocus = QtGui.QApplication.focusWidget() - SearchBox.globalIgnoreFocusOut - SearchBox.globalIgnoreFocusOut = True - App._SearchBar3DViewer = SafeViewer.SafeViewer() - App._SearchBar3DViewerB = SafeViewer.SafeViewer() - oldFocus.setFocus() - SearchBox.globalIgnoreFocusOut = False - # Tried setting the preview to a fixed size to prevent it from disappearing when changing its contents, this sets it to a fixed size but doesn't actually pick the size, .resize does that but isn't enough to fix the bug. - #safeViewerInstance.setSizePolicy(QtGui.QSizePolicy(QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Fixed)) - self.preview = App._SearchBar3DViewer - App._SearchBar3DViewer, App._SearchBar3DViewerB = App._SearchBar3DViewerB, App._SearchBar3DViewer + super(DocumentObjectToolTipWidget, self).__init__() + html = ( + "<p>" + + nfo["toolTip"]["label"] + + "</p><p><code>App.getDocument(" + + repr(str(nfo["toolTip"]["docName"])) + + ").getObject(" + + repr(str(nfo["toolTip"]["name"])) + + ")</code></p>" + ) + description = QtGui.QTextEdit() + description.setReadOnly(True) + description.setAlignment(QtCore.Qt.AlignTop) + description.setText(html) - obj = App.getDocument(str(nfo['toolTip']['docName'])).getObject(str(nfo['toolTip']['name'])) + if App._SearchBar3DViewer is None: + oldFocus = QtGui.QApplication.focusWidget() + SearchBox.globalIgnoreFocusOut + SearchBox.globalIgnoreFocusOut = True + App._SearchBar3DViewer = SafeViewer.SafeViewer() + App._SearchBar3DViewerB = SafeViewer.SafeViewer() + oldFocus.setFocus() + SearchBox.globalIgnoreFocusOut = False + # Tried setting the preview to a fixed size to prevent it from disappearing when changing its contents, this sets it to a fixed size but doesn't actually pick the size, .resize does that but isn't enough to fix the bug. + # safeViewerInstance.setSizePolicy(QtGui.QSizePolicy(QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Fixed)) + self.preview = App._SearchBar3DViewer + App._SearchBar3DViewer, App._SearchBar3DViewerB = ( + App._SearchBar3DViewerB, + App._SearchBar3DViewer, + ) - # This is really a bad way to do this… to prevent the setExtraInfo function from - # finalizing the object, we remove the parent ourselves. - oldParent = self.preview.parent() - lay = QtGui.QVBoxLayout() - lay.setContentsMargins(0,0,0,0) - lay.setSpacing(0) - self.setLayout(lay) - lay.addWidget(description) - lay.addWidget(self.preview) - #if oldParent is not None: - # oldParent.hide() # hide before detaching, or we have widgets floating as their own window that appear for a split second in some cases. - # oldParent.setParent(None) - - # Tried hiding/detaching the preview to prevent it from disappearing when changing its contents - #self.preview.viewer.stopAnimating() - self.preview.showSceneGraph(obj.ViewObject.RootNode) + obj = App.getDocument(str(nfo["toolTip"]["docName"])).getObject( + str(nfo["toolTip"]["name"]) + ) - setParent(self) - # Let the GUI recompute the side of the description based on its horizontal size. - FreeCADGui.updateGui() - siz = description.document().size().toSize() - description.setFixedHeight(siz.height() + 5) + # This is really a bad way to do this… to prevent the setExtraInfo function from + # finalizing the object, we remove the parent ourselves. + oldParent = self.preview.parent() + lay = QtGui.QVBoxLayout() + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(0) + self.setLayout(lay) + lay.addWidget(description) + lay.addWidget(self.preview) + # if oldParent is not None: + # oldParent.hide() # hide before detaching, or we have widgets floating as their own window that appear for a split second in some cases. + # oldParent.setParent(None) + + # Tried hiding/detaching the preview to prevent it from disappearing when changing its contents + # self.preview.viewer.stopAnimating() + self.preview.showSceneGraph(obj.ViewObject.RootNode) + + setParent(self) + # Let the GUI recompute the side of the description based on its horizontal size. + FreeCADGui.updateGui() + siz = description.document().size().toSize() + description.setFixedHeight(siz.height() + 5) + + def finalizer(self): + # self.preview.finalizer() + # Detach the widget so that it may be reused without getting deleted + self.preview.setParent(None) - def finalizer(self): - #self.preview.finalizer() - # Detach the widget so that it may be reused without getting deleted - self.preview.setParent(None) def documentToolTip(nfo, setParent): - return '<p>' + nfo['toolTip']['label'] + '</p><p><code>App.getDocument(' + repr(str(nfo['toolTip']['name'])) + ')</code></p><p><img src="data:image/png;base64,.............."></p>' + return ( + "<p>" + + nfo["toolTip"]["label"] + + "</p><p><code>App.getDocument(" + + repr(str(nfo["toolTip"]["name"])) + + ')</code></p><p><img src="data:image/png;base64,.............."></p>' + ) + def documentObjectToolTip(nfo, setParent): - return DocumentObjectToolTipWidget(nfo, setParent) + return DocumentObjectToolTipWidget(nfo, setParent) + def documentResultsProvider(): - itemGroups = [] - def document(doc): - group = [] - for o in doc.Objects: - #all_actions.append(lambda: ) - action = { 'handler': 'documentObject', 'document': o.Document.Name, 'object': o.Name } - item = { - 'icon': o.ViewObject.Icon if o.ViewObject and o.ViewObject.Icon else None, - 'text': o.Label + ' (' + o.Name + ')', - # TODO: preview of the object - 'toolTip': { 'label': o.Label, 'name': o.Name, 'docName': o.Document.Name}, - 'action': action, - 'subitems': [] - } - group.append(item) + itemGroups = [] - action = { 'handler': 'document', 'document': doc.Name } - itemGroups.append({ - 'icon': QtGui.QIcon(':/icons/Document.svg'), - 'text': doc.Label + ' (' + doc.Name + ')', - # TODO: preview of the document - 'toolTip': { 'label': doc.Label, 'name': doc.Name}, - 'action':action, - 'subitems': group }) - if App.ActiveDocument: - document(App.ActiveDocument) - for docname, doc in App.listDocuments().items(): - if not App.activeDocument or docname != App.ActiveDocument.Name: - document(doc) - return itemGroups + def document(doc): + group = [] + for o in doc.Objects: + # all_actions.append(lambda: ) + action = { + "handler": "documentObject", + "document": o.Document.Name, + "object": o.Name, + } + item = { + "icon": ( + o.ViewObject.Icon if o.ViewObject and o.ViewObject.Icon else None + ), + "text": o.Label + " (" + o.Name + ")", + # TODO: preview of the object + "toolTip": { + "label": o.Label, + "name": o.Name, + "docName": o.Document.Name, + }, + "action": action, + "subitems": [], + } + group.append(item) + + action = {"handler": "document", "document": doc.Name} + itemGroups.append( + { + "icon": QtGui.QIcon(":/icons/Document.svg"), + "text": doc.Label + " (" + doc.Name + ")", + # TODO: preview of the document + "toolTip": {"label": doc.Label, "name": doc.Name}, + "action": action, + "subitems": group, + } + ) + + if App.ActiveDocument: + document(App.ActiveDocument) + for docname, doc in App.listDocuments().items(): + if not App.activeDocument or docname != App.ActiveDocument.Name: + document(doc) + return itemGroups diff --git a/ResultsPreferences.py b/ResultsPreferences.py index 737e9b0..4079c1d 100644 --- a/ResultsPreferences.py +++ b/ResultsPreferences.py @@ -2,89 +2,127 @@ import os import FreeCAD as App import FreeCADGui from PySide import QtGui -import Serialize +import Serialize_SearchBar +import Parameters_SearchBar as Parameters + +genericToolIcon = QtGui.QIcon(QtGui.QIcon(Parameters.genericToolIcon_Pixmap)) -genericToolIcon = QtGui.QIcon(QtGui.QIcon(os.path.dirname(__file__) + '/Tango-Tools-spanner-hammer.svg')) def getParam(grpPath, type_, name): - return { - # TODO: use letter icon based on the type, as the preferences editor does - 'icon': genericToolIcon, - 'text': name, - 'toolTip': '', - 'action': {'handler': 'param', 'path': grpPath, 'type': type_, 'name': name}, - 'subitems': [] - } + return { + # TODO: use letter icon based on the type, as the preferences editor does + "icon": genericToolIcon, + "text": name, + "toolTip": "", + "action": {"handler": "param", "path": grpPath, "type": type_, "name": name}, + "subitems": [], + } + def getParamGroup(grpPath): - try: - grp = App.ParamGet(grpPath) - except: - return [] - contents = grp.GetContents() - if contents is not None: - return [getParam(grpPath, type_, name) for (type_, name, value) in contents] - else: - return [] + try: + grp = App.ParamGet(grpPath) + except: + return [] + contents = grp.GetContents() + if contents is not None: + return [getParam(grpPath, type_, name) for (type_, name, value) in contents] + else: + return [] + def getParamGroups(nameInConfig, nameInPath): userParameterPath = App.ConfigGet(nameInConfig) from lxml import etree + xml = etree.parse(userParameterPath).getroot() xml.find('FCParamGroup[@Name="Root"]') root = xml.find('FCParamGroup[@Name="Root"]') + def recur(atRoot, path, name, tree): - params = [] if atRoot else getParamGroup(path) - subgroups = [recur(False, path + (':' if atRoot else '/') + child.attrib['Name'], child.attrib['Name'], child) for child in tree.getchildren() if child.tag == 'FCParamGroup'] - return { - 'icon': QtGui.QIcon(':/icons/Group.svg'), - 'text': name, - 'toolTip': '', - 'action': { 'handler': 'paramGroup', 'path': path, 'name': name }, - 'subitems': params + subgroups - } + params = [] if atRoot else getParamGroup(path) + subgroups = [ + recur( + False, + path + (":" if atRoot else "/") + child.attrib["Name"], + child.attrib["Name"], + child, + ) + for child in tree.getchildren() + if child.tag == "FCParamGroup" + ] + return { + "icon": QtGui.QIcon(":/icons/Group.svg"), + "text": name, + "toolTip": "", + "action": {"handler": "paramGroup", "path": path, "name": name}, + "subitems": params + subgroups, + } + return recur(True, nameInPath, nameInPath, root) + def getAllParams(): - try: - from lxml import etree - return [getParamGroups('UserParameter', 'User parameter')] - except: - print('Could not load the list of all parameters. Please install the LXML python library with:\npython -m pip install --upgrade lxml') - return [] + try: + from lxml import etree + + return [getParamGroups("UserParameter", "User parameter")] + except: + print( + "Could not load the list of all parameters. Please install the LXML python library with:\npython -m pip install --upgrade lxml" + ) + return [] + def paramGroupAction(nfo): - FreeCADGui.runCommand('Std_DlgParameter',0) - print('Open Parameter Editor (parameter group)') - # TODO: find a way to select the desired group in the parameter dialog once it opens + FreeCADGui.runCommand("Std_DlgParameter", 0) + print("Open Parameter Editor (parameter group)") + # TODO: find a way to select the desired group in the parameter dialog once it opens + def paramAction(nfo): - FreeCADGui.runCommand('Std_DlgParameter',0) - print('Open Parameter Editor (single parameter)') - # TODO: find a way to select the desired parameter in the parameter dialog once it opens + FreeCADGui.runCommand("Std_DlgParameter", 0) + print("Open Parameter Editor (single parameter)") + # TODO: find a way to select the desired parameter in the parameter dialog once it opens + getters = { - 'Boolean' : 'GetBool', - 'Float' : 'GetFloat', - 'Integer' : 'GetInt', - 'String' : 'GetString', - 'Unsigned Long': 'GetUnsigned', + "Boolean": "GetBool", + "Float": "GetFloat", + "Integer": "GetInt", + "String": "GetString", + "Unsigned Long": "GetUnsigned", } + def paramGroupToolTip(nfo, setParent): - path = nfo['action']['path'] - name = nfo['action']['name'] - return '<h1>' + name + '</h1><p><code>App.ParamGet(' + repr(path) + ')</code></p' + path = nfo["action"]["path"] + name = nfo["action"]["name"] + return "<h1>" + name + "</h1><p><code>App.ParamGet(" + repr(path) + ")</code></p" + def paramToolTip(nfo, setParent): - path = nfo['action']['path'] - type_ = nfo['action']['type'] - name = nfo['action']['name'] - try: - value = getattr(App.ParamGet(path), getters[type_])(name) - except: - value = 'An error occurred while attempting to access this value.' - return '<p><code>App.ParamGet(' + repr(path) + ').' + getters[type_] + '(' + repr(name) + ')</code></p><p>Type: ' + type_ + '</p><p>Value: ' + repr(value) + '</p>' + path = nfo["action"]["path"] + type_ = nfo["action"]["type"] + name = nfo["action"]["name"] + try: + value = getattr(App.ParamGet(path), getters[type_])(name) + except: + value = "An error occurred while attempting to access this value." + return ( + "<p><code>App.ParamGet(" + + repr(path) + + ")." + + getters[type_] + + "(" + + repr(name) + + ")</code></p><p>Type: " + + type_ + + "</p><p>Value: " + + repr(value) + + "</p>" + ) + def paramResultsProvider(): - return getAllParams() + return getAllParams() diff --git a/ResultsRefreshTools.py b/ResultsRefreshTools.py index 74859a9..9ac924f 100644 --- a/ResultsRefreshTools.py +++ b/ResultsRefreshTools.py @@ -1,22 +1,42 @@ +import FreeCAD as App +import FreeCADGui as Gui + import os from PySide import QtGui -import Serialize +import Serialize_SearchBar +import Parameters_SearchBar as Parameters + +genericToolIcon = QtGui.QIcon(QtGui.QIcon(Parameters.genericToolIcon_Pixmap)) + +# Define the translation +translate = App.Qt.translate + def refreshToolsAction(nfo): - import RefreshTools - RefreshTools.refreshToolsAction() + import RefreshTools + + RefreshTools.refreshToolsAction() + def refreshToolsToolTip(nfo, setParent): - return Serialize.iconToHTML(genericToolIcon) + '<p>Load all workbenches to refresh this list of tools. This may take a minute, depending on the number of installed workbenches.</p>' + return ( + Serialize_SearchBar.iconToHTML(genericToolIcon) + + "<p>" + + translate( + "SearchBar", + "Load all workbenches to refresh the cached results. This may take a minute, depending on the number of installed workbenches.", + ) + + "</p>" + ) + -genericToolIcon = QtGui.QIcon(QtGui.QIcon(os.path.dirname(__file__) + '/Tango-Tools-spanner-hammer.svg')) def refreshToolsResultsProvider(): - return [ - { - 'icon': genericToolIcon, - 'text': 'Refresh list of tools', - 'toolTip': '', - 'action': {'handler': 'refreshTools'}, - 'subitems': [] - } - ] \ No newline at end of file + return [ + { + "icon": genericToolIcon, + "text": translate("SearchBar", "Refresh cached results"), + "toolTip": "", + "action": {"handler": "refreshTools"}, + "subitems": [], + } + ] diff --git a/ResultsToolbar.py b/ResultsToolbar.py index d0ab287..d179a22 100644 --- a/ResultsToolbar.py +++ b/ResultsToolbar.py @@ -1,105 +1,195 @@ +import FreeCAD as App from PySide import QtGui import FreeCADGui -import Serialize +import Serialize_SearchBar + +# Define the translation +translate = App.Qt.translate + def toolbarAction(nfo): - act = nfo['action'] - print('show toolbar ' + act['toolbar'] + ' from workbenches ' + repr(act['workbenches'])) + act = nfo["action"] + print( + "show toolbar " + + act["toolbar"] + + " from workbenches " + + repr(act["workbenches"]) + ) + def subToolAction(nfo): - act = nfo['action'] - toolPath = act['toolbar'] + '.' + act['tool'] - if 'subTool' in act: - toolPath = toolPath + '.' + act['subTool'] - def runTool(): - mw = FreeCADGui.getMainWindow() - for the_toolbar in mw.findChildren(QtGui.QToolBar, act['toolbar']): - for tbt in the_toolbar.findChildren(QtGui.QToolButton): - if tbt.text() == act['tool']: - action = None - if 'subTool' in act: - men = tbt.menu() - if men: - for mac in men.actions(): - if mac.text() == act['subTool']: - action = mac - break - else: - action = tbt.defaultAction() - if 'showMenu' in act and act['showMenu']: - print('Popup submenu of tool ' + toolPath + ' available in workbenches ' + repr(act['workbenches'])) - the_toolbar.show() - tbt.showMenu() - return True - elif action is not None: - print('Run action of tool ' + toolPath + ' available in workbenches ' + repr(act['workbenches'])) - action.trigger() - return True - return False - if runTool(): - return - else: - for workbench in act['workbenches']: - print('Activating workbench ' + workbench + ' to access tool ' + toolPath) - FreeCADGui.activateWorkbench(workbench) - if runTool(): + act = nfo["action"] + toolPath = act["toolbar"] + "." + act["tool"] + if "subTool" in act: + toolPath = toolPath + "." + act["subTool"] + + def runTool(): + mw = FreeCADGui.getMainWindow() + for the_toolbar in mw.findChildren(QtGui.QToolBar, act["toolbar"]): + for tbt in the_toolbar.findChildren(QtGui.QToolButton): + if tbt.text() == act["tool"]: + action = None + if "subTool" in act: + men = tbt.menu() + if men: + for mac in men.actions(): + if mac.text() == act["subTool"]: + action = mac + break + else: + action = tbt.defaultAction() + if "showMenu" in act and act["showMenu"]: + print( + "Popup submenu of tool " + + toolPath + + " available in workbenches " + + repr(act["workbenches"]) + ) + the_toolbar.show() + tbt.showMenu() + return True + elif action is not None: + print( + "Run action of tool " + + toolPath + + " available in workbenches " + + repr(act["workbenches"]) + ) + action.trigger() + return True + return False + + if runTool(): return - print('Tool ' + toolPath + ' not found, was it offered by an extension that is no longer present?') + else: + for workbench in act["workbenches"]: + print("Activating workbench " + workbench + " to access tool " + toolPath) + try: + FreeCADGui.activateWorkbench(workbench) + except Exception: + print("SearchBar: Workbench not present! Was it disabled?") + return + if runTool(): + return + print( + "Tool " + + toolPath + + " not found, was it offered by an extension that is no longer present?" + ) + def toolbarToolTip(nfo, setParent): - workbenches = FreeCADGui.listWorkbenches() - in_workbenches = ['<li>' + (Serialize.iconToHTML(QtGui.QIcon(workbenches[wb].Icon)) if wb in workbenches else '? ') + wb + '</li>' for wb in nfo['action']['workbenches']] - return '<p>Show the ' + nfo['text'] + ' toolbar</p><p>This toolbar appears in the following workbenches: <ul>' + ''.join(in_workbenches) + '</ul></p>' + workbenches = FreeCADGui.listWorkbenches() + in_workbenches = [ + "<li>" + + ( + Serialize_SearchBar.iconToHTML(QtGui.QIcon(workbenches[wb].Icon)) + if wb in workbenches + else "? " + ) + + wb + + "</li>" + for wb in nfo["action"]["workbenches"] + ] + return ( + "<p>Show the " + + nfo["text"] + + " toolbar</p><p>This toolbar appears in the following workbenches: <ul>" + + "".join(in_workbenches) + + "</ul></p>" + ) + def subToolToolTip(nfo, setParent): - return Serialize.iconToHTML(nfo['icon'], 32) + '<p>' + nfo['toolTip'] + '</p>' + return ( + Serialize_SearchBar.iconToHTML(nfo["icon"], 32) + + "<p>" + + nfo["toolTip"] + + "</p>" + ) + def getAllToolbars(): - all_tbs = dict() - for wbname, workbench in FreeCADGui.listWorkbenches().items(): - try: - tbs = workbench.listToolbars() - except: - continue - # careful, tbs contains all the toolbars of the workbench, including shared toolbars - for tb in tbs: - if tb not in all_tbs: - all_tbs[tb] = set() - all_tbs[tb].add(wbname) - return all_tbs + all_tbs = dict() + for wbname, workbench in FreeCADGui.listWorkbenches().items(): + try: + tbs = workbench.listToolbars() + except: + continue + # careful, tbs contains all the toolbars of the workbench, including shared toolbars + for tb in tbs: + if tb not in all_tbs: + all_tbs[tb] = set() + all_tbs[tb].add(wbname) + return all_tbs + def toolbarResultsProvider(): - itemGroups = [] - all_tbs = getAllToolbars() - mw = FreeCADGui.getMainWindow() - for toolbarName, toolbarIsInWorkbenches in all_tbs.items(): - toolbarIsInWorkbenches = sorted(list(toolbarIsInWorkbenches)) - for the_toolbar in mw.findChildren(QtGui.QToolBar, toolbarName): - group = [] - for tbt in the_toolbar.findChildren(QtGui.QToolButton): - text = tbt.text() - act = tbt.defaultAction() - if text != '': - # TODO: there also is the tooltip - icon = tbt.icon() - men = tbt.menu() - subgroup = [] - if men: - subgroup = [] - for mac in men.actions(): - if mac.text(): - action = { 'handler': 'subTool', 'workbenches': toolbarIsInWorkbenches, 'toolbar': toolbarName, 'tool': text, 'subTool': mac.text() } - subgroup.append({'icon':mac.icon(), 'text':mac.text(), 'toolTip': mac.toolTip(), 'action':action, 'subitems':[]}) - # The default action of a menu changes dynamically, instead of triggering the last action, just show the menu. - action = { 'handler': 'tool', 'workbenches': toolbarIsInWorkbenches, 'toolbar': toolbarName, 'tool': text, 'showMenu': bool(men) } - group.append({'icon':icon, 'text':text, 'toolTip': tbt.toolTip(), 'action': action, 'subitems': subgroup}) - # TODO: move the 'workbenches' field to the itemgroup - action = { 'handler': 'toolbar', 'workbenches': toolbarIsInWorkbenches, 'toolbar': toolbarName } - itemGroups.append({ - 'icon': QtGui.QIcon(':/icons/Group.svg'), - 'text': toolbarName, - 'toolTip': '', - 'action': action, - 'subitems': group - }) - return itemGroups + itemGroups = [] + all_tbs = getAllToolbars() + mw = FreeCADGui.getMainWindow() + for toolbarName, toolbarIsInWorkbenches in all_tbs.items(): + toolbarIsInWorkbenches = sorted(list(toolbarIsInWorkbenches)) + for the_toolbar in mw.findChildren(QtGui.QToolBar, toolbarName): + group = [] + for tbt in the_toolbar.findChildren(QtGui.QToolButton): + text = tbt.text() + act = tbt.defaultAction() + if text != "": + # TODO: there also is the tooltip + icon = tbt.icon() + men = tbt.menu() + subgroup = [] + if men: + subgroup = [] + for mac in men.actions(): + if mac.text(): + action = { + "handler": "subTool", + "workbenches": toolbarIsInWorkbenches, + "toolbar": toolbarName, + "tool": text, + "subTool": mac.text(), + } + subgroup.append( + { + "icon": mac.icon(), + "text": mac.text(), + "toolTip": mac.toolTip(), + "action": action, + "subitems": [], + } + ) + # The default action of a menu changes dynamically, instead of triggering the last action, just show the menu. + action = { + "handler": "tool", + "workbenches": toolbarIsInWorkbenches, + "toolbar": toolbarName, + "tool": text, + "showMenu": bool(men), + } + group.append( + { + "icon": icon, + "text": text, + "toolTip": tbt.toolTip(), + "action": action, + "subitems": subgroup, + } + ) + # TODO: move the 'workbenches' field to the itemgroup + action = { + "handler": "toolbar", + "workbenches": toolbarIsInWorkbenches, + "toolbar": toolbarName, + } + itemGroups.append( + { + "icon": QtGui.QIcon(":/icons/Group.svg"), + "text": toolbarName, + "toolTip": "", + "action": action, + "subitems": group, + } + ) + return itemGroups diff --git a/SafeViewer.py b/SafeViewer.py index 8381c20..1154dc3 100644 --- a/SafeViewer.py +++ b/SafeViewer.py @@ -1,106 +1,137 @@ from PySide import QtGui -import FreeCAD +import FreeCAD as App + +# Define the translation +translate = App.Qt.translate + class SafeViewer(QtGui.QWidget): - """FreeCAD uses a modified version of QuarterWidget, so the import pivy.quarter one will cause segfaults. - FreeCAD's FreeCADGui.createViewer() puts the viewer widget inside an MDI window, and detaching it without causing segfaults on exit is tricky. - This class contains some kludges to extract the viewer as a standalone widget and destroy it safely.""" - enabled = FreeCAD.ParamGet('User parameter:BaseApp/Preferences/Mod/SearchBar').GetBool('PreviewEnabled', False) - instances = [] - def __init__(self, parent = None): - super(SafeViewer, self).__init__() - SafeViewer.instances.append(self) - self.init_parent = parent - self.instance_enabled = False # Has this specific instance been enabled? - if SafeViewer.enabled: - self.displaying_warning = False - self.enable() - else: - import FreeCADGui - from PySide import QtCore - self.displaying_warning = True - self.lbl_warning = QtGui.QTextEdit() - self.lbl_warning.setReadOnly(True) - self.lbl_warning.setAlignment(QtCore.Qt.AlignTop) - self.lbl_warning.setText("Warning: the 3D preview has some stability issues. It can cause FreeCAD to crash (usually when quitting the application) and could in theory cause data loss, inside and outside of FreeCAD.") - self.btn_enable_for_this_session = QtGui.QPushButton('Enable 3D preview for this session') - self.btn_enable_for_this_session.clicked.connect(self.enable_for_this_session) - self.btn_enable_for_future_sessions = QtGui.QPushButton('Enable 3D preview for future sessions') - self.btn_enable_for_future_sessions.clicked.connect(self.enable_for_future_sessions) - self.setLayout(QtGui.QVBoxLayout()) - self.layout().addWidget(self.lbl_warning) - self.layout().addWidget(self.btn_enable_for_this_session) - self.layout().addWidget(self.btn_enable_for_future_sessions) - - def enable_for_this_session(self): - if not SafeViewer.enabled: - for instance in SafeViewer.instances: - instance.enable() + """FreeCAD uses a modified version of QuarterWidget, so the import pivy.quarter one will cause segfaults. + FreeCAD's FreeCADGui.createViewer() puts the viewer widget inside an MDI window, and detaching it without causing segfaults on exit is tricky. + This class contains some kludges to extract the viewer as a standalone widget and destroy it safely. + """ - def enable_for_future_sessions(self): - if not SafeViewer.enabled: - # Store in prefs - FreeCAD.ParamGet('User parameter:BaseApp/Preferences/Mod/SearchBar').SetBool('PreviewEnabled', True) - # Then enable as usual - self.enable_for_this_session() + enabled = App.ParamGet("User parameter:BaseApp/Preferences/Mod/SearchBar").GetBool( + "PreviewEnabled", False + ) + instances = [] - def enable(self): - if not self.instance_enabled: - import FreeCADGui - # TODO: use a mutex wrapping the entire method, if possible - SafeViewer.enabled = True - self.instance_enabled = True # Has this specific instance been enabled? + def __init__(self, parent=None): + super(SafeViewer, self).__init__() + SafeViewer.instances.append(self) + self.init_parent = parent + self.instance_enabled = False # Has this specific instance been enabled? + if SafeViewer.enabled: + self.displaying_warning = False + self.enable() + else: + import FreeCADGui + from PySide import QtCore - if (self.displaying_warning): - self.layout().removeWidget(self.lbl_warning) - self.layout().removeWidget(self.btn_enable_for_this_session) - self.layout().removeWidget(self.btn_enable_for_future_sessions) + self.displaying_warning = True + self.lbl_warning = QtGui.QTextEdit() + self.lbl_warning.setReadOnly(True) + self.lbl_warning.setAlignment(QtCore.Qt.AlignTop) + self.lbl_warning.setText( + translate( + "SearchBar", + "Warning: the 3D preview has some stability issues. It can cause FreeCAD to crash (usually when quitting the application) and could in theory cause data loss, inside and outside of App.", + ) + ) + self.btn_enable_for_this_session = QtGui.QPushButton( + translate("SearchBar", "Enable 3D preview for this session") + ) + self.btn_enable_for_this_session.clicked.connect( + self.enable_for_this_session + ) + self.btn_enable_for_future_sessions = QtGui.QPushButton( + translate("SearchBar", "Enable 3D preview for future sessions") + ) + self.btn_enable_for_future_sessions.clicked.connect( + self.enable_for_future_sessions + ) + self.setLayout(QtGui.QVBoxLayout()) + self.layout().addWidget(self.lbl_warning) + self.layout().addWidget(self.btn_enable_for_this_session) + self.layout().addWidget(self.btn_enable_for_future_sessions) - self.viewer = FreeCADGui.createViewer() - self.graphicsView = self.viewer.graphicsView() - self.oldGraphicsViewParent = self.graphicsView.parent() - self.oldGraphicsViewParentParent = self.oldGraphicsViewParent.parent() - self.oldGraphicsViewParentParentParent = self.oldGraphicsViewParentParent.parent() + def enable_for_this_session(self): + if not SafeViewer.enabled: + for instance in SafeViewer.instances: + instance.enable() - # Avoid segfault but still hide the undesired window by moving it to a new hidden MDI area. - self.hiddenQMDIArea = QtGui.QMdiArea() - self.hiddenQMDIArea.addSubWindow(self.oldGraphicsViewParentParentParent) + def enable_for_future_sessions(self): + if not SafeViewer.enabled: + # Store in prefs + App.ParamGet("User parameter:BaseApp/Preferences/Mod/SearchBar").SetBool( + "PreviewEnabled", True + ) + # Then enable as usual + self.enable_for_this_session() - self.private_widget = self.oldGraphicsViewParent - self.private_widget.setParent(self.init_parent) + def enable(self): + if not self.instance_enabled: + import FreeCADGui - self.setLayout(QtGui.QVBoxLayout()) - self.layout().addWidget(self.private_widget) - self.layout().setContentsMargins(0,0,0,0) + # TODO: use a mutex wrapping the entire method, if possible + SafeViewer.enabled = True + self.instance_enabled = True # Has this specific instance been enabled? - def fin(slf): - slf.finalizer() + if self.displaying_warning: + self.layout().removeWidget(self.lbl_warning) + self.layout().removeWidget(self.btn_enable_for_this_session) + self.layout().removeWidget(self.btn_enable_for_future_sessions) - import weakref - weakref.finalize(self, fin, self) + self.viewer = FreeCADGui.createViewer() + self.graphicsView = self.viewer.graphicsView() + self.oldGraphicsViewParent = self.graphicsView.parent() + self.oldGraphicsViewParentParent = self.oldGraphicsViewParent.parent() + self.oldGraphicsViewParentParentParent = ( + self.oldGraphicsViewParentParent.parent() + ) - self.destroyed.connect(self.finalizer) + # Avoid segfault but still hide the undesired window by moving it to a new hidden MDI area. + self.hiddenQMDIArea = QtGui.QMdiArea() + self.hiddenQMDIArea.addSubWindow(self.oldGraphicsViewParentParentParent) - def finalizer(self): - # Cleanup in an order that doesn't cause a segfault: - if SafeViewer.enabled: - self.private_widget.setParent(self.oldGraphicsViewParentParent) - self.oldGraphicsViewParentParentParent.close() - self.oldGraphicsViewParentParentParent = None - self.oldGraphicsViewParentParent = None - self.oldGraphicsViewParent = None - self.graphicsView = None - self.viewer = None - #self.parent = None - self.init_parent = None - self.hiddenQMDIArea = None + self.private_widget = self.oldGraphicsViewParent + self.private_widget.setParent(self.init_parent) + + self.setLayout(QtGui.QVBoxLayout()) + self.layout().addWidget(self.private_widget) + self.layout().setContentsMargins(0, 0, 0, 0) + + def fin(slf): + slf.finalizer() + + import weakref + + weakref.finalize(self, fin, self) + + self.destroyed.connect(self.finalizer) + + def finalizer(self): + # Cleanup in an order that doesn't cause a segfault: + if SafeViewer.enabled: + self.private_widget.setParent(self.oldGraphicsViewParentParent) + self.oldGraphicsViewParentParentParent.close() + self.oldGraphicsViewParentParentParent = None + self.oldGraphicsViewParentParent = None + self.oldGraphicsViewParent = None + self.graphicsView = None + self.viewer = None + # self.parent = None + self.init_parent = None + self.hiddenQMDIArea = None + + def showSceneGraph(self, g): + import FreeCAD as App + + if SafeViewer.enabled: + self.viewer.getViewer().setSceneGraph(g) + self.viewer.setCameraOrientation(App.Rotation(1, 1, 0, 0.2)) + self.viewer.fitAll() - def showSceneGraph(self, g): - import FreeCAD as App - if SafeViewer.enabled: - self.viewer.getViewer().setSceneGraph(g) - self.viewer.setCameraOrientation(App.Rotation(1,1,0, 0.2)) - self.viewer.fitAll() """ # Example use: diff --git a/SearchBox.py b/SearchBox.py index fd3cf77..e3160a0 100644 --- a/SearchBox.py +++ b/SearchBox.py @@ -1,372 +1,543 @@ +import FreeCAD as App +import FreeCADGui as Gui import os -from PySide import QtGui -from PySide import QtCore -import FreeCADGui # just used for FreeCADGui.updateGui() + +from PySide.QtCore import ( + Qt, + SIGNAL, + QSize, + QIdentityProxyModel, + QPoint, +) +from PySide.QtWidgets import ( + QTabWidget, + QSlider, + QSpinBox, + QCheckBox, + QComboBox, + QLabel, + QTabWidget, + QSizePolicy, + QPushButton, + QLineEdit, + QTextEdit, + QListView, + QAbstractItemView, + QWidget, + QVBoxLayout, + QApplication, + QListWidget, + QWidgetAction, +) +from PySide.QtGui import ( + QIcon, + QPixmap, + QColor, + QStandardItemModel, + QShortcut, + QKeySequence, + QStandardItem, +) + from SearchBoxLight import SearchBoxLight +import Parameters_SearchBar as Parameters + +genericToolIcon = QIcon(Parameters.genericToolIcon_Pixmap) globalIgnoreFocusOut = False -genericToolIcon = QtGui.QIcon(QtGui.QIcon(os.path.dirname(__file__) + '/Tango-Tools-spanner-hammer.svg')) +# Define the translation +translate = App.Qt.translate + +# Avoid garbage collection by storing the action in a global variable +wax = None +sea = None +tbr = None + def easyToolTipWidget(html): - foo = QtGui.QTextEdit() - foo.setReadOnly(True) - foo.setAlignment(QtCore.Qt.AlignTop) - foo.setText(html) - return foo + foo = QTextEdit() + foo.setReadOnly(True) + foo.setAlignment(Qt.AlignmentFlag.AlignTop) + foo.setText(html) + return foo -class SearchBox(QtGui.QLineEdit): - # The following block of code is present in the lightweight proxy SearchBoxLight - ''' - resultSelected = QtCore.Signal(int, int) - ''' - @staticmethod - def lazyInit(self): - if self.isInitialized: - return self - getItemGroups = self.getItemGroups - getToolTip = self.getToolTip - getItemDelegate = self.getItemDelegate - maxVisibleRows = self.maxVisibleRows - # The following block of code is executed by the lightweight proxy SearchBoxLight - ''' + +def SearchBoxFunction(mw): + import SearchBoxLight + + global wax, sea, tbr + + if mw: + if sea is None: + sea = SearchBoxLight.SearchBoxLight( + getItemGroups=lambda: __import__("GetItemGroups").getItemGroups(), + getToolTip=lambda groupId, setParent: __import__("GetItemGroups").getToolTip(groupId, setParent), + getItemDelegate=lambda: __import__("IndentedItemDelegate").IndentedItemDelegate(), + ) + sea.resultSelected.connect( + lambda index, groupId: __import__("GetItemGroups").onResultSelected(index, groupId) + ) + + if wax is None: + wax = QWidgetAction(None) + wax.setWhatsThis( + translate( + "SearchBar", + "Use this search bar to find tools, document objects, preferences and more", + ) + ) + + sea.setWhatsThis( + translate( + "SearchBar", + "Use this search bar to find tools, document objects, preferences and more", + ) + ) + wax.setDefaultWidget(sea) + return wax + + +class SearchBox(QLineEdit): + # The following block of code is present in the lightweight proxy SearchBoxLight + """ + resultSelected = QtCore.Signal(int, int) + """ + + @staticmethod + def lazyInit(self): + if self.isInitialized: + return self + getItemGroups = self.getItemGroups + getToolTip = self.getToolTip + getItemDelegate = self.getItemDelegate + maxVisibleRows = self.maxVisibleRows + # The following block of code is executed by the lightweight proxy SearchBoxLight + """ # Call parent constructor super(SearchBoxLight, self).__init__(parent) # Connect signals and slots self.textChanged.connect(self.filterModel) # Thanks to https://saurabhg.com/programming/search-box-using-qlineedit/ for indicating a few useful options - ico = QtGui.QIcon(':/icons/help-browser.svg') - #ico = QtGui.QIcon(':/icons/WhatsThis.svg') + ico = QIcon(':/icons/help-browser.svg') + #ico = QIcon(':/icons/WhatsThis.svg') self.addAction(ico, QtGui.QLineEdit.LeadingPosition) self.setClearButtonEnabled(True) self.setPlaceholderText('Search tools, prefs & tree') self.setFixedWidth(200) # needed to avoid a change of width when the clear button appears/disappears - ''' + """ - # Save arguments - #self.model = model - self.getItemGroups = getItemGroups - self.getToolTip = getToolTip - self.itemGroups = None # Will be initialized by calling getItemGroups() the first time the search box gains focus, through focusInEvent and refreshItemGroups - self.maxVisibleRows = maxVisibleRows # TODO: use this to compute the correct height - # Create proxy model - self.proxyModel = QtCore.QIdentityProxyModel() - # Filtered model to which items are manually added. Store it as a property of the object instead of a local variable, to prevent grbage collection. - self.mdl = QtGui.QStandardItemModel() - #self.proxyModel.setModel(self.model) - # Create list view - self.listView = QtGui.QListView(self) - self.listView.setWindowFlags(QtGui.Qt.ToolTip) - self.listView.setWindowFlag(QtGui.Qt.FramelessWindowHint) - self.listView.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) - self.listView.setModel(self.proxyModel) - self.listView.setItemDelegate(getItemDelegate()) # https://stackoverflow.com/a/65930408/324969 - # make the QListView non-editable - self.listView.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) - # Create pane for showing extra info about the currently-selected tool - #self.extraInfo = QtGui.QLabel() - self.extraInfo = QtGui.QWidget() - self.extraInfo.setWindowFlags(QtGui.Qt.ToolTip) - self.extraInfo.setWindowFlag(QtGui.Qt.FramelessWindowHint) - self.extraInfo.setLayout(QtGui.QVBoxLayout()) - self.extraInfo.layout().setContentsMargins(0,0,0,0) - self.setExtraInfoIsActive = False - self.pendingExtraInfo = None - self.currentExtraInfo = None - # Connect signals and slots - self.listView.clicked.connect(lambda x: self.selectResult('select', x)) - self.listView.selectionModel().selectionChanged.connect(self.onSelectionChanged) + # Save arguments + # self.model = model + self.getItemGroups = getItemGroups + self.getToolTip = getToolTip + self.itemGroups = None # Will be initialized by calling getItemGroups() the first time the search box gains focus, through focusInEvent and refreshItemGroups + self.maxVisibleRows = maxVisibleRows # TODO: use this to compute the correct height + # Create proxy model + self.proxyModel = QIdentityProxyModel() + # Filtered model to which items are manually added. Store it as a property of the object instead of a local variable, to prevent grbage collection. + self.mdl = QStandardItemModel() + # self.proxyModel.setModel(self.model) + # Create list view + self.listView = QListView(self) + self.listView.setWindowFlags(Qt.WindowType.ToolTip) + self.listView.setWindowFlag(Qt.WindowType.FramelessWindowHint) + self.listView.setSelectionMode(self.listView.SelectionMode.SingleSelection) + self.listView.setModel(self.proxyModel) + self.listView.setItemDelegate(getItemDelegate()) # https://stackoverflow.com/a/65930408/324969 + self.listView.setMouseTracking(True) + # make the QListView non-editable + self.listView.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + # Create pane for showing extra info about the currently-selected tool + # self.extraInfo = QtGui.QLabel() + self.extraInfo = QWidget() + self.extraInfo.setWindowFlags(Qt.WindowType.ToolTip) + self.extraInfo.setWindowFlag(Qt.WindowType.FramelessWindowHint) + self.extraInfo.setLayout(QVBoxLayout()) + self.extraInfo.layout().setContentsMargins(0, 0, 0, 0) + self.setExtraInfoIsActive = False + self.pendingExtraInfo = None + self.currentExtraInfo = None + # Connect signals and slots + self.listView.clicked.connect( + lambda x: self.selectResult("select", x) + ) # This makes all workbenches appear. TODO: findout why, a click event seems not logic + self.listView.selectionModel().selectionChanged.connect( + self.onSelectionChanged + ) # This updates the details when using the keyboard + # Add custom mouse events. On windows the click events were not working for Searcbar versions 1.2.x and older. + # These events and their proxies in the SearchBorLight fixes this + self.listView.mousePressEvent = lambda event: self.proxyMousePressEvent(event) + self.listView.mouseMoveEvent = lambda event: self.proxyMouseMoveEvent(event) + self.extraInfo.leaveEvent = lambda event: self.proxyLeaveEvent(event) - # Note: should probably use the eventFilter method instead... - wdgctx = QtCore.Qt.ShortcutContext.WidgetShortcut + # Note: should probably use the eventFilter method instead... + wdgctx = Qt.ShortcutContext.WidgetShortcut - QtGui.QShortcut(QtGui.QKeySequence(QtCore.Qt.Key_Down), self, context = wdgctx).activated.connect(self.listDown) - QtGui.QShortcut(QtGui.QKeySequence(QtCore.Qt.Key_Up), self, context = wdgctx).activated.connect(self.listUp) - QtGui.QShortcut(QtGui.QKeySequence(QtCore.Qt.Key_PageDown), self, context = wdgctx).activated.connect(self.listPageDown) - QtGui.QShortcut(QtGui.QKeySequence(QtCore.Qt.Key_PageUp), self, context = wdgctx).activated.connect(self.listPageUp) + QShortcut(QKeySequence(Qt.Key.Key_Down), self, context=wdgctx).activated.connect(self.listDown) + QShortcut(QKeySequence(Qt.Key.Key_Up), self, context=wdgctx).activated.connect(self.listUp) + QShortcut(QKeySequence(Qt.Key.Key_PageDown), self, context=wdgctx).activated.connect(self.listPageDown) + QShortcut(QKeySequence(Qt.Key.Key_PageUp), self, context=wdgctx).activated.connect(self.listPageUp) - # Home and End do not work, for some reason. - #QtGui.QShortcut(QtGui.QKeySequence.MoveToEndOfDocument, self, context = wdgctx).activated.connect(self.listEnd) - #QtGui.QShortcut(QtGui.QKeySequence.MoveToStartOfDocument, self, context = wdgctx).activated.connect(self.listStart) - #QtGui.QShortcut(QtGui.QKeySequence(QtCore.Qt.Key_End), self, context = wdgctx).activated.connect(self.listEnd) - #QtGui.QShortcut(QtGui.QKeySequence('Home'), self, context = wdgctx).activated.connect(self.listStart) - - QtGui.QShortcut(QtGui.QKeySequence(QtCore.Qt.Key_Enter), self, context = wdgctx).activated.connect(self.listAccept) - QtGui.QShortcut(QtGui.QKeySequence(QtCore.Qt.Key_Return), self, context = wdgctx).activated.connect(self.listAccept) - QtGui.QShortcut(QtGui.QKeySequence('Ctrl+Return'), self, context = wdgctx).activated.connect(self.listAcceptToggle) - QtGui.QShortcut(QtGui.QKeySequence('Ctrl+Enter'), self, context = wdgctx).activated.connect(self.listAcceptToggle) - QtGui.QShortcut(QtGui.QKeySequence('Ctrl+Space'), self, context = wdgctx).activated.connect(self.listAcceptToggle) - - QtGui.QShortcut(QtGui.QKeySequence(QtCore.Qt.Key_Escape), self, context = wdgctx).activated.connect(self.listCancel) + # Home and End do not work, for some reason. + # QShortcut(QKeySequence.MoveToEndOfDocument, self, context = wdgctx).activated.connect(self.listEnd) + # QShortcut(QKeySequence.MoveToStartOfDocument, self, context = wdgctx).activated.connect(self.listStart) + # QShortcut(QKeySequence(Qt.Key.Key_End), self, context = wdgctx).activated.connect(self.listEnd) + # QShortcut(QKeySequence('Home'), self, context = wdgctx).activated.connect(self.listStart) - # Initialize the model with the full list (assuming the text() is empty) - #self.proxyFilterModel(self.text()) # This is done by refreshItemGroups on focusInEvent, because the initial loading from cache can take time - self.firstShowList = True - self.isInitialized = True - return self + QShortcut(QKeySequence(Qt.Key.Key_Enter), self, context=wdgctx).activated.connect(self.listAccept) + QShortcut(QKeySequence(Qt.Key.Key_Return), self, context=wdgctx).activated.connect(self.listAccept) + QShortcut(QKeySequence("Ctrl+Return"), self, context=wdgctx).activated.connect(self.listAcceptToggle) + QShortcut(QKeySequence("Ctrl+Enter"), self, context=wdgctx).activated.connect(self.listAcceptToggle) + QShortcut(QKeySequence("Ctrl+Space"), self, context=wdgctx).activated.connect(self.listAcceptToggle) - @staticmethod - def refreshItemGroups(self): - self.itemGroups = self.getItemGroups() - self.proxyFilterModel(self.text()) + QShortcut(QKeySequence(Qt.Key.Key_Escape), self, context=wdgctx).activated.connect(self.listCancel) - @staticmethod - def proxyFocusInEvent(self, qFocusEvent): - if self.firstShowList: - mdl = QtGui.QStandardItemModel() - mdl.appendRow([QtGui.QStandardItem(genericToolIcon, 'Please wait, loading results from cache…'), - QtGui.QStandardItem('0'), - QtGui.QStandardItem('-1')]) - self.proxyModel.setSourceModel(mdl) - self.showList() - self.firstShowList = False - FreeCADGui.updateGui() - global globalIgnoreFocusOut - if not globalIgnoreFocusOut: - self.refreshItemGroups() - self.showList() - super(SearchBoxLight, self).focusInEvent(qFocusEvent) + # Initialize the model with the full list (assuming the text() is empty) + # self.proxyFilterModel(self.text()) # This is done by refreshItemGroups on focusInEvent, because the initial loading from cache can take time + self.firstShowList = True + self.isInitialized = True + return self - @staticmethod - def proxyFocusOutEvent(self, qFocusEvent): - global globalIgnoreFocusOut - if not globalIgnoreFocusOut: - self.hideList() - super(SearchBoxLight, self).focusOutEvent(qFocusEvent) + @staticmethod + def proxyMousePressEvent(self, event): + if self.listView.underMouse(): + self.selectResult(mode=None, index=self.listView.currentIndex()) + else: + event.ignore() + return - @staticmethod - def movementKey(self, rowUpdate): - currentIndex = self.listView.currentIndex() - self.showList() - if self.listView.isEnabled(): - currentRow = currentIndex.row() + @staticmethod + def proxyMouseMoveEvent(self, arg__1): + index = self.listView.indexAt(arg__1.pos()) + self.listView.setCurrentIndex(index) + + self.setExtraInfo(index) + # Poor attempt to circumvent a glitch where the extra info pane stays visible after pressing Return + if not self.listView.isHidden(): + self.showExtraInfo() + if self.listView.isHidden(): + self.hideExtraInfo() + return + + @staticmethod + def refreshItemGroups(self): + self.itemGroups = self.getItemGroups() + self.proxyFilterModel(self.text()) + + @staticmethod + def proxyFocusInEvent(self, qFocusEvent): + # if the extrainfo is under the cursor, don't focus but only show the list + if self.extraInfo.underMouse(): + self.showList() + qFocusEvent.ignore() + return + if self.firstShowList: + mdl = QStandardItemModel() + mdl.appendRow( + [ + QStandardItem( + genericToolIcon, + translate("SearchBar", "Please wait, loading results from cache…"), + ), + QStandardItem("0"), + QStandardItem("-1"), + ] + ) + self.proxyModel.setSourceModel(mdl) + self.showList() + self.firstShowList = False + Gui.updateGui() + global globalIgnoreFocusOut + if not globalIgnoreFocusOut: + self.refreshItemGroups() + self.showList() + super(SearchBoxLight, self).focusInEvent(qFocusEvent) + return + + @staticmethod + def proxyFocusOutEvent(self, qFocusEvent): + global globalIgnoreFocusOut + if not globalIgnoreFocusOut: + self.hideList() + # super(SearchBoxLight, self).focusOutEvent(qFocusEvent) + + @staticmethod + def proxyLeaveEvent(self, qFocusEvent): + self.clearFocus() + self.hideList() + return + + @staticmethod + def movementKey(self, rowUpdate): + currentIndex = self.listView.currentIndex() + self.showList() + if self.listView.isEnabled(): + currentRow = currentIndex.row() + nbRows = self.listView.model().rowCount() + if nbRows > 0: + newRow = rowUpdate(currentRow, nbRows) + index = self.listView.model().index(newRow, 0) + self.listView.setCurrentIndex(index) + + @staticmethod + def proxyListDown(self): + self.movementKey(lambda current, nbRows: (current + 1) % nbRows) + + @staticmethod + def proxyListUp(self): + self.movementKey(lambda current, nbRows: (current - 1) % nbRows) + + @staticmethod + def proxyListPageDown(self): + self.movementKey(lambda current, nbRows: min(current + max(1, self.maxVisibleRows / 2), nbRows - 1)) + + @staticmethod + def proxyListPageUp(self): + self.movementKey(lambda current, nbRows: max(current - max(1, self.maxVisibleRows / 2), 0)) + + @staticmethod + def proxyListEnd(self): + self.movementKey(lambda current, nbRows: nbRows - 1) + + @staticmethod + def proxyListStart(self): + self.movementKey(lambda current, nbRows: 0) + + @staticmethod + def acceptKey(self, mode): + currentIndex = self.listView.currentIndex() + self.showList() + if currentIndex.isValid(): + self.selectResult(mode, currentIndex) + + @staticmethod + def proxyListAccept(self): + self.acceptKey("select") + + @staticmethod + def proxyListAcceptToggle(self): + self.acceptKey("toggle") + + @staticmethod + def cancelKey(self): + self.hideList() + self.clearFocus() + + # QKeySequence::Cancel + @staticmethod + def proxyListCancel(self): + self.cancelKey() + + @staticmethod + def proxyKeyPressEvent(self, qKeyEvent): + key = qKeyEvent.key() + modifiers = qKeyEvent.modifiers() + self.showList() + if key == Qt.Key.Key_Home and modifiers & Qt.Key.CTRL != 0: + self.listStart() + elif key == Qt.Key.Key_End and modifiers & Qt.Key.CTRL != 0: + self.listEnd() + else: + super(SearchBoxLight, self).keyPressEvent(qKeyEvent) + + @staticmethod + def showList(self): + self.setFloatingWidgetsGeometry() + if not self.listView.isVisible(): + self.listView.show() + self.showExtraInfo() + + @staticmethod + def hideList(self): + self.listView.hide() + self.hideExtraInfo() + + @staticmethod + def hideExtraInfo(self): + self.extraInfo.hide() + + @staticmethod + def selectResult(self, mode: None, index): + groupId = int(index.model().itemData(index.siblingAtColumn(2))[0]) + self.hideList() + # TODO: allow other options, e.g. some items could act as combinators / cumulative filters + self.setText("") + self.proxyFilterModel(self.text()) + # TODO: emit index relative to the base model + self.resultSelected.emit(index, groupId) + + @staticmethod + def proxyFilterModel(self, userInput): + # TODO: this will cause a race condition if it is accessed while being modified + def matches(s): + return userInput.lower() in s.lower() + + def filterGroup(group): + if matches(group["text"]): + # If a group matches, include the entire subtree (might need to disable this if it causes too much noise) + return group + else: + subitems = filterGroups(group["subitems"]) + # if len(subitems) == 0: + # self.index = 0 + if len(subitems) > 0 or matches(group["text"]): + return { + "id": group["id"], + "text": group["text"], + "icon": group["icon"], + "action": group["action"], + "toolTip": group["toolTip"], + "subitems": subitems, + } + else: + return None + + def filterGroups(groups): + groups = (filterGroup(group) for group in groups) + return [group for group in groups if group is not None] + + self.mdl = QStandardItemModel() + self.mdl.appendColumn([]) + + def addGroups(filteredGroups, depth=0): + for group in filteredGroups: + # TODO: this is not very clean, we should memorize the index from the original itemgroups + self.mdl.appendRow( + [ + QStandardItem(group["icon"] or genericToolIcon, group["text"]), + QStandardItem(str(depth)), + QStandardItem(str(group["id"])), + ] + ) + addGroups(group["subitems"], depth + 1) + + addGroups(filterGroups(self.itemGroups)) + self.proxyModel.setSourceModel(self.mdl) + self.currentExtraInfo = None # Unset this so that the ExtraInfo can be updated + # TODO: try to find the already-highlighted item + indexSelect = 1 nbRows = self.listView.model().rowCount() if nbRows > 0: - newRow = rowUpdate(currentRow, nbRows) - index = self.listView.model().index(newRow, 0) - self.listView.setCurrentIndex(index) - - @staticmethod - def proxyListDown(self): self.movementKey(lambda current, nbRows: (current + 1) % nbRows) - @staticmethod - def proxyListUp(self): self.movementKey(lambda current, nbRows: (current - 1) % nbRows) - @staticmethod - def proxyListPageDown(self): self.movementKey(lambda current, nbRows: min(current + max(1, self.maxVisibleRows / 2), nbRows - 1)) - @staticmethod - def proxyListPageUp(self): self.movementKey(lambda current, nbRows: max(current - max(1, self.maxVisibleRows / 2), 0)) - @staticmethod - def proxyListEnd(self): self.movementKey(lambda current, nbRows: nbRows - 1) - @staticmethod - def proxyListStart(self): self.movementKey(lambda current, nbRows: 0) - - @staticmethod - def acceptKey(self, mode): - currentIndex = self.listView.currentIndex() - self.showList() - if currentIndex.isValid(): - self.selectResult(mode, currentIndex) - - @staticmethod - def proxyListAccept(self): self.acceptKey('select') - @staticmethod - def proxyListAcceptToggle(self): self.acceptKey('toggle') - - @staticmethod - def cancelKey(self): - self.hideList() - self.clearFocus() - - # QKeySequence::Cancel - @staticmethod - def proxyListCancel(self): self.cancelKey() - - @staticmethod - def proxyKeyPressEvent(self, qKeyEvent): - key = qKeyEvent.key() - modifiers = qKeyEvent.modifiers() - self.showList() - if key == QtCore.Qt.Key_Home and modifiers & QtCore.Qt.CTRL != 0: - self.listStart() - elif key == QtCore.Qt.Key_End and modifiers & QtCore.Qt.CTRL != 0: - self.listEnd() - else: - super(SearchBoxLight, self).keyPressEvent(qKeyEvent) - - @staticmethod - def showList(self): - self.setFloatingWidgetsGeometry() - if not self.listView.isVisible(): - self.listView.show() - self.showExtraInfo() - - @staticmethod - def hideList(self): - self.listView.hide() - self.hideExtraInfo() - - @staticmethod - def hideExtraInfo(self): - self.extraInfo.hide() - - @staticmethod - def selectResult(self, mode, index): - groupId = int(index.model().itemData(index.siblingAtColumn(2))[0]) - self.hideList() - # TODO: allow other options, e.g. some items could act as combinators / cumulative filters - self.setText('') - self.proxyFilterModel(self.text()) - # TODO: emit index relative to the base model - self.resultSelected.emit(index, groupId) - - @staticmethod - def proxyFilterModel(self, userInput): - # TODO: this will cause a race condition if it is accessed while being modified - def matches(s): - return userInput.lower() in s.lower() - def filterGroup(group): - if matches(group['text']): - # If a group matches, include the entire subtree (might need to disable this if it causes too much noise) - return group - else: - subitems = filterGroups(group['subitems']) - if len(subitems) > 0 or matches(group['text']): - return { 'id': group['id'], 'text': group['text'], 'icon': group['icon'], 'action': group['action'], 'toolTip':group['toolTip'], 'subitems': subitems } + index = self.listView.model().index(indexSelect, 0) + self.listView.setCurrentIndex(index) + self.setExtraInfo(index) else: - return None - def filterGroups(groups): - groups = (filterGroup(group) for group in groups) - return [group for group in groups if group is not None] - self.mdl = QtGui.QStandardItemModel() - self.mdl.appendColumn([]) - def addGroups(filteredGroups, depth=0): - for group in filteredGroups: - # TODO: this is not very clean, we should memorize the index from the original itemgroups - self.mdl.appendRow([QtGui.QStandardItem(group['icon'] or genericToolIcon, group['text']), - QtGui.QStandardItem(str(depth)), - QtGui.QStandardItem(str(group['id']))]) - addGroups(group['subitems'], depth+1) - addGroups(filterGroups(self.itemGroups)) - self.proxyModel.setSourceModel(self.mdl) - self.currentExtraInfo = None # Unset this so that the ExtraInfo can be updated - # TODO: try to find the already-highlighted item - nbRows = self.listView.model().rowCount() - if nbRows > 0: - index = self.listView.model().index(0, 0) - self.listView.setCurrentIndex(index) - self.setExtraInfo(index) - else: - self.clearExtraInfo() - #self.showList() + self.clearExtraInfo() + # self.showList() - @staticmethod - def setFloatingWidgetsGeometry(self): - def getScreenPosition(widget): - geo = widget.geometry() - parent = widget.parent() - parentPos = getScreenPosition(parent) if parent is not None else QtCore.QPoint(0,0) - return QtCore.QPoint(geo.x() + parentPos.x(), geo.y() + parentPos.y()) - pos = getScreenPosition(self) - siz = self.size() - screen = QtGui.QGuiApplication.screenAt(pos) - x = pos.x() - y = pos.y() + siz.height() - hint_w = self.listView.sizeHint().width() - # TODO: this can still bump into the bottom of the screen, in that case we should flip - w = max(siz.width(), hint_w) - h = 200 # TODO: set height / size here according to desired number of items - extraw = w # choose a preferred width that doesn't change all the time, - # self.extraInfo.sizeHint().width() would change for every item. - extrax = x - extraw - if screen is not None: - scr = screen.geometry() - x = min(scr.x() + scr.width() - hint_w, x) - extraleftw = x - scr.x() - extrarightw = scr.x() + scr.width() - x - # flip the extraInfo if it doesn't fit on the screen - if extraleftw < extraw and extrarightw > extraleftw: - extrax = x + w - extraw = min(extrarightw, extraw) - else: + @staticmethod + def setFloatingWidgetsGeometry(self): + def getScreenPosition(widget): + geo = widget.geometry() + parent = widget.parent() + parentPos = getScreenPosition(parent) if parent is not None else QPoint(0, 0) + return QPoint(geo.x() + parentPos.x(), geo.y() + parentPos.y()) + + pos = getScreenPosition(self) + siz = self.size() + screen = QApplication.screenAt(pos) + x = pos.x() + y = pos.y() + siz.height() + hint_w = self.listView.sizeHint().width() + # TODO: this can still bump into the bottom of the screen, in that case we should flip + w = max(siz.width(), hint_w) + h = 200 # TODO: set height / size here according to desired number of items + extraw = w # choose a preferred width that doesn't change all the time, + # self.extraInfo.sizeHint().width() would change for every item. extrax = x - extraw - extraw = min(extraleftw, extraw) - self.listView.setGeometry(x, y, w, h) - self.extraInfo.setGeometry(extrax, y, extraw, h) + if screen is not None: + scr = screen.geometry() + x = min(scr.x() + scr.width() - hint_w, x) + extraleftw = x - scr.x() + extrarightw = scr.x() + scr.width() - x + # flip the extraInfo if it doesn't fit on the screen + if extraleftw < extraw and extrarightw > extraleftw: + extrax = x + w + extraw = min(extrarightw, extraw) + else: + extrax = x - extraw + extraw = min(extraleftw, extraw) + self.listView.setGeometry(x, y, w, h) + self.extraInfo.setGeometry(extrax, y, extraw, h) - @staticmethod - def proxyOnSelectionChanged(self, selected, deselected): - # The list has .setSelectionMode(QtGui.QAbstractItemView.SingleSelection), - # so there is always at most one index in selected.indexes() and at most one - # index in deselected.indexes() - selected = selected.indexes() - deselected = deselected.indexes() - if len(selected) > 0: - index = selected[0] - self.setExtraInfo(index) - # Poor attempt to circumvent a glitch where the extra info pane stays visible after pressing Return - if not self.listView.isHidden(): - self.showExtraInfo() - elif len(deselected) > 0: - self.hideExtraInfo() + @staticmethod + def proxyOnSelectionChanged(self, selected, deselected): + # The list has .setSelectionMode(QAbstractItemView.SingleSelection), + # so there is always at most one index in selected.indexes() and at most one + # index in deselected.indexes() + selected = selected.indexes() + deselected = deselected.indexes() + if len(selected) > 0: + index = selected[0] + self.setExtraInfo(index) + # Poor attempt to circumvent a glitch where the extra info pane stays visible after pressing Return + if not self.listView.isHidden(): + self.showExtraInfo() + elif len(deselected) > 0: + self.hideExtraInfo() - @staticmethod - def setExtraInfo(self, index): - if self.currentExtraInfo == (index.row(), index.column(), index.model()): - # avoid useless updates of the extra info window; this also prevents segfaults when the widget - # is replaced when selecting an option from the right-click context menu - return - self.currentExtraInfo = (index.row(), index.column(), index.model()) - # TODO: use an atomic swap or mutex if possible - if self.setExtraInfoIsActive: - self.pendingExtraInfo = index - #print("boom") - else: - self.setExtraInfoIsActive = True - #print("lock") - # setExtraInfo can be called multiple times while this function is running, - # so just before existing we check for the latest pending call and execute it, - # if during that second execution some other calls are made the latest of those will - # be queued by the code a few lines above this one, and the loop will continue processing - # until an iteration during which no further call was made. - while True: - groupId = str(index.model().itemData(index.siblingAtColumn(2))[0]) - # TODO: move this outside of this class, probably use a single metadata - # This is a hack to allow some widgets to set the parent and recompute their size - # during their construction. - parentIsSet = False - def setParent(toolTipWidget): - nonlocal parentIsSet - parentIsSet = True - w = self.extraInfo.layout().takeAt(0) - while w: - if hasattr(w.widget(), 'finalizer'): - # The 3D viewer segfaults very easily if it is used after being destroyed, and some Python/C++ interop seems to overzealously destroys some widgets, including this one, too soon? - # Ensuring that we properly detacth the 3D viewer widget before discarding its parent seems to avoid these crashes. - #print('FINALIZER') - w.widget().finalizer() - if w.widget() is not None: - w.widget().hide() # hide before detaching, or we have widgets floating as their own window that appear for a split second in some cases. - w.widget().setParent(None) - w = self.extraInfo.layout().takeAt(0) - self.extraInfo.layout().addWidget(toolTipWidget) - self.setFloatingWidgetsGeometry() - toolTipWidget = self.getToolTip(groupId, setParent) - if isinstance(toolTipWidget, str): - toolTipWidget = easyToolTipWidget(toolTipWidget) - if not parentIsSet: - setParent(toolTipWidget) - if self.pendingExtraInfo is not None: - index = self.pendingExtraInfo - self.pendingExtraInfo = None + @staticmethod + def setExtraInfo(self, index): + if self.currentExtraInfo == (index.row(), index.column(), index.model()): + # avoid useless updates of the extra info window; this also prevents segfaults when the widget + # is replaced when selecting an option from the right-click context menu + return + self.currentExtraInfo = (index.row(), index.column(), index.model()) + # TODO: use an atomic swap or mutex if possible + if self.setExtraInfoIsActive: + self.pendingExtraInfo = index + # print("boom") else: - break - #print("unlock") - self.setExtraInfoIsActive = False + self.setExtraInfoIsActive = True + # print("lock") + # setExtraInfo can be called multiple times while this function is running, + # so just before existing we check for the latest pending call and execute it, + # if during that second execution some other calls are made the latest of those will + # be queued by the code a few lines above this one, and the loop will continue processing + # until an iteration during which no further call was made. + while True: + groupId = str(index.model().itemData(index.siblingAtColumn(2))[0]) + # TODO: move this outside of this class, probably use a single metadata + # This is a hack to allow some widgets to set the parent and recompute their size + # during their construction. + parentIsSet = False - @staticmethod - def clearExtraInfo(self): - # TODO: just clear the contents but keep the widget visible. - self.extraInfo.hide() + def setParent(toolTipWidget): + nonlocal parentIsSet + parentIsSet = True + w = self.extraInfo.layout().takeAt(0) + while w: + if hasattr(w.widget(), "finalizer"): + # The 3D viewer segfaults very easily if it is used after being destroyed, and some Python/C++ interop seems to overzealously destroys some widgets, including this one, too soon? + # Ensuring that we properly detacth the 3D viewer widget before discarding its parent seems to avoid these crashes. + # print('FINALIZER') + w.widget().finalizer() + if w.widget() is not None: + w.widget().hide() # hide before detaching, or we have widgets floating as their own window that appear for a split second in some cases. + w.widget().setParent(None) + w = self.extraInfo.layout().takeAt(0) + self.extraInfo.layout().addWidget(toolTipWidget) + self.setFloatingWidgetsGeometry() - @staticmethod - def showExtraInfo(self): - self.extraInfo.show() + toolTipWidget = self.getToolTip(groupId, setParent) + if isinstance(toolTipWidget, str): + toolTipWidget = easyToolTipWidget(toolTipWidget) + if not parentIsSet: + setParent(toolTipWidget) + if self.pendingExtraInfo is not None: + index = self.pendingExtraInfo + self.pendingExtraInfo = None + else: + break + # print("unlock") + self.setExtraInfoIsActive = False + + @staticmethod + def clearExtraInfo(self): + # TODO: just clear the contents but keep the widget visible. + self.extraInfo.hide() + + @staticmethod + def showExtraInfo(self): + self.extraInfo.show() diff --git a/SearchBoxLight.py b/SearchBoxLight.py index a11b160..2c17826 100644 --- a/SearchBoxLight.py +++ b/SearchBoxLight.py @@ -1,49 +1,97 @@ from PySide import QtGui from PySide import QtCore + # This is a "light" version of the SearchBox implementation, which loads the actual implementation on first click class SearchBoxLight(QtGui.QLineEdit): - resultSelected = QtCore.Signal(int, int) - def __init__(self, getItemGroups, getToolTip, getItemDelegate, maxVisibleRows = 20, parent = None): - self.isInitialized = False + resultSelected = QtCore.Signal(int, int) - # Store arguments - self.getItemGroups = getItemGroups - self.getToolTip = getToolTip - self.getItemDelegate = getItemDelegate - self.maxVisibleRows = maxVisibleRows + def __init__( + self, getItemGroups, getToolTip, getItemDelegate, maxVisibleRows=20, parent=None + ): + self.isInitialized = False - # Call parent constructor - super(SearchBoxLight, self).__init__(parent) - # Connect signals and slots - self.textChanged.connect(self.filterModel) - # Thanks to https://saurabhg.com/programming/search-box-using-qlineedit/ for indicating a few useful options - ico = QtGui.QIcon(':/icons/help-browser.svg') - #ico = QtGui.QIcon(':/icons/WhatsThis.svg') - self.addAction(ico, QtGui.QLineEdit.LeadingPosition) - self.setClearButtonEnabled(True) - self.setPlaceholderText('Search tools, prefs & tree') - self.setFixedWidth(200) # needed to avoid a change of width when the clear button appears/disappears - def lazyInit(self): - pass - def __getattr__(self, name): - import types - def f(*args, **kwargs): - import SearchBox - SearchBox.SearchBox.lazyInit(self) - return getattr(SearchBox.SearchBox, name)(*args, **kwargs) - return types.MethodType(f, self) - def focusInEvent(self, *args, **kwargs): return self.proxyFocusInEvent(*args, **kwargs) - def focusOutEvent(self, *args, **kwargs): return self.proxyFocusOutEvent(*args, **kwargs) - def keyPressEvent(self, *args, **kwargs): return self.proxyKeyPressEvent(*args, **kwargs) - def onSelectionChanged(self, *args, **kwargs): return self.proxyOnSelectionChanged(*args, **kwargs) - def filterModel(self, *args, **kwargs): return self.proxyFilterModel(*args, **kwargs) - def listDown(self, *args, **kwargs): return self.proxyListDown(*args, **kwargs) - def listUp(self, *args, **kwargs): return self.proxyListUp(*args, **kwargs) - def listPageDown(self, *args, **kwargs): return self.proxyListPageDown(*args, **kwargs) - def listPageUp(self, *args, **kwargs): return self.proxyListPageUp(*args, **kwargs) - def listEnd(self, *args, **kwargs): return self.proxyListEnd(*args, **kwargs) - def listStart(self, *args, **kwargs): return self.proxyListStart(*args, **kwargs) - def listAccept(self, *args, **kwargs): return self.proxyListAccept(*args, **kwargs) - def listAcceptToggle(self, *args, **kwargs): return self.proxyListAcceptToggle(*args, **kwargs) - def listCancel(self, *args, **kwargs): return self.proxyListCancel(*args, **kwargs) + # Store arguments + self.getItemGroups = getItemGroups + self.getToolTip = getToolTip + self.getItemDelegate = getItemDelegate + self.maxVisibleRows = maxVisibleRows + + # Call parent constructor + super(SearchBoxLight, self).__init__(parent) + # Connect signals and slots + self.textChanged.connect(self.filterModel) + # Thanks to https://saurabhg.com/programming/search-box-using-qlineedit/ for indicating a few useful options + ico = QtGui.QIcon(":/icons/help-browser.svg") + # ico = QtGui.QIcon(':/icons/WhatsThis.svg') + self.addAction(ico, QtGui.QLineEdit.LeadingPosition) + self.setClearButtonEnabled(True) + self.setPlaceholderText("Search tools, prefs & tree") + self.setFixedWidth( + 200 + ) # needed to avoid a change of width when the clear button appears/disappears + + def lazyInit(self): + pass + + def __getattr__(self, name): + import types + + def f(*args, **kwargs): + import SearchBox + + SearchBox.SearchBox.lazyInit(self) + return getattr(SearchBox.SearchBox, name)(*args, **kwargs) + + return types.MethodType(f, self) + + def MousePressEvent(self, *args, **kwargs): + return self.proxyMousePressEvent(*args, **kwargs) + + def MouseMoveEvent(self, *args, **kwargs): + return self.proxyMouseMoveEvent(*args, **kwargs) + + def LeaveEvent(self, *args, **kwargs): + return self.proxyLeaveEvent(*args, **kwargs) + + def focusInEvent(self, *args, **kwargs): + return self.proxyFocusInEvent(*args, **kwargs) + + def focusOutEvent(self, *args, **kwargs): + return self.proxyFocusOutEvent(*args, **kwargs) + + def keyPressEvent(self, *args, **kwargs): + return self.proxyKeyPressEvent(*args, **kwargs) + + def onSelectionChanged(self, *args, **kwargs): + return self.proxyOnSelectionChanged(*args, **kwargs) + + def filterModel(self, *args, **kwargs): + return self.proxyFilterModel(*args, **kwargs) + + def listDown(self, *args, **kwargs): + return self.proxyListDown(*args, **kwargs) + + def listUp(self, *args, **kwargs): + return self.proxyListUp(*args, **kwargs) + + def listPageDown(self, *args, **kwargs): + return self.proxyListPageDown(*args, **kwargs) + + def listPageUp(self, *args, **kwargs): + return self.proxyListPageUp(*args, **kwargs) + + def listEnd(self, *args, **kwargs): + return self.proxyListEnd(*args, **kwargs) + + def listStart(self, *args, **kwargs): + return self.proxyListStart(*args, **kwargs) + + def listAccept(self, *args, **kwargs): + return self.proxyListAccept(*args, **kwargs) + + def listAcceptToggle(self, *args, **kwargs): + return self.proxyListAcceptToggle(*args, **kwargs) + + def listCancel(self, *args, **kwargs): + return self.proxyListCancel(*args, **kwargs) diff --git a/SearchResults.py b/SearchResults.py index 58252fa..70e8ada 100644 --- a/SearchResults.py +++ b/SearchResults.py @@ -1,18 +1,20 @@ -actionHandlers = { } -toolTipHandlers = { } -resultProvidersCached = { } -resultProvidersUncached = { } +actionHandlers = {} +toolTipHandlers = {} +resultProvidersCached = {} +resultProvidersUncached = {} + # name : string # getItemGroupsCached: () -> [itemGroup] # getItemGroupsUncached: () -> [itemGroup] def registerResultProvider(name, getItemGroupsCached, getItemGroupsUncached): - resultProvidersCached[name] = getItemGroupsCached - resultProvidersUncached[name] = getItemGroupsUncached + resultProvidersCached[name] = getItemGroupsCached + resultProvidersUncached[name] = getItemGroupsUncached + # name : str # action : act -> None # toolTip : groupId, setParent -> (str or QWidget) def registerResultHandler(name, action, toolTip): - actionHandlers[name] = action - toolTipHandlers[name] = toolTip + actionHandlers[name] = action + toolTipHandlers[name] = toolTip diff --git a/Serialize.py b/Serialize.py deleted file mode 100644 index 3b891ba..0000000 --- a/Serialize.py +++ /dev/null @@ -1,87 +0,0 @@ -from PySide import QtCore -from PySide import QtGui -import json - -def iconToBase64(icon, sz = QtCore.QSize(64,64), mode = QtGui.QIcon.Mode.Normal, state = QtGui.QIcon.State.On): - buf = QtCore.QBuffer() - buf.open(QtCore.QIODevice.WriteOnly) - icon.pixmap(sz, mode, state).save(buf, 'PNG') - return QtCore.QTextCodec.codecForName('UTF-8').toUnicode(buf.data().toBase64()) - -def iconToHTML(icon, sz = 12, mode = QtGui.QIcon.Mode.Normal, state = QtGui.QIcon.State.On): - return '<img width="'+str(sz)+'" height="'+str(sz)+'" src="data:image/png;base64,' + iconToBase64(icon, QtCore.QSize(sz,sz), mode, state) + '" />' - -def serializeIcon(icon): - iconPixmaps = {} - for sz in icon.availableSizes(): - strW = str(sz.width()) - strH = str(sz.height()) - iconPixmaps[strW] = {} - iconPixmaps[strW][strH] = {} - for strMode, mode in {'normal':QtGui.QIcon.Mode.Normal, 'disabled':QtGui.QIcon.Mode.Disabled, 'active':QtGui.QIcon.Mode.Active, 'selected':QtGui.QIcon.Mode.Selected}.items(): - iconPixmaps[strW][strH][strMode] = {} - for strState, state in {'off':QtGui.QIcon.State.Off, 'on':QtGui.QIcon.State.On}.items(): - iconPixmaps[strW][strH][strMode][strState] = iconToBase64(icon, sz, mode, state) - return iconPixmaps - -# workbenches is a list(str), toolbar is a str, text is a str, icon is a QtGui.QIcon -def serializeTool(tool): - return { - 'workbenches': tool['workbenches'], - 'toolbar': tool['toolbar'], - 'text': tool['text'], - 'toolTip': tool['toolTip'], - 'icon': serializeIcon(tool['icon']), - } - -def deserializeIcon(iconPixmaps): - ico = QtGui.QIcon() - for strW, wPixmaps in iconPixmaps.items(): - for strH, hPixmaps in wPixmaps.items(): - for strMode, modePixmaps in hPixmaps.items(): - mode = {'normal':QtGui.QIcon.Mode.Normal, 'disabled':QtGui.QIcon.Mode.Disabled, 'active':QtGui.QIcon.Mode.Active, 'selected':QtGui.QIcon.Mode.Selected}[strMode] - for strState, statePixmap in modePixmaps.items(): - state = {'off':QtGui.QIcon.State.Off, 'on':QtGui.QIcon.State.On}[strState] - pxm = QtGui.QPixmap() - pxm.loadFromData(QtCore.QByteArray.fromBase64(QtCore.QTextCodec.codecForName('UTF-8').fromUnicode(statePixmap))) - ico.addPixmap(pxm, mode, state) - return ico - -def deserializeTool(tool): - return { - 'workbenches': tool['workbenches'], - 'toolbar': tool['toolbar'], - 'text': tool['text'], - 'toolTip': tool['toolTip'], - 'icon': deserializeIcon(tool['icon']), - } - -def serializeItemGroup(itemGroup): - return { - 'icon': serializeIcon(itemGroup['icon']), - 'text': itemGroup['text'], - 'toolTip': itemGroup['toolTip'], - 'action': itemGroup['action'], - 'subitems': serializeItemGroups(itemGroup['subitems']) - } - -def serializeItemGroups(itemGroups): - return [serializeItemGroup(itemGroup) for itemGroup in itemGroups] - -def serialize(itemGroups): - return json.dumps(serializeItemGroups(itemGroups)) - -def deserializeItemGroup(itemGroup): - return { - 'icon': deserializeIcon(itemGroup['icon']), - 'text': itemGroup['text'], - 'toolTip': itemGroup['toolTip'], - 'action': itemGroup['action'], - 'subitems': deserializeItemGroups(itemGroup['subitems']) - } - -def deserializeItemGroups(serializedItemGroups): - return [deserializeItemGroup(itemGroup) for itemGroup in serializedItemGroups] - -def deserialize(serializedItemGroups): - return deserializeItemGroups(json.loads(serializedItemGroups)) diff --git a/Serialize_SearchBar.py b/Serialize_SearchBar.py new file mode 100644 index 0000000..86a498f --- /dev/null +++ b/Serialize_SearchBar.py @@ -0,0 +1,156 @@ +from PySide import QtCore +from PySide import QtGui +import json + + +def iconToBase64( + icon: QtGui.QIcon, + sz=QtCore.QSize(64, 64), + mode=QtGui.QIcon.Mode.Normal, + state=QtGui.QIcon.State.On, +): + """ + Converts a QIcon to a Base64-encoded string representation of its pixmap. + + Args: + icon (QIcon): The icon to encode. + sz (QSize): The size of the pixmap to generate. + mode (QIcon.Mode): The mode of the pixmap (e.g., Normal, Disabled). + state (QIcon.State): The state of the pixmap (e.g., On, Off). + + Returns: + str: The Base64-encoded string of the icon's pixmap. + """ + buf = QtCore.QBuffer() + buf.open(QtCore.QIODevice.OpenModeFlag.WriteOnly) + + # Save the pixmap of the icon to the buffer in PNG format + pixmap: QtGui.QPixmap = icon.pixmap(sz, mode, state) + try: + pixmap.save(buf, "PNG") + except Exception as e: + # raise ValueError("Failed to save icon to buffer. Ensure the icon is valid.") + print(e) + + # Use standard Base64 encoding + base64_data = buf.data().toBase64().data().decode("utf-8") + buf.close() + return base64_data + + +def iconToHTML(icon, sz=12, mode=QtGui.QIcon.Mode.Normal, state=QtGui.QIcon.State.On): + return ( + '<img width="' + + str(sz) + + '" height="' + + str(sz) + + '" src="data:image/png;base64,' + + iconToBase64(icon, QtCore.QSize(sz, sz), mode, state) + + '" />' + ) + + +def serializeIcon(icon): + iconPixmaps = {} + for sz in icon.availableSizes(): + strW = str(sz.width()) + strH = str(sz.height()) + iconPixmaps[strW] = {} + iconPixmaps[strW][strH] = {} + for strMode, mode in { + "normal": QtGui.QIcon.Mode.Normal, + "disabled": QtGui.QIcon.Mode.Disabled, + "active": QtGui.QIcon.Mode.Active, + "selected": QtGui.QIcon.Mode.Selected, + }.items(): + iconPixmaps[strW][strH][strMode] = {} + for strState, state in { + "off": QtGui.QIcon.State.Off, + "on": QtGui.QIcon.State.On, + }.items(): + iconPixmaps[strW][strH][strMode][strState] = iconToBase64( + icon, sz, mode, state + ) + return iconPixmaps + + +# workbenches is a list(str), toolbar is a str, text is a str, icon is a QtGui.QIcon +def serializeTool(tool): + return { + "workbenches": tool["workbenches"], + "toolbar": tool["toolbar"], + "text": tool["text"], + "toolTip": tool["toolTip"], + "icon": serializeIcon(tool["icon"]), + } + + +def deserializeIcon(iconPixmaps): + ico = QtGui.QIcon() + for strW, wPixmaps in iconPixmaps.items(): + for strH, hPixmaps in wPixmaps.items(): + for strMode, modePixmaps in hPixmaps.items(): + mode = { + "normal": QtGui.QIcon.Mode.Normal, + "disabled": QtGui.QIcon.Mode.Disabled, + "active": QtGui.QIcon.Mode.Active, + "selected": QtGui.QIcon.Mode.Selected, + }[strMode] + for strState, statePixmap in modePixmaps.items(): + state = {"off": QtGui.QIcon.State.Off, "on": QtGui.QIcon.State.On}[ + strState + ] + pxm = QtGui.QPixmap() + pxm.loadFromData( + QtCore.QByteArray.fromBase64( + bytearray(statePixmap.encode("utf-8")) + ) + ) + ico.addPixmap(pxm, mode, state) + return ico + + +def deserializeTool(tool): + return { + "workbenches": tool["workbenches"], + "toolbar": tool["toolbar"], + "text": tool["text"], + "toolTip": tool["toolTip"], + "icon": deserializeIcon(tool["icon"]), + } + + +def serializeItemGroup(itemGroup): + return { + "icon": serializeIcon(itemGroup["icon"]), + "text": itemGroup["text"], + "toolTip": itemGroup["toolTip"], + "action": itemGroup["action"], + "subitems": serializeItemGroups(itemGroup["subitems"]), + } + + +def serializeItemGroups(itemGroups): + return [serializeItemGroup(itemGroup) for itemGroup in itemGroups] + + +def serialize(itemGroups): + return json.dumps(serializeItemGroups(itemGroups)) + + +def deserializeItemGroup(itemGroup): + return { + "icon": deserializeIcon(itemGroup["icon"]), + "text": itemGroup["text"], + "toolTip": itemGroup["toolTip"], + "action": itemGroup["action"], + "subitems": deserializeItemGroups(itemGroup["subitems"]), + } + + +def deserializeItemGroups(serializedItemGroups): + return [deserializeItemGroup(itemGroup) for itemGroup in serializedItemGroups] + + +def deserialize(serializedItemGroups): + return deserializeItemGroups(json.loads(serializedItemGroups)) diff --git a/StyleMapping_SearchBar.py b/StyleMapping_SearchBar.py new file mode 100644 index 0000000..4417b71 --- /dev/null +++ b/StyleMapping_SearchBar.py @@ -0,0 +1,239 @@ +# ************************************************************************* +# * * +# * Copyright (c) 2019-2024 Paul Ebbers * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 3 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# ************************************************************************* +import FreeCAD as App +import FreeCADGui as Gui +import os +from PySide.QtGui import QIcon, QPixmap, QAction +from PySide.QtWidgets import ( + QListWidgetItem, + QTableWidgetItem, + QListWidget, + QTableWidget, + QToolBar, + QToolButton, + QComboBox, + QPushButton, + QMenu, + QWidget, + QMainWindow, +) +from PySide.QtCore import Qt, SIGNAL, Signal, QObject, QThread +import sys +import json +from datetime import datetime +import shutil +import Standard_Functions_RIbbon as StandardFunctions +import Parameters_Ribbon +import webbrowser +import time + +# Get the resources +pathIcons = Parameters_Ribbon.ICON_LOCATION +pathStylSheets = Parameters_Ribbon.STYLESHEET_LOCATION +pathUI = Parameters_Ribbon.UI_LOCATION +pathBackup = Parameters_Ribbon.BACKUP_LOCATION +sys.path.append(pathIcons) +sys.path.append(pathStylSheets) +sys.path.append(pathUI) +sys.path.append(pathBackup) + + +def ReturnStyleItem(ControlName, ShowCustomIcon=False, IgnoreOverlay=False): + """ + Enter one of the names below: + + ControlName (string): + "Background_Color" returns string, + "Border_Color" returns string, + "FontColor" returns string, + "FontColor" returns string, + """ + # define a result holder and a dict for the StyleMapping file + result = "none" + + # Get the current stylesheet for FreeCAD + FreeCAD_preferences = App.ParamGet("User parameter:BaseApp/Preferences/MainWindow") + currentStyleSheet = FreeCAD_preferences.GetString("StyleSheet") + IsInList = False + for key, value in StyleMapping_default["Stylesheets"].items(): + if key == currentStyleSheet: + IsInList = True + break + if IsInList is False: + currentStyleSheet = "none" + + try: + result = StyleMapping_default["Stylesheets"][currentStyleSheet][ControlName] + if result == "" or result is None: + result = StyleMapping_default["Stylesheets"][""][ControlName] + return result + except Exception as e: + print(e) + return None + + +def ReturnColor(ColorType="Background_Color"): + mw: QMainWindow = Gui.getMainWindow() + palette = mw.style().standardPalette() + # Get the color + Color = palette.base().color().toTuple() # RGBA tupple + if ColorType == "Border_Color": + Color = palette.buttonText().color().toTuple() + if ColorType == "Background_Color_Hover": + Color = palette.highlight().color().toTuple() + + HexColor = StandardFunctions.ColorConvertor(Color, Color[3] / 255, True, False) + + return HexColor + + +def ReturnFontColor(): + fontColor = "#000000" + IsDarkTheme = DarkMode() + + if IsDarkTheme is True: + fontColor = "#ffffff" + + return fontColor + + +def DarkMode(): + import xml.etree.ElementTree as ET + import os + + # Define the standard result + IsDarkTheme = False + + # Get the current stylesheet for FreeCAD + FreeCAD_preferences = App.ParamGet("User parameter:BaseApp/Preferences/MainWindow") + currentStyleSheet = FreeCAD_preferences.GetString("StyleSheet") + + path = os.path.dirname(__file__) + # Get the folder with add-ons + for i in range(2): + # Starting point + path = os.path.dirname(path) + + # Go through the sub-folders + for root, dirs, files in os.walk(path): + for name in dirs: + # if the current stylesheet matches a sub directory, try to geth the pacakgexml + if currentStyleSheet.replace(".qss", "").lower() in name.lower(): + try: + packageXML = os.path.join(path, name, "package.xml") + + # Get the tree and root of the xml file + tree = ET.parse(packageXML) + treeRoot = tree.getroot() + + # Get all the tag elements + elements = [] + namespaces = {"i": "https://wiki.freecad.org/Package_Metadata"} + elements = treeRoot.findall(".//i:content/i:preferencepack/i:tag", namespaces) + + # go throug all tags. If 'dark' in the element text, this is a dark theme + for element in elements: + if "dark" in element.text.lower(): + IsDarkTheme = True + break + except Exception: + continue + + return IsDarkTheme + + +StyleMapping_default = { + "Stylesheets": { + "": { + "Background_Color": "#f0f0f0", + "Background_Color_Hover": "#ced4da", + "Border_Color": "#646464", + "FontColor": ReturnFontColor(), + }, + "none": { + "Background_Color": "none", + "Background_Color_Hover": "#48a0f8", + "Border_Color": ReturnColor("Border_Color"), + "FontColor": ReturnFontColor(), + }, + "FreeCAD Dark.qss": { + "Background_Color": "#333333", + "Background_Color_Hover": "#48a0f8", + "Border_Color": "#ffffff", + "FontColor": "#ffffff", + }, + "FreeCAD Light.qss": { + "Background_Color": "#f0f0f0", + "Background_Color_Hover": "#48a0f8", + "Border_Color": "#646464", + "FontColor": "#000000", + }, + "OpenLight.qss": { + "Background_Color": "#dee2e6", + "Background_Color_Hover": "#a5d8ff", + "Border_Color": "#1c7ed6", + "FontColor": "#000000", + }, + "OpenDark.qss": { + "Background_Color": "#212529", + "Background_Color_Hover": "#1f364d", + "Border_Color": "#264b69", + "FontColor": "#ffffff", + }, + "Behave-dark.qss": { + "Background_Color": "#232932", + "Background_Color_Hover": "#557bb6", + "Border_Color": "#3a7400", + "FontColor": ReturnFontColor(), + }, + "ProDark.qss": { + "Background_Color": "#333333", + "Background_Color_Hover": "#557bb6", + "Border_Color": "#adc5ed", + "FontColor": ReturnFontColor(), + }, + "Darker.qss": { + "Background_Color": "#444444", + "Background_Color_Hover": "#4aa5ff", + "Border_Color": "#696968", + "FontColor": ReturnFontColor(), + }, + "Light-modern.qss": { + "Background_Color": "#f0f0f0", + "Background_Color_Hover": "#4aa5ff", + "Border_Color": "#646464", + "FontColor": ReturnFontColor(), + }, + "Dark-modern.qss": { + "Background_Color": "#2b2b2b", + "Background_Color_Hover": "#4aa5ff", + "Border_Color": "#ffffff", + "FontColor": ReturnFontColor(), + }, + "Dark-contrast.qss": { + "Background_Color": "#444444", + "Background_Color_Hover": "#4aa5ff", + "Border_Color": "#787878", + "FontColor": ReturnFontColor(), + }, + } +} diff --git a/package.xml b/package.xml index 7511316..f9d0d5e 100644 --- a/package.xml +++ b/package.xml @@ -1,28 +1,34 @@ <?xml version="1.0" encoding="UTF-8" standalone="no" ?> <package format="1" xmlns="https://wiki.freecad.org/Package_Metadata"> + <name>SearchBar</name> + <description>Adds a search bar widget for tools, document objects, and preferences</description> - <version>1.0.2</version> + + <version>1.4.1</version> + <date>2022-06-01</date> - <author email="searchbar@suzanne.soy">Suzanne Soy</author> - <maintainer email="searchbar@suzanne.soy">Suzanne Soy</maintainer> + + <maintainer>Paul Ebbers</maintainer> + <license file="LICENSE">CCOv1</license> - <url type="repository" branch="main">https://github.com/SuzanneSoy/SearchBar</url> - <url type="bugtracker">https://github.com/SuzanneSoy/SearchBar/issues</url> - <url type="documentation">https://github.com/SuzanneSoy/SearchBar</url> - <icon>Tango-System-search.svg</icon> + + <url type="repository" branch="main">https://github.com/APEbbers/SearchBar</url> + + <url type="bugtracker">https://github.com/APEbbers/SearchBar/issues</url> + + <url type="documentation">https://github.com/APEbbers/SearchBar</url> + <depend type="python">lxml</depend> <content> <workbench> <name>SearchBar</name> - <description>Adds a search bar widget for tools, document objects, and preferences</description> - <classname></classname> <icon>Tango-System-search.svg</icon> <subdirectory>./</subdirectory> <tag>search</tag> <tag>widget</tag> <tag>ui/ux</tag> - <version>0.1.0</version> </workbench> </content> + </package> diff --git a/translations/README.md b/translations/README.md new file mode 100644 index 0000000..ae84e37 --- /dev/null +++ b/translations/README.md @@ -0,0 +1,104 @@ +# About translating Ribbon Addon + +> [!NOTE] +> All commands **must** be run in `./translations/` directory. + +> [!IMPORTANT] +> If you want to update/release the files you need to have installed +> `lupdate` and `lrelease` from Qt6 version. Using the versions from +> Qt5 is not advised because they're buggy. + +## Updating translations template file + +To update the template file from source files you should use this command: + +```shell +./update_translation.sh -U +``` + +Once done you can commit the changes and upload the new file to CrowdIn platform +at <https://crowdin.com/project/freecad-addons> webpage and find the **Ribbon** project. + +## Creating file for missing locale + +### Using script + +To create a file for a new language with all **Ribbon** translatable strings execute +the script with `-u` flag plus your locale: + +```shell +./update_translation.sh -u de +``` + +### Renaming file + +Also you can rename new `SearchBar.ts` file by appending the locale code, +for example, `SearchBar_de.ts` for German and change + +```xml +<TS version="2.1"> +``` + +to + +```xml +<TS version="2.1" language="de" sourcelanguage="en"> +``` + +As of 2024/10/28 the supported locales on FreeCAD +(according to `FreeCADGui.supportedLocales()`) are 44: + +```python +{'English': 'en', 'Afrikaans': 'af', 'Arabic': 'ar', 'Basque': 'eu', +'Belarusian': 'be', 'Bulgarian': 'bg', 'Catalan': 'ca', +'Chinese Simplified': 'zh-CN', 'Chinese Traditional': 'zh-TW', 'Croatian': 'hr', +'Czech': 'cs', 'Danish': 'da', 'Dutch': 'nl', 'Filipino': 'fil', 'Finnish': 'fi', + 'French': 'fr', 'Galician': 'gl', 'Georgian': 'ka', 'German': 'de', 'Greek': 'el', + 'Hungarian': 'hu', 'Indonesian': 'id', 'Italian': 'it', 'Japanese': 'ja', + 'Kabyle': 'kab', 'Korean': 'ko', 'Lithuanian': 'lt', 'Norwegian': 'no', + 'Polish': 'pl', 'Portuguese': 'pt-PT', 'Portuguese, Brazilian': 'pt-BR', + 'Romanian': 'ro', 'Russian': 'ru', 'Serbian': 'sr', 'Serbian, Latin': 'sr-CS', + 'Slovak': 'sk', 'Slovenian': 'sl', 'Spanish': 'es-ES', 'Spanish, Argentina': 'es-AR', +'Swedish': 'sv-SE', 'Turkish': 'tr', 'Ukrainian': 'uk', 'Valencian': 'val-ES', +'Vietnamese': 'vi'} +``` + +## Translating + +To edit your language file open your file in `Qt Linguist` from `qt5-tools`/`qt6-tools` +package or in a text editor like `xed`, `mousepad`, `gedit`, `nano`, `vim`/`nvim`, +`geany` etc. and translate it. + +Alternatively you can visit the **FreeCAD-addons** project on CrowdIn platform +at <https://crowdin.com/project/freecad-addons> webpage and find your language, +once done, look for the **Ribbon** project. + +## Compiling translations + +To convert all `.ts` files to `.qm` files (merge) you can use this command: + +```shell +./update_translation.sh -R +``` + +If you are a translator that wants to update only their language file +to test it on **FreeCAD** before doing a PR you can use this command: + +```shell +./update_translation.sh -r de +``` + +This will update the `.qm` file for your language (German in this case). + +## Sending translations + +Now you can contribute your translated `.ts` file to **Ribbon** repository, +also include the `.qm` file. + +<https://github.com/APEbbers/FreeCAD-Ribbon> + +## More information + +You can read more about translating external workbenches here: + +<https://wiki.freecad.org/Translating_an_external_workbench> diff --git a/translations/update_translation.sh b/translations/update_translation.sh new file mode 100644 index 0000000..77e7d36 --- /dev/null +++ b/translations/update_translation.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash + +# -------------------------------------------------------------------------------------------------- +# +# Create, update and release translation files. +# +# Supported locales on FreeCAD <2024-10-14, FreeCADGui.supportedLocales(), total=44>: +# {'English': 'en', 'Afrikaans': 'af', 'Arabic': 'ar', 'Basque': 'eu', 'Belarusian': 'be', +# 'Bulgarian': 'bg', 'Catalan': 'ca', 'Chinese Simplified': 'zh-CN', +# 'Chinese Traditional': 'zh-TW', 'Croatian': 'hr', 'Czech': 'cs', 'Danish': 'da', +# 'Dutch': 'nl', 'Filipino': 'fil', 'Finnish': 'fi', 'French': 'fr', 'Galician': 'gl', +# 'Georgian': 'ka', 'German': 'de', 'Greek': 'el', 'Hungarian': 'hu', 'Indonesian': 'id', +# 'Italian': 'it', 'Japanese': 'ja', 'Kabyle': 'kab', 'Korean': 'ko', 'Lithuanian': 'lt', +# 'Norwegian': 'no', 'Polish': 'pl', 'Portuguese': 'pt-PT', 'Portuguese, Brazilian': 'pt-BR', +# 'Romanian': 'ro', 'Russian': 'ru', 'Serbian': 'sr', 'Serbian, Latin': 'sr-CS', 'Slovak': 'sk', +# 'Slovenian': 'sl', 'Spanish': 'es-ES', 'Spanish, Argentina': 'es-AR', 'Swedish': 'sv-SE', +# 'Turkish': 'tr', 'Ukrainian': 'uk', 'Valencian': 'val-ES', 'Vietnamese': 'vi'} +# +# NOTE: PREPARATION +# - Install Qt tools +# Debian-based (e.g., Ubuntu): $ sudo apt-get install qttools5-dev-tools pyqt6-dev-tools +# Fedora-based: $ sudo dnf install qt6-linguist qt6-devel +# Arch-based: $ sudo pacman -S qt6-tools python-pyqt6 +# - Make the script executable +# $ chmod +x update_translation.sh +# - The script has to be executed within the `translations` directory. +# Executing the script with no flags invokes the help. +# $ ./update_translation.sh +# +# NOTE: WORKFLOW TRANSLATOR (LOCAL) +# - Execute the script passing the `-u` flag plus locale code as argument +# Only update the file(s) you're translating! +# $ ./update_translation.sh -u es-ES +# - Do the translation via Qt Linguist and use `File>Release` +# - If releasing with the script execute it passing the `-r` flag +# plus locale code as argument +# $ ./update_translation.sh -r es-ES +# +# NOTE: WORKFLOW MAINTAINER (CROWDIN) +# - Execute the script passing the '-U' flag +# $ ./update_translation.sh -U +# - Once done, download the translated files, copy them to `translations` +# - Upload the updated file to CrowdIn and wait for translators do their thing ;-) +# and release all the files to update the changes +# $ ./update_translation.sh -R +# +# -------------------------------------------------------------------------------------------------- + +supported_locales=( + "en" "af" "ar" "eu" "be" "bg" "ca" "zh-CN" "zh-TW" "hr" + "cs" "da" "nl" "fil" "fi" "fr" "gl" "ka" "de" "el" + "hu" "id" "it" "ja" "kab" "ko" "lt" "no" "pl" "pt-PT" + "pt-BR" "ro" "ru" "sr" "sr-CS" "sk" "sl" "es-ES" "es-AR" "sv-SE" + "tr" "uk" "val-ES" "vi" +) + +is_locale_supported() { + local locale="$1" + for supported_locale in "${supported_locales[@]}"; do + [ "$supported_locale" == "$locale" ] && return 0 + done + return 1 +} + +update_locale() { + local locale="$1" + local u=${locale:+_} # Conditional underscore + FILES="../*.py ../Resources/ui/*.ui" + + # NOTE: Execute the right commands depending on: + # - if it's a locale file or the main, agnostic one + [ ! -f "${WB}${u}${locale}.ts" ] && action="Creating" || action="Updating" + echo -e "\033[1;34m\n\t<<< ${action} '${WB}${u}${locale}.ts' file >>>\n\033[m" + if [ "$u" == "" ]; then + eval $LUPDATE "$FILES" -ts "${WB}.ts" # locale-agnostic file + else + eval $LUPDATE "$FILES" -source-language en_US -target-language "${locale//-/_}" \ + -ts "${WB}_${locale}.ts" + fi +} + +normalize_crowdin_files() { + # Rename files which locales are different on FreeCAD and delete not supported locales + crowdin_fixes=(af-ZA ar-SA be-BY bg-BG ca-ES cs-CZ da-DK de-DE el-GR eu-ES fi-FI + fil-PH fr-FR gl-ES hr-HR hu-HU it-IT ja-JP ka-GE kab-KAB ko-KR lt-LT nl-NL + no-NO pl-PL ro-RO ru-RU sk-SK sl-SI sr-SP tr-TR uk-UA vi-VN) + + crowdin_deletes=(az-AZ bn-BD br-FR bs-BA en en-GB en-US eo-UY es-CO es-VE et-EE fa-IR he-IL + hi-IN hy-AM id-ID kaa lv-LV mk-MK ms-MY sat-IN si-LK ta-IN te-IN th-TH ur-PK xav yo-NG) + + for pattern in "${crowdin_fixes[@]}"; do + find . -type f -name "*_${pattern}\.*" | while read -r file; do + mv -v "$file" "${file//-*./.}" + done + done + + for pattern in "${crowdin_deletes[@]}"; do + find . -type f -name "*_${pattern}\.*" -delete + done +} + +help() { + echo -e "\nDescription:" + echo -e "\tCreate, update and release translation files." + echo -e "\nUsage:" + echo -e "\t./update_translation.sh [-R] [-U] [-r <locale>] [-u <locale>]" + echo -e "\nFlags:" + echo -e " -R\n\tRelease all translations (qm files)" + echo -e " -U\n\tUpdate all translations (ts files)" + echo -e " -r <locale>\n\tRelease the specified locale" + echo -e " -u <locale>\n\tUpdate strings for the specified locale" + echo -e " -N\n\tNormalize CrowdIn filenames" +} + +# Main function ------------------------------------------------------------------------------------ + +# LUPDATE="C:/Program Files/FreeCAD 1.0/bin/Lib/site-packages/PySide6/lupdate" # from Qt6 +LUPDATE=/usr/lib/qt6/bin/lupdate # from Qt6 +# LUPDATE=lupdate # from Qt5 +# LRELEASE="C:/Program Files/FreeCAD 1.0/bin/Lib/site-packages/PySide6/lrelease" # from Qt6 +LRELEASE=/usr/lib/qt6/bin/lrelease # from Qt6 +# LRELEASE=lrelease # from Qt5 +WB="SearchBar" + +sed -i '3s/-/_/' ${WB}*.ts # Enforce underscore on locales +sed -i '3s/\"en\"/\"en_US\"/g' ${WB}*.ts # Use en_US + +if [ $# -eq 1 ]; then + if [ "$1" == "-R" ]; then + find . -type f -name '*_*.ts' | while IFS= read -r file; do + # Release all locales + $LRELEASE -nounfinished "$file" + echo + done + elif [ "$1" == "-U" ]; then + for locale in "${supported_locales[@]}"; do + update_locale "$locale" + done + elif [ "$1" == "-u" ]; then + update_locale # Update main file (agnostic) + elif [ "$1" == "-N" ]; then + normalize_crowdin_files + else + help + fi +elif [ $# -eq 2 ]; then + LOCALE="$2" + if is_locale_supported "$LOCALE"; then + if [ "$1" == "-r" ]; then + # Release locale (creation of *.qm file from *.ts file) + $LRELEASE -nounfinished "${WB}_${LOCALE}.ts" + elif [ "$1" == "-u" ]; then + # Update main & locale files + update_locale + update_locale "$LOCALE" + fi + else + echo "Verify your language code. Case sensitive." + echo "If it's correct, ask a maintainer to add support for your language on FreeCAD." + echo -e "\nSupported locales, '\033[1;34mFreeCADGui.supportedLocales()\033[m': \033[1;33m" + for locale in $(printf "%s\n" "${supported_locales[@]}" | sort); do + echo -n "$locale " + done + echo + fi +else + help +fi