diff --git a/GetItemGroups.py b/GetItemGroups.py index 42b1bd3..56262b4 100644 --- a/GetItemGroups.py +++ b/GetItemGroups.py @@ -3,55 +3,70 @@ globalGroups = [] itemGroups = None serializedItemGroups = None + 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 PySide6 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.", + ) + 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 "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 + + itemGroups = Serialize.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..76d2d63 100644 --- a/IndentedItemDelegate.py +++ b/IndentedItemDelegate.py @@ -1,12 +1,14 @@ -from PySide import QtGui +from PySide6 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..bd0a373 100644 --- a/InitGui.py +++ b/InitGui.py @@ -3,35 +3,43 @@ wax = None sea = None tbr = None + 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)) + import FreeCADGui + from PySide6 import QtGui + import SearchBoxLight - if wax is None: - wax = QtGui.QWidgetAction(None) - wax.setWhatsThis('Use this search bar to find tools, document objects, preferences and more') + 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) + ) + + if wax is None: + wax = QtGui.QWidgetAction(None) + wax.setWhatsThis("Use this search bar to find tools, document objects, preferences and more") + + 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() - 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) diff --git a/RefreshTools.py b/RefreshTools.py index 1acdad8..ad7c6f9 100644 --- a/RefreshTools.py +++ b/RefreshTools.py @@ -1,81 +1,99 @@ import os import FreeCAD as App + 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) + from PySide6 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) + 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 + + 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 + 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 + + itemGroups = Serialize.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 PySide6 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") diff --git a/ResultsDocument.py b/ResultsDocument.py index e726594..44435b1 100644 --- a/ResultsDocument.py +++ b/ResultsDocument.py @@ -1,116 +1,143 @@ -from PySide import QtGui -from PySide import QtCore +from PySide6 import QtGui +from PySide6 import QtCore import FreeCAD as App import FreeCADGui import SafeViewer import SearchBox + 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 = '
' + nfo['toolTip']['label'] + '
App.getDocument(' + repr(str(nfo['toolTip']['docName'])) + ').getObject(' + repr(str(nfo['toolTip']['name'])) + ')
" + + nfo["toolTip"]["label"] + + "
App.getDocument("
+ + repr(str(nfo["toolTip"]["docName"]))
+ + ").getObject("
+ + repr(str(nfo["toolTip"]["name"]))
+ + ")
' + nfo['toolTip']['label'] + '
App.getDocument(' + repr(str(nfo['toolTip']['name'])) + ')
" + + nfo["toolTip"]["label"] + + "
App.getDocument("
+ + repr(str(nfo["toolTip"]["name"]))
+ + ')
App.ParamGet(' + repr(path) + ')
App.ParamGet(" + repr(path) + ")
App.ParamGet(' + repr(path) + ').' + getters[type_] + '(' + repr(name) + ')
Type: ' + type_ + '
Value: ' + repr(value) + '
' + 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 ( + "App.ParamGet("
+ + repr(path)
+ + ")."
+ + getters[type_]
+ + "("
+ + repr(name)
+ + ")
Type: " + + type_ + + "
Value: " + + repr(value) + + "
" + ) + def paramResultsProvider(): - return getAllParams() + return getAllParams() diff --git a/ResultsRefreshTools.py b/ResultsRefreshTools.py index 74859a9..07f7915 100644 --- a/ResultsRefreshTools.py +++ b/ResultsRefreshTools.py @@ -1,22 +1,31 @@ import os -from PySide import QtGui +from PySide6 import QtGui import Serialize + def refreshToolsAction(nfo): - import RefreshTools - RefreshTools.refreshToolsAction() + import RefreshTools + + RefreshTools.refreshToolsAction() + def refreshToolsToolTip(nfo, setParent): - return Serialize.iconToHTML(genericToolIcon) + 'Load all workbenches to refresh this list of tools. This may take a minute, depending on the number of installed workbenches.
' + return ( + Serialize.iconToHTML(genericToolIcon) + + "Load all workbenches to refresh this list of tools. This may take a minute, depending on the number of installed workbenches.
" + ) + + +genericToolIcon = QtGui.QIcon(QtGui.QIcon(os.path.dirname(__file__) + "/Tango-Tools-spanner-hammer.svg")) + -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": "Refresh list of tools", + "toolTip": "", + "action": {"handler": "refreshTools"}, + "subitems": [], + } + ] diff --git a/ResultsToolbar.py b/ResultsToolbar.py index d0ab287..be61be6 100644 --- a/ResultsToolbar.py +++ b/ResultsToolbar.py @@ -1,105 +1,153 @@ -from PySide import QtGui +from PySide6 import QtGui import FreeCADGui import Serialize + 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) + FreeCADGui.activateWorkbench(workbench) + 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 = ['Show the ' + nfo['text'] + ' toolbar
This toolbar appears in the following workbenches:
Show the " + + nfo["text"] + + " toolbar
This toolbar appears in the following workbenches:
' + nfo['toolTip'] + '
' + return Serialize.iconToHTML(nfo["icon"], 32) + "" + nfo["toolTip"] + "
" + 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..37973ef 100644 --- a/SafeViewer.py +++ b/SafeViewer.py @@ -1,110 +1,120 @@ -from PySide import QtGui +from PySide6 import QtGui import FreeCAD + 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 = FreeCAD.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 PySide6 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( + "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) - 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 + FreeCAD.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: -from PySide import QtGui +from PySide6 import QtGui import pivy from SafeViewer import SafeViewer sv = SafeViewer() diff --git a/SearchBox.py b/SearchBox.py index fd3cf77..4ffe46c 100644 --- a/SearchBox.py +++ b/SearchBox.py @@ -1,35 +1,38 @@ import os -from PySide import QtGui -from PySide import QtCore -import FreeCADGui # just used for FreeCADGui.updateGui() +from PySide6 import QtGui +from PySide6 import QtCore +import FreeCADGui # just used for FreeCADGui.updateGui() from SearchBoxLight import SearchBoxLight globalIgnoreFocusOut = False -genericToolIcon = QtGui.QIcon(QtGui.QIcon(os.path.dirname(__file__) + '/Tango-Tools-spanner-hammer.svg')) +genericToolIcon = QtGui.QIcon(QtGui.QIcon(os.path.dirname(__file__) + "/Tango-Tools-spanner-hammer.svg")) + def easyToolTipWidget(html): - foo = QtGui.QTextEdit() - foo.setReadOnly(True) - foo.setAlignment(QtCore.Qt.AlignTop) - foo.setText(html) - return foo + foo = QtGui.QTextEdit() + foo.setReadOnly(True) + foo.setAlignment(QtCore.Qt.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 - ''' + # 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 @@ -41,332 +44,382 @@ class SearchBox(QtGui.QLineEdit): 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 = 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) - # Note: should probably use the eventFilter method instead... - wdgctx = QtCore.Qt.ShortcutContext.WidgetShortcut + # Note: should probably use the eventFilter method instead... + wdgctx = QtCore.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) + 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 + ) - # 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. + # 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) - # 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 + 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) - @staticmethod - def refreshItemGroups(self): - self.itemGroups = self.getItemGroups() - self.proxyFilterModel(self.text()) + QtGui.QShortcut(QtGui.QKeySequence(QtCore.Qt.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 refreshItemGroups(self): + self.itemGroups = self.getItemGroups() + self.proxyFilterModel(self.text()) - @staticmethod - def movementKey(self, rowUpdate): - currentIndex = self.listView.currentIndex() - self.showList() - if self.listView.isEnabled(): - currentRow = currentIndex.row() + @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) + + @staticmethod + def proxyFocusOutEvent(self, qFocusEvent): + global globalIgnoreFocusOut + if not globalIgnoreFocusOut: + self.hideList() + super(SearchBoxLight, self).focusOutEvent(qFocusEvent) + + @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 == 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, + } + 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: - 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(0, 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 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 - 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(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 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..ed129cb 100644 --- a/SearchBoxLight.py +++ b/SearchBoxLight.py @@ -1,49 +1,84 @@ -from PySide import QtGui -from PySide import QtCore +from PySide6 import QtGui +from PySide6 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 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/Serialize.py b/Serialize.py index 3b891ba..a163e30 100644 --- a/Serialize.py +++ b/Serialize.py @@ -1,87 +1,130 @@ -from PySide import QtCore -from PySide import QtGui +from PySide6 import QtCore +from PySide6 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 '