From ee215cbb2ce0c317c984fa0e1d169d85de0c18d1 Mon Sep 17 00:00:00 2001 From: Eric Mehl Date: Mon, 12 Aug 2024 10:40:32 -0400 Subject: [PATCH 1/7] WidgetEditor : Add editor for UI widgets --- python/GafferUI/PythonEditor.py | 2 + python/GafferUI/WidgetEditor.py | 207 ++++++++++++++++++++++++++++++++ python/GafferUI/__init__.py | 3 + startup/gui/layouts.py | 1 + 4 files changed, 213 insertions(+) create mode 100644 python/GafferUI/WidgetEditor.py diff --git a/python/GafferUI/PythonEditor.py b/python/GafferUI/PythonEditor.py index ff410841f4c..172cf806f53 100644 --- a/python/GafferUI/PythonEditor.py +++ b/python/GafferUI/PythonEditor.py @@ -173,6 +173,8 @@ def __dropText( self, widget, dragData ) : return repr( dragData ) elif isinstance( dragData, IECore.Data ) and hasattr( dragData, "value" ) : return repr( dragData.value ) + elif isinstance( dragData, GafferUI.WidgetPath ) : + return repr( dragData ) return None diff --git a/python/GafferUI/WidgetEditor.py b/python/GafferUI/WidgetEditor.py new file mode 100644 index 00000000000..23e0ab93929 --- /dev/null +++ b/python/GafferUI/WidgetEditor.py @@ -0,0 +1,207 @@ +########################################################################## +# +# Copyright (c) 2024, Cinesite VFX Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above +# copyright notice, this list of conditions and the following +# disclaimer. +# +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following +# disclaimer in the documentation and/or other materials provided with +# the distribution. +# +# * Neither the name of John Haddon nor the names of +# any other contributors to this software may be used to endorse or +# promote products derived from this software without specific prior +# written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +########################################################################## + +import imath + +import IECore + +import Gaffer +import GafferUI + + +class WidgetPath( Gaffer.Path ) : + # A `Gaffer.Path` to a `GafferUI.Widget` rooted at `rootWidget`. Path + # entries are string representations of the integer index into the parent + # widget's children for the widget, or the name of the parent's member variable + # for the widget. + + def __init__( self, scriptNode, path = None, root = "/", filter = None ) : + + Gaffer.Path.__init__( self, path = path, root = root, filter = filter ) + + self.__scriptNode = scriptNode + + def copy( self ) : + + return self.__class__( self.__scriptNode, self[:], self.root(), self.getFilter() ) + + def isValid( self, canceller = None ) : + + return self.widget() is not None + + def isLeaf( self, canceller = None ) : + + return self.isValid() and len( self.__childWidgets( self.widget() ) ) == 0 + + def propertyNames( self ) : + + return Gaffer.Path.propertyNames() + [ + "widgetEditor:name", + "widgetEditor:widget" + ] + + def property( self, name, canceller = None ) : + + result = Gaffer.Path.property( self, name ) + + if result is not None : + return result + + widget = self.widget() + if widget is None : + return None + + if name == "widgetEditor:name" : + return self[-1] + elif name == "widgetEditor:widget" : + return widget + + def widget( self ) : + # Returns the `GafferUI.Widget` for this path. + + if self.__scriptNode is None : + return None + + widget = GafferUI.ScriptWindow.acquire( self.__scriptNode ) + assert( widget is not None ) + # A path with a single element is the top level `ScriptWindow`, start looking below that. + for i in self[1:] : + childWidgets = self.__childWidgets( widget ) + if i.isnumeric(): + widget = widget[ int( i ) ] + else : + widget = childWidgets[i] + + return widget + + def scriptNode( self ) : + + return self.__scriptNode + + def _children( self, canceller ) : + + if not self.isValid() or self.isLeaf() : + return [] + + if len( self ) == 0 : + return [ WidgetPath( self.__scriptNode, self[:] + ["scriptWindow"], self.root(), self.getFilter() ) ] + + childWidgets = self.__childWidgets( self.widget() ) + return [ + WidgetPath( self.__scriptNode, self[:] + [ k ], self.root(), self.getFilter() ) + for k in childWidgets.keys() + ] + + def __repr__( self ) : + + result = "GafferUI.ScriptWindow.acquire(root)" + for p in self[1:] : + if p.isnumeric() : + result += f"[{p}]" + else : + result += "." + p + + return result + + def __isAggregate( self, widget ) : + + return hasattr( widget, "__getitem__" ) and hasattr( widget, "__len__" ) + + def __childWidgets( self, widget ) : + + result = {} + + visited = set() + + if self.__isAggregate( widget ) : + for i in range( 0, len( widget ) ) : + if isinstance( widget[i], GafferUI.Widget ) and widget[i] not in visited : + result[str( i )] = widget[i] + visited.add( widget[i] ) + + for a in dir( widget ) : + if isinstance( getattr( widget, a ), GafferUI.Widget ) and getattr( widget, a ) not in visited : + result[a] = getattr( widget, a ) + visited.add( getattr( widget, a ) ) + + return result + +class WidgetEditor( GafferUI.Editor ) : + + def __init__( self, scriptNode, **kw ) : + + column = GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Vertical, borderWidth = 4, spacing = 4 ) + GafferUI.Editor.__init__( self, column, scriptNode, **kw ) + + self.__scriptNode = scriptNode + + with column : + + self.__widgetNameColumn = GafferUI.PathListingWidget.StandardColumn( "Name", "widgetEditor:name", sizeMode = GafferUI.PathColumn.SizeMode.Stretch ) + + self.__widgetListingWidget = GafferUI.PathListingWidget( + WidgetPath( None ), # temp until we make a WidgetPath + columns = ( + self.__widgetNameColumn, + ), + selectionMode = GafferUI.PathListingWidget.SelectionMode.Row, + displayMode = GafferUI.PathListingWidget.DisplayMode.Tree + ) + + self.__widgetListingWidget.dragBeginSignal().connectFront( Gaffer.WeakMethod( self.__dragBegin ) ) + + self.visibilityChangedSignal().connect( Gaffer.WeakMethod( self.__visibilityChanged ) ) + + def __repr__( self ) : + + return "GafferUI.WidgetEditor( scriptNode )" + + def __dragBegin( self, widget, event ) : + + path = self.__widgetListingWidget.pathAt( imath.V2f( event.line.p0.x, event.line.p0.y ) ) + + column = self.__widgetListingWidget.columnAt( imath.V2f( event.line.p0.x, event.line.p0.y ) ) + + if column == self.__widgetNameColumn : + GafferUI.Pointer.setCurrent( "nodes" ) + return path + + def __visibilityChanged( self, widget ) : + + if widget.visible() and self.__widgetListingWidget.getPath().scriptNode() is None : + self.__widgetListingWidget.setPath( WidgetPath( self.__scriptNode ) ) + +GafferUI.Editor.registerType( "WidgetEditor", WidgetEditor ) \ No newline at end of file diff --git a/python/GafferUI/__init__.py b/python/GafferUI/__init__.py index c2bd177c343..e979db3cb54 100644 --- a/python/GafferUI/__init__.py +++ b/python/GafferUI/__init__.py @@ -275,6 +275,9 @@ def __shiboken() : from .TweakPlugValueWidget import TweakPlugValueWidget from .PlugPopup import PlugPopup from .OptionalValuePlugValueWidget import OptionalValuePlugValueWidget +from .WidgetEditor import WidgetEditor +from .WidgetEditor import WidgetPath + # and then specific node uis diff --git a/startup/gui/layouts.py b/startup/gui/layouts.py index 41b1a542cef..f3326268c77 100644 --- a/startup/gui/layouts.py +++ b/startup/gui/layouts.py @@ -58,6 +58,7 @@ layouts.registerEditor( "ImageInspector") layouts.registerEditor( "RenderPassEditor" ) layouts.registerEditor( "AttributeEditor" ) +layouts.registerEditor( "WidgetEditor" ) # Register some predefined layouts # From 6b7b174312efa956fa151e1b9dbe7d2f28dd3129 Mon Sep 17 00:00:00 2001 From: Eric Mehl Date: Fri, 16 Aug 2024 17:50:10 -0400 Subject: [PATCH 2/7] WidgetEditor : Pick widget --- python/GafferUI/WidgetEditor.py | 78 +++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/python/GafferUI/WidgetEditor.py b/python/GafferUI/WidgetEditor.py index 23e0ab93929..4811089d407 100644 --- a/python/GafferUI/WidgetEditor.py +++ b/python/GafferUI/WidgetEditor.py @@ -41,6 +41,37 @@ import Gaffer import GafferUI +from Qt import QtCore +from Qt import QtWidgets + +# A `QtCore.Object` for capturing all mouse clicks before any UI elements +# get the click event so we can identify the widget clicked on. +class _ButtonPressFilter( QtCore.QObject ) : + + def __init__( self ) : + + QtCore.QObject.__init__( self ) + + self.__widgetPickedSignal = Gaffer.Signals.Signal1() + + def eventFilter( self, obj, event ) : + + if event.type() == QtCore.QEvent.MouseButtonPress : + widget = GafferUI.Widget.widgetAt( GafferUI.Widget.mousePosition() ) + + if widget is not None : + self.__widgetPickedSignal( widget ) + + return True + + return False + + # A signal emitted whenver a widget is picked. Slots should have the + # signature slot( widget ). + def widgetPickedSignal( self ) : + + return self.__widgetPickedSignal + class WidgetPath( Gaffer.Path ) : # A `Gaffer.Path` to a `GafferUI.Widget` rooted at `rootWidget`. Path @@ -170,6 +201,18 @@ def __init__( self, scriptNode, **kw ) : with column : + with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) : + + self.__pickButton = GafferUI.Button( "Pick Widget" ) + self.__pickButton.buttonReleaseSignal().connect( Gaffer.WeakMethod( self.__pickButtonReleased ) ) + self.__pickButton._qtWidget().setMaximumWidth( 150 ) + + self.__delayedPickButton = GafferUI.Button( "Pick Widget (3 sec delay)" ) + self.__delayedPickButton.buttonReleaseSignal().connect( Gaffer.WeakMethod( self.__delayedPickButtonReleased ) ) + self.__delayedPickButton._qtWidget().setMaximumWidth( 150 ) + + self.__timerWidget = GafferUI.BusyWidget( size = 25, busy = False ) + self.__widgetNameColumn = GafferUI.PathListingWidget.StandardColumn( "Name", "widgetEditor:name", sizeMode = GafferUI.PathColumn.SizeMode.Stretch ) self.__widgetListingWidget = GafferUI.PathListingWidget( @@ -185,6 +228,9 @@ def __init__( self, scriptNode, **kw ) : self.visibilityChangedSignal().connect( Gaffer.WeakMethod( self.__visibilityChanged ) ) + self.__buttonPressFilter = _ButtonPressFilter() + self.__buttonPressFilter.widgetPickedSignal().connect( Gaffer.WeakMethod( self.__widgetPicked ) ) + def __repr__( self ) : return "GafferUI.WidgetEditor( scriptNode )" @@ -199,9 +245,41 @@ def __dragBegin( self, widget, event ) : GafferUI.Pointer.setCurrent( "nodes" ) return path + def __installEventFilter( self ) : + + self.__timerWidget.setBusy( False ) + QtWidgets.QApplication.instance().installEventFilter( self.__buttonPressFilter ) + + def __pickButtonReleased( self, *unused ) : + + self.__installEventFilter() + + def __delayedPickButtonReleased( self, *unused ) : + + self.__timerWidget.setBusy( True ) + QtCore.QTimer.singleShot( 3000, self.__installEventFilter ) + def __visibilityChanged( self, widget ) : if widget.visible() and self.__widgetListingWidget.getPath().scriptNode() is None : self.__widgetListingWidget.setPath( WidgetPath( self.__scriptNode ) ) + def __widgetPathWalk( self, path, targetWidget ) : + + for c in path.children() : + widget = c.property( "widgetEditor:widget" ) + if widget == targetWidget : + return c + elif widget.isAncestorOf( targetWidget ) : + return self.__widgetPathWalk( c, targetWidget ) + + def __widgetPicked( self, widget ) : + + path = self.__widgetPathWalk( self.__widgetListingWidget.getPath(), widget ) + pm = IECore.PathMatcher() + pm.addPath( str( path ) ) + self.__widgetListingWidget.setSelection( pm, True ) + QtWidgets.QApplication.instance().removeEventFilter( self.__buttonPressFilter ) + + GafferUI.Editor.registerType( "WidgetEditor", WidgetEditor ) \ No newline at end of file From 21699d32acd171edcd486789ec8a3654323df98d Mon Sep 17 00:00:00 2001 From: Eric Mehl Date: Fri, 23 Aug 2024 17:36:31 -0400 Subject: [PATCH 3/7] WidgetEditor : Widget type column --- python/GafferUI/WidgetEditor.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/python/GafferUI/WidgetEditor.py b/python/GafferUI/WidgetEditor.py index 4811089d407..e83c54c936b 100644 --- a/python/GafferUI/WidgetEditor.py +++ b/python/GafferUI/WidgetEditor.py @@ -101,7 +101,8 @@ def propertyNames( self ) : return Gaffer.Path.propertyNames() + [ "widgetEditor:name", - "widgetEditor:widget" + "widgetEditor:widget", + "widgetEditor:widgetType", ] def property( self, name, canceller = None ) : @@ -119,6 +120,8 @@ def property( self, name, canceller = None ) : return self[-1] elif name == "widgetEditor:widget" : return widget + elif name == "widgetEditor:widgetType" : + return type( widget ).__name__ def widget( self ) : # Returns the `GafferUI.Widget` for this path. @@ -213,12 +216,13 @@ def __init__( self, scriptNode, **kw ) : self.__timerWidget = GafferUI.BusyWidget( size = 25, busy = False ) - self.__widgetNameColumn = GafferUI.PathListingWidget.StandardColumn( "Name", "widgetEditor:name", sizeMode = GafferUI.PathColumn.SizeMode.Stretch ) + self.__widgetNameColumn = GafferUI.PathListingWidget.StandardColumn( "Name", "widgetEditor:name" ) self.__widgetListingWidget = GafferUI.PathListingWidget( WidgetPath( None ), # temp until we make a WidgetPath columns = ( self.__widgetNameColumn, + GafferUI.PathListingWidget.StandardColumn( "Type", "widgetEditor:widgetType" ), ), selectionMode = GafferUI.PathListingWidget.SelectionMode.Row, displayMode = GafferUI.PathListingWidget.DisplayMode.Tree From 481ced77fb37fff975145804cecb6e354e6ef1b8 Mon Sep 17 00:00:00 2001 From: Eric Mehl Date: Fri, 4 Oct 2024 15:44:13 -0400 Subject: [PATCH 4/7] WidgetEditor : Add width and height columns --- python/GafferUI/WidgetEditor.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/python/GafferUI/WidgetEditor.py b/python/GafferUI/WidgetEditor.py index e83c54c936b..413aaed32a6 100644 --- a/python/GafferUI/WidgetEditor.py +++ b/python/GafferUI/WidgetEditor.py @@ -103,6 +103,8 @@ def propertyNames( self ) : "widgetEditor:name", "widgetEditor:widget", "widgetEditor:widgetType", + "widgetEditor:width", + "widgetEditor:height", ] def property( self, name, canceller = None ) : @@ -122,6 +124,10 @@ def property( self, name, canceller = None ) : return widget elif name == "widgetEditor:widgetType" : return type( widget ).__name__ + elif name == "widgetEditor:width" : + return widget.size().x + elif name == "widgetEditor:height" : + return widget.size().y def widget( self ) : # Returns the `GafferUI.Widget` for this path. @@ -223,6 +229,8 @@ def __init__( self, scriptNode, **kw ) : columns = ( self.__widgetNameColumn, GafferUI.PathListingWidget.StandardColumn( "Type", "widgetEditor:widgetType" ), + GafferUI.PathListingWidget.StandardColumn( "Width", "widgetEditor:width" ), + GafferUI.PathListingWidget.StandardColumn( "Height", "widgetEditor:height" ), ), selectionMode = GafferUI.PathListingWidget.SelectionMode.Row, displayMode = GafferUI.PathListingWidget.DisplayMode.Tree From b67d9f595a570d4945254b6234c6d5c48c4375f9 Mon Sep 17 00:00:00 2001 From: Eric Mehl Date: Fri, 4 Oct 2024 16:44:20 -0400 Subject: [PATCH 5/7] WidgetEditor : Add min / max size columns --- python/GafferUI/WidgetEditor.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/python/GafferUI/WidgetEditor.py b/python/GafferUI/WidgetEditor.py index 413aaed32a6..0b47612f4c0 100644 --- a/python/GafferUI/WidgetEditor.py +++ b/python/GafferUI/WidgetEditor.py @@ -105,6 +105,10 @@ def propertyNames( self ) : "widgetEditor:widgetType", "widgetEditor:width", "widgetEditor:height", + "widgetEditor:minimumWidth", + "widgetEditor:minimumHeight", + "widgetEditor:maximumWidth", + "widgetEditor:maximumHeight", ] def property( self, name, canceller = None ) : @@ -128,6 +132,14 @@ def property( self, name, canceller = None ) : return widget.size().x elif name == "widgetEditor:height" : return widget.size().y + elif name == "widgetEditor:minimumWidth" : + return widget._qtWidget().minimumwidth() + elif name == "widgetEditor:minimumHeight" : + return widget._qtWidget().minimumheight() + elif name == "widgetEditor:maximumWidth" : + return widget._qtWidget().maximumWidth() + elif name == "widgetEditor:maximumHeight" : + return widget._qtWidget().maximumHeight() def widget( self ) : # Returns the `GafferUI.Widget` for this path. @@ -231,6 +243,10 @@ def __init__( self, scriptNode, **kw ) : GafferUI.PathListingWidget.StandardColumn( "Type", "widgetEditor:widgetType" ), GafferUI.PathListingWidget.StandardColumn( "Width", "widgetEditor:width" ), GafferUI.PathListingWidget.StandardColumn( "Height", "widgetEditor:height" ), + GafferUI.PathListingWidget.StandardColumn( "Minimum Width", "widgetEditor:minumumWidth" ), + GafferUI.PathListingWidget.StandardColumn( "Minimum Height", "widgetEditor:minumumHeight" ), + GafferUI.PathListingWidget.StandardColumn( "Maximum Width", "widgetEditor:maximumWidth" ), + GafferUI.PathListingWidget.StandardColumn( "Maximum Height", "widgetEditor:maximumHeight" ), ), selectionMode = GafferUI.PathListingWidget.SelectionMode.Row, displayMode = GafferUI.PathListingWidget.DisplayMode.Tree From ca2332fec0b91d0f89a0ab95300bf38b8e75cd8d Mon Sep 17 00:00:00 2001 From: Eric Mehl Date: Fri, 15 Nov 2024 17:03:48 -0500 Subject: [PATCH 6/7] WidgetEditor : Highlight selected widget --- python/GafferUI/WidgetEditor.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/python/GafferUI/WidgetEditor.py b/python/GafferUI/WidgetEditor.py index 0b47612f4c0..19e3742e3fe 100644 --- a/python/GafferUI/WidgetEditor.py +++ b/python/GafferUI/WidgetEditor.py @@ -43,6 +43,7 @@ from Qt import QtCore from Qt import QtWidgets +from Qt import QtGui # A `QtCore.Object` for capturing all mouse clicks before any UI elements # get the click event so we can identify the widget clicked on. @@ -253,12 +254,15 @@ def __init__( self, scriptNode, **kw ) : ) self.__widgetListingWidget.dragBeginSignal().connectFront( Gaffer.WeakMethod( self.__dragBegin ) ) + self.__widgetListingWidget.selectionChangedSignal().connect( Gaffer.WeakMethod( self.__selectionChanged ) ) self.visibilityChangedSignal().connect( Gaffer.WeakMethod( self.__visibilityChanged ) ) self.__buttonPressFilter = _ButtonPressFilter() self.__buttonPressFilter.widgetPickedSignal().connect( Gaffer.WeakMethod( self.__widgetPicked ) ) + self.__highlightEffects = {} + def __repr__( self ) : return "GafferUI.WidgetEditor( scriptNode )" @@ -273,6 +277,28 @@ def __dragBegin( self, widget, event ) : GafferUI.Pointer.setCurrent( "nodes" ) return path + def __selectionChanged( self, pathListing ) : + + for p, e in self.__highlightEffects.items() : + oldEffect, newEffect = e + p.property( "widgetEditor:widget" )._qtWidget().setGraphicsEffect( oldEffect ) + + self.__highlightEffects = {} + + selection = pathListing.getSelectedPaths() + + for p in selection : + w = p.property( "widgetEditor:widget" ) + if w is not None : + oldEffect = w._qtWidget().graphicsEffect() + + newEffect = QtWidgets.QGraphicsColorizeEffect() + newEffect.setColor( QtGui.QColor( 119, 156, 189, 255 ) ) + newEffect.setStrength( 0.85 ) + + self.__highlightEffects[p] = ( oldEffect, newEffect ) + w._qtWidget().setGraphicsEffect( newEffect ) + def __installEventFilter( self ) : self.__timerWidget.setBusy( False ) From c93a4c6c70203a951d2edaa7d4c6f640531cd4c8 Mon Sep 17 00:00:00 2001 From: Eric Mehl Date: Fri, 6 Dec 2024 17:30:57 -0500 Subject: [PATCH 7/7] fixup! WidgetEditor : Add editor for UI widgets --- python/GafferUI/WidgetEditor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/GafferUI/WidgetEditor.py b/python/GafferUI/WidgetEditor.py index 19e3742e3fe..fdac124f4fa 100644 --- a/python/GafferUI/WidgetEditor.py +++ b/python/GafferUI/WidgetEditor.py @@ -336,4 +336,5 @@ def __widgetPicked( self, widget ) : QtWidgets.QApplication.instance().removeEventFilter( self.__buttonPressFilter ) +IECore.registerRunTimeTyped( WidgetPath, typeName = "GafferUI::WidgetPath" ) GafferUI.Editor.registerType( "WidgetEditor", WidgetEditor ) \ No newline at end of file