From 6465633aab1704357dca4ffd602bd2b9b85f1e40 Mon Sep 17 00:00:00 2001 From: Eric Mehl Date: Wed, 15 Apr 2026 17:44:50 -0400 Subject: [PATCH 01/18] GadgetWidget : Don't lose item focus on leave --- python/GafferUI/GLWidget.py | 9 +++++++++ python/GafferUI/GadgetWidget.py | 10 +++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/python/GafferUI/GLWidget.py b/python/GafferUI/GLWidget.py index a784071b099..14f18704023 100644 --- a/python/GafferUI/GLWidget.py +++ b/python/GafferUI/GLWidget.py @@ -371,6 +371,7 @@ def __init__( self, parent, backgroundDrawFunction ) : self.__backgroundDrawFunction = backgroundDrawFunction self.sceneRectChanged.connect( self.__sceneRectChanged ) + self.focusItemChanged.connect( self.__focusItemChanged ) self.__overlays = {} # Mapping from GafferUI.Widget to _OverlayProxyWidget @@ -427,6 +428,14 @@ def __sceneRectChanged( self, sceneRect ) : for proxy in self.__overlays.values() : self.__updateItemGeometry( proxy, sceneRect ) + def __focusItemChanged( self, newItem, oldItem, reason ) : + + if newItem is None and reason == QtCore.Qt.PopupFocusReason : + # Don't lose the focus item for this view due to a popup menu. + # Losing that would prevent the `GLWidget` from forwarding events + # to overlay widgets. + self.setFocusItem( oldItem ) + def __updateItemGeometry( self, item, sceneRect ) : item.widget().setGeometry( QtCore.QRect( 0, 0, sceneRect.width(), sceneRect.height() ) ) diff --git a/python/GafferUI/GadgetWidget.py b/python/GafferUI/GadgetWidget.py index d886712be5f..24b89c726fd 100644 --- a/python/GafferUI/GadgetWidget.py +++ b/python/GafferUI/GadgetWidget.py @@ -130,6 +130,9 @@ def _draw( self ) : def __enter( self, widget ) : if not isinstance( QtWidgets.QApplication.focusWidget(), ( QtWidgets.QLineEdit, QtWidgets.QPlainTextEdit ) ) : + # \todo Do we want to clear the `focusItem` here too? If not, the breadcrumbs text + # widget will get focus as soon as this GadgetWidget gets focus, which may not be + # intuitive? self._qtWidget().setFocus() ## \todo Widget.enterSignal() should be providing this @@ -151,7 +154,12 @@ def __enter( self, widget ) : def __leave( self, widget ) : - self._qtWidget().clearFocus() + focusWidget = QtWidgets.QApplication.focusWidget() + if isinstance( focusWidget, QtWidgets.QGraphicsView ) : + focusWidget = focusWidget.scene().focusItem() + + if not isinstance( focusWidget, QtWidgets.QGraphicsProxyWidget ) : + self._qtWidget().clearFocus() p = self.mousePosition( relativeTo = self ) event = GafferUI.ButtonEvent( From 66b4ff941264cd29980570b0819ae69264748043 Mon Sep 17 00:00:00 2001 From: Eric Mehl Date: Thu, 2 Apr 2026 15:37:30 -0400 Subject: [PATCH 02/18] BreadCrumbsWidget : Widget with interactive path --- Changes.md | 8 + python/Gaffer/GraphComponentPath.py | 4 + python/GafferUI/BreadCrumbsWidget.py | 370 +++++++++++++++++++ python/GafferUI/GraphEditor.py | 41 +- python/GafferUI/_StyleSheet.py | 24 ++ python/GafferUI/__init__.py | 1 + python/GafferUITest/BreadCrumbsWidgetTest.py | 124 +++++++ python/GafferUITest/__init__.py | 1 + resources/graphics.py | 1 + resources/graphics.svg | 40 +- 10 files changed, 610 insertions(+), 4 deletions(-) create mode 100644 python/GafferUI/BreadCrumbsWidget.py create mode 100644 python/GafferUITest/BreadCrumbsWidgetTest.py diff --git a/Changes.md b/Changes.md index 123d4967d19..68bdb6bfdfe 100644 --- a/Changes.md +++ b/Changes.md @@ -7,6 +7,7 @@ Improvements - ShaderTweaks : Added support for `{shaderType=someShaderType}` qualifiers in parameter names, allowing tweaking of a parameter on all shaders of a given type (#6838). - Scene Editors : The effects of the `render:inclusions`, `render:exclusions` and `render:additionalLights` options are now represented in the Scene Editors. As these options result in the RenderSetAdaptor pruning scene locations at render time, the Hierarchy View, Attribute Editor and Light Editor now display the same pruned scene hierarchy provided to the renderer. - SetEditor, PrimitiveInspector, UVInspector : Added inspection of scene edits performed by render adaptors registered to `client = "SceneEditor"`. +- Graph Editor : Added location bar for additional control of the Graph Editor's root. Text and button interactions can be used to navigate the node hierarchy. Fixes ----- @@ -25,6 +26,13 @@ Fixes - MotionPath : Fixed hashing bug preventing motion path curves from updating when their source transforms were modified. - Viewer : Added prevention and recovery for situations where framing large objects causes the camera matrix to become corrupted with nans (#6715). +API +--- + +- GraphComponentPath : Added `setFromComponent()` +- GraphEditor : Added `getRootPath()` to return the `GraphComponentPath` that controls the editor's root node. +- BreadCrumbsWidget : Added widget for interacting with paths using a combination of button widgets and text entry. + 1.6.16.0 (relative to 1.6.15.0) ======== diff --git a/python/Gaffer/GraphComponentPath.py b/python/Gaffer/GraphComponentPath.py index e684157a737..bb745a91464 100644 --- a/python/Gaffer/GraphComponentPath.py +++ b/python/Gaffer/GraphComponentPath.py @@ -95,6 +95,10 @@ def cancellationSubject( self ) : ## \todo Perhaps BackgroundTask cancellation shouldn't only be plug-centric? return script["fileName"] + def setFromComponent( self, component ) : + + self.setFromString( component.relativeName( self.__rootComponent ).replace( ".", "/" ) if not component.isSame( self.__rootComponent ) else "/" ) + def _children( self, canceller ) : try : diff --git a/python/GafferUI/BreadCrumbsWidget.py b/python/GafferUI/BreadCrumbsWidget.py new file mode 100644 index 00000000000..506b848fb2d --- /dev/null +++ b/python/GafferUI/BreadCrumbsWidget.py @@ -0,0 +1,370 @@ +########################################################################## +# +# Copyright (c) 2026, 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 os +import functools + +import imath + +import IECore + +import Gaffer +import GafferUI + +from Qt import QtWidgets + +class BreadCrumbsWidget( GafferUI.Widget ) : + + def __init__( self, path, popupMenuTitle = "Path Item", **kw ) : + + self.__row = GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, borderWidth = 1, spacing = 7 ) + + GafferUI.Widget.__init__( self, self.__row, **kw ) + + self.__row._qtWidget().setObjectName( "gafferBreadCrumbs" ) + + with self.__row : + self.__pathButtonContainer = GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) + + self.__textWidget = GafferUI.TextWidget( toolTip = + "Right-click for contents menu." + "
Down for contents menu." + "
Up to change to container path." + "
Tab for auto-complete." + "
Home to return to root." + ) + + self.__textWidget.keyPressSignal().connect( Gaffer.WeakMethod( self.__textKeyPress ) ) + self.__textWidget.editingFinishedSignal().connect( Gaffer.WeakMethod( self.__textEditingFinished ) ) + self.__textWidget.activatedSignal().connect( Gaffer.WeakMethod( self.__textActivated ) ) + self.__textWidget.contextMenuSignal().connect( Gaffer.WeakMethod( self.__textContextMenu ) ) + self.__textWidget.textChangedSignal().connect( Gaffer.WeakMethod( self.__textChanged ) ) + + self.__popupMenu = None + + self.__popupMenuTitle = popupMenuTitle + + self.__path = Gaffer.DictPath( {}, "/" ) # Updated in `setPath()` + self.setPath( path ) + + def setPath( self, path ) : + + self.__path = path + self.__path.pathChangedSignal().connect( Gaffer.WeakMethod( self.__pathChanged, fallbackResult = None ) ) + self.__updateWidgets() + + def getPath( self ) : + + return self.__path + + def __updateWidgets( self ) : + + ## \todo Is it worth keeping buttons that are still valid? + while len( self.__pathButtonContainer ) > 0 : + self.__pathButtonContainer.remove( self.__pathButtonContainer[0] ) + + path = self.__path.copy() + path.setFromString( path.root() ) + + for w in self.__pathWidgets( path.copy() ) : + self.__pathButtonContainer.append( w ) + + for i in range( 0, len( self.__path ) ) : + path.append( self.__path[i] ) + if path.isValid() : + for w in self.__pathWidgets( path.copy() ) : + self.__pathButtonContainer.append( w ) + else : + break + + self.__textWidget.setText( path[-1] if ( len( path ) > 0 and not path.isValid() ) else "" ) + + def __pathWidgets( self, path ) : + + pathButton = GafferUI.Button( + path[-1] if len( path ) > 0 else "", + image = "home.png" if len( path ) == 0 else None, + hasFrame = False, + highlightOnOver = False, + toolTip = "Click to set as current path." + ( "
Right-click for adjacent paths menu." if len( path ) > 0 else "" ) + ) + pathButton.buttonPressSignal().connect( functools.partial( Gaffer.WeakMethod( self.__pathButtonPress ), path ) ) + pathButton.enterSignal().connect( Gaffer.WeakMethod( self.__pathButtonEnter ) ) + pathButton.leaveSignal().connect( Gaffer.WeakMethod( self.__pathButtonLeave ) ) + + return ( pathButton, GafferUI.Label( "/" ) ) + + def __copyPathToClipboard( self, pathString ) : + + self.ancestor( GafferUI.ScriptWindow ).scriptNode().applicationRoot().setClipboardContents( IECore.StringData( pathString ) ) + + def __acquireGraphEditor( self, pathString ) : + + scriptNode = self.ancestor( GafferUI.ScriptWindow ).scriptNode() + n = scriptNode.descendant( pathString ) if pathString else scriptNode + GafferUI.GraphEditor.acquire( n ) + + def __pathButtonPress( self, path, button, event ) : + + if event.buttons == GafferUI.ButtonEvent.Buttons.Right : + menuDefinition = IECore.MenuDefinition() + + if len( path ) > 0 : + parentPath = path.copy() + del parentPath[-1] + + menuDefinition.update( self.__pathMenuDefinition( parentPath ) ) + + menuDefinition.append( + "/copyDivider", + { + "divider" : True + } + ) + menuDefinition.append( + "Copy Path", + { + "command" : functools.partial( Gaffer.WeakMethod( self.__copyPathToClipboard ), pathString = str( path ) ), + } + ) + + menuDefinition.append( + "Open in new Graph Editor", + { + "command" : functools.partial( Gaffer.WeakMethod( self.__acquireGraphEditor ), pathString = str( path ) ), + "active" : path != self.__path, + } + ) + + self.__popupListing( menuDefinition, button ) + + return True + + elif event.button == event.Buttons.Left : + self.__path[:] = path[:] + return True + + return False + + def __pathButtonEnter( self, button ) : + + button.setHasFrame( True ) + + def __pathButtonLeave( self, button ) : + + button.setHasFrame( False ) + + def __textChanged( self, textWidget ) : + + popupRequested = False + if self.__popupMenu is not None and self.__popupMenu.visible() : + GafferUI.WidgetAlgo.keepUntilIdle( self.__popupMenu ) + self.__popupMenu = None + popupRequested = True + + text = textWidget.getText() + newPath = self.__validatedPath( self.__path, text ) + + if newPath is not None and ( ( len( text ) > 0 and text[-1] == "/" ) or len( newPath ) == 0 ) : + self.__path[:] = newPath[:] + return + + if popupRequested : + self.__popupListing( self.__pathMenuDefinition( self.__path, text ), self.__textWidget ) + + def __textEditingFinished( self, textWidget ) : + + # This signal is also emitted when the menu pops up. If that's the case, + # don't clear the text. Also leave the text intact if we still have focus, + # i.e. the enter key was pressed. `__textActivated()` takes care of the contents + # in that case.` + if ( self.__popupMenu is None or not self.__popupMenu.visible() ) and not self.__textWidget._qtWidget().hasFocus() : + self.__textWidget.setText( "" ) + + return True + + def __textActivated( self, textWidget ) : + + if self.__popupMenu is not None and self.__popupMenu.visible() : + self.__popupMenu = None + + text = textWidget.getText() + newPath = self.__validatedPath( self.__path, text ) + + if newPath is not None : + self.__path[:] = newPath[:] + + return True + + def __validatedPath( self, path, suffix ) : + + newPath = path.copy() + + if suffix == "" : + return None + + suffix = suffix.replace( ".", "/" ) + newPath.setFromString( str( path ) + "/" + suffix ) + + passesFilter = True + if path.getFilter() is not None : + passesFilter = path.getFilter().filter( [newPath] ) == [newPath] + + return newPath if ( newPath.isValid() and passesFilter ) else None + + def __textKeyPress( self, widget, event ) : + + if not self.__textWidget.getEditable() : + # \todo This is copied from the `PathWidget`, is it possible to arrive here? + # Does it belong on `self` instead? + return False + + if event.key == "Backspace" and self.__textWidget.getText() == "" and len( self.__path ) > 0 : + t = self.__path[-1] + del self.__path[-1] + self.__textWidget.setText( t ) + return True + + elif event.key=="Tab" : + self.__tabComplete() + return True + + elif event.key == "Down" : + self.__popupListing( self.__pathMenuDefinition( self.__path ), self.__textWidget ) + return True + + elif event.key == "Up" : + if self.__textWidget.getText() != "" : + self.__textWidget.setText( "" ) + elif len( self.__path ) > 0 : + del self.__path[-1] + return True + + elif event.key == "Home" : + self.__path.setFromString( self.__path.root() ) + return True + + return False + + def __textContextMenu( self, widget ) : + + self.__popupListing( self.__pathMenuDefinition( self.__path ), None ) + return True + + def __tabComplete( self ) : + + position = self.__textWidget.getCursorPosition() + text = self.__textWidget.getText() + + matches = [ x[-1] for x in self.__path.children() if x[-1].startswith( text[:position] ) ] + + match = os.path.commonprefix( matches ) + if match : + self.__textWidget.setText( match ) + self.__popupListing( self.__pathMenuDefinition( self.__path, match or text ), self.__textWidget ) + + self.__textWidget.setCursorPosition( len( self.__textWidget.getText() ) ) + + def __setPathEntry( self, path ) : + + if path == self.__path : + return + + newPath = self.__path.copy() + newPath.setFromString( newPath.root() ) + pathLength = len( path ) + for i in range( 0, max( pathLength, len( self.__path ) ) ) : + newPath.append( path[i] if i < pathLength else self.__path[i] ) + + newPath.truncateUntilValid() + self.__path[:] = newPath[:] + + self.__textWidget.grabFocus() + + def __pathMenuDefinition( self, path, prefix = "" ) : + + result = IECore.MenuDefinition() + + sortedChildren = sorted( path.children(), key = lambda v : v[-1] ) + + pathPrefix = "/" + for i, childPath in enumerate( [ i for i in sortedChildren if i[-1].startswith( prefix ) ] ) : + result.append( + pathPrefix + childPath[-1], + { + "command" : functools.partial( Gaffer.WeakMethod( self.__setPathEntry ), childPath ), + } + ) + + if i == 10 : + pathPrefix = "/More/" + result.append( "/Divider", { "divider" : True } ) + + if result.size() == 0 : + result.append( "/No viewable children", { "active" : False, } ) + + return result + + def __popupListing( self, menuDefinition, parentWidget ) : + + bound = None + if parentWidget is not None : + bound = parentWidget.bound() + xOffset = 0 + if isinstance( parentWidget, GafferUI.TextWidget ) : + xOffset = parentWidget._qtWidget().cursorRect().left() + + self.__popupMenu = GafferUI.Menu( menuDefinition, title = self.__popupMenuTitle ) + self.__popupMenu.visibilityChangedSignal().connect( Gaffer.WeakMethod( self.__popupMenuVisibilityChanged ) ) + self.__popupMenu.popup( + parent = self.ancestor( GafferUI.GadgetWidget ) or self.__textWidget, + position = imath.V2i( bound.min().x + xOffset, bound.max().y ) if bound is not None else None, + ) + + ## \todo Expose KeyboardMode publicly in `popup()`? + + ## \todo Is this valid for uses outside the GraphEditor? + self.__popupMenu._qtWidget().keyboardMode = self.__popupMenu._qtWidget().KeyboardMode.Forward + + def __popupMenuVisibilityChanged( self, widget ) : + + # \todo Determine if `__textWidget` needs to be cleared. It should be if it + # no longer has focus after the popup is hidden. + pass + + def __pathChanged( self, path ) : + + self.__updateWidgets() diff --git a/python/GafferUI/GraphEditor.py b/python/GafferUI/GraphEditor.py index a1d4a816c48..679dffe1714 100644 --- a/python/GafferUI/GraphEditor.py +++ b/python/GafferUI/GraphEditor.py @@ -37,12 +37,31 @@ import functools import imath +import weakref import IECore import Gaffer import GafferUI +class _ViewableChildrenPathFilter( Gaffer.PathFilter ) : + + def __init__( self, scriptRoot, userData = {} ) : + + Gaffer.PathFilter.__init__( self, userData ) + + self.__scriptRoot = weakref.ref( scriptRoot ) + + def _filter( self, paths, canceller ) : + + result = [] + for p in paths : + n = self.__scriptRoot().descendant( str( p ).lstrip( "/" ).replace( "/", "." ) ) + if isinstance( n, Gaffer.Node ) and Gaffer.Metadata.value( n, "ui:childNodesAreViewable" ) or False : + result.append( p ) + + return result + class GraphEditor( GafferUI.Editor ) : def __init__( self, scriptNode, **kw ) : @@ -64,17 +83,23 @@ def __init__( self, scriptNode, **kw ) : self.__gadgetWidget.getViewportGadget().setDragTracking( GafferUI.ViewportGadget.DragTracking.XDragTracking | GafferUI.ViewportGadget.DragTracking.YDragTracking ) self.__frame( scriptNode.selection() ) - self.__gadgetWidget.buttonPressSignal().connect( Gaffer.WeakMethod( self.__buttonPress ) ) - self.__gadgetWidget.keyPressSignal().connect( Gaffer.WeakMethod( self.__keyPress ) ) - self.__gadgetWidget.buttonDoubleClickSignal().connect( Gaffer.WeakMethod( self.__buttonDoubleClick ) ) + self.__gadgetWidget.getViewportGadget().buttonPressSignal().connect( Gaffer.WeakMethod( self.__buttonPress ) ) + self.__gadgetWidget.getViewportGadget().keyPressSignal().connect( Gaffer.WeakMethod( self.__keyPress ) ) + self.__gadgetWidget.getViewportGadget().buttonDoubleClickSignal().connect( Gaffer.WeakMethod( self.__buttonDoubleClick ) ) self.dragEnterSignal().connect( Gaffer.WeakMethod( self.__dragEnter ) ) self.dragLeaveSignal().connect( Gaffer.WeakMethod( self.__dragLeave ) ) self.dropSignal().connect( Gaffer.WeakMethod( self.__drop ) ) self.__dragEnterPointer = None self.__gadgetWidget.getViewportGadget().preRenderSignal().connect( Gaffer.WeakMethod( self.__preRender ) ) + self.__viewableChildrenPathFilter = _ViewableChildrenPathFilter( self.scriptNode() ) + self.__rootPath = Gaffer.GraphComponentPath( self.scriptNode(), [], filter = self.__viewableChildrenPathFilter ) + self.__rootPathChangedConnection = self.__rootPath.pathChangedSignal().connect( Gaffer.WeakMethod( self.__rootPathChanged ), scoped = True ) + with GafferUI.ListContainer( borderWidth = 8, spacing = 0 ) as overlay : with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal ) : + GafferUI.BreadCrumbsWidget( self.__rootPath, "Node Path" ) + GafferUI.Spacer( imath.V2i( 1 ) ) GafferUI.MenuButton( image = "annotations.png", hasFrame = False, @@ -614,8 +639,13 @@ def __rootChanged( self, graphGadget, previousRoot ) : self.titleChangedSignal()( self ) + self.__rootPath.setFromComponent( graphGadget.getRoot() ) + def __rootNameChanged( self, root, oldName ) : + with Gaffer.Signals.BlockedConnection( self.__rootPathChangedConnection ) : + self.__rootPath.setFromComponent( self.graphGadget().getRoot() ) + self.titleChangedSignal()( self ) def __rootParentChanged( self, node, oldParent ) : @@ -624,6 +654,11 @@ def __rootParentChanged( self, node, oldParent ) : if node.parent() == None : self.graphGadget().setRoot( self.scriptNode() ) + def __rootPathChanged( self, path ) : + + with Gaffer.Signals.BlockedConnection( self.__rootPathChangedConnection ) : + self.graphGadget().setRoot( path.property( "graphComponent:graphComponent" ) ) + def __preRender( self, viewportGadget ) : # Find all unpositioned nodes. diff --git a/python/GafferUI/_StyleSheet.py b/python/GafferUI/_StyleSheet.py index f4a84c148b0..848f04431fc 100644 --- a/python/GafferUI/_StyleSheet.py +++ b/python/GafferUI/_StyleSheet.py @@ -1777,6 +1777,30 @@ def styleColor( key ) : border: 1px solid $brightColor; } + #gafferBreadCrumbs { + border: 1px solid transparent; + border-bottom-color: $tintDarkerStronger; + border-right-color: $tintDarkerStronger; + background-color: $backgroundLight; + border-radius: $widgetCornerRadius; + } + + #gafferBreadCrumbs QLineEdit { + border: 0; + border-radius: 0; + background-color: transparent; + } + + #gafferBreadCrumbs QPushButton[gafferWithFrame="false"] { + margin: 1px; + padding: 4px; + } + + #gafferBreadCrumbs QPushButton[gafferWithFrame="true"] { + margin: 0px; + font-weight: normal; + } + """ ).substitute( substitutions ) diff --git a/python/GafferUI/__init__.py b/python/GafferUI/__init__.py index a1c612df7e2..46ad3838b05 100644 --- a/python/GafferUI/__init__.py +++ b/python/GafferUI/__init__.py @@ -181,6 +181,7 @@ def __shiboken() : from .Bookmarks import Bookmarks from . import WidgetAlgo from .CodeWidget import CodeWidget +from .BreadCrumbsWidget import BreadCrumbsWidget # then all the PathPreviewWidgets. note that the order # of import controls the order of display. diff --git a/python/GafferUITest/BreadCrumbsWidgetTest.py b/python/GafferUITest/BreadCrumbsWidgetTest.py new file mode 100644 index 00000000000..d05240be4fb --- /dev/null +++ b/python/GafferUITest/BreadCrumbsWidgetTest.py @@ -0,0 +1,124 @@ +########################################################################## +# +# Copyright (c) 2020, 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 unittest + +import Gaffer +import GafferUI +import GafferUITest + +class BreadCrumbsWidgetTest( GafferUITest.TestCase ) : + + def __assertWidgets( self, widgets, path ) : + + self.assertEqual( len( widgets ), 2 + len( path ) * 2 ) + + self.assertIsInstance( widgets[0], GafferUI.Button ) + self.assertEqual( widgets[0].getText(), "" ) + self.assertIsInstance( widgets[1], GafferUI.Label ) + self.assertEqual( widgets[1].getText(), "/" ) + + for i, p in enumerate( path ) : + self.assertIsInstance( widgets[i * 2 + 2], GafferUI.Button ) + self.assertEqual( widgets[i * 2 + 2].getText(), p ) + self.assertIsInstance( widgets[i * 2 + 3], GafferUI.Label ) + self.assertEqual( widgets[i * 2 + 3].getText(), "/" ) + + def testPathWidgets( self ) : + + path = Gaffer.DictPath( { "parent" : { "child": "contents" }, "brother" : "contents" }, "/" ) + + crumbs = GafferUI.BreadCrumbsWidget( path ) + + self.__assertWidgets( crumbs._BreadCrumbsWidget__pathButtonContainer, path ) + + path.setFromString( "/parent" ) + self.__assertWidgets( crumbs._BreadCrumbsWidget__pathButtonContainer, path ) + + path.append( "child" ) + self.__assertWidgets( crumbs._BreadCrumbsWidget__pathButtonContainer, path ) + + path.setFromString( "/brother" ) + self.__assertWidgets( crumbs._BreadCrumbsWidget__pathButtonContainer, path ) + + def testTextEntry( self ) : + + path = Gaffer.DictPath( { "parent" : { "child": { "grandChild" : "contents" } }, "brother" : "contents" }, "/" ) + + crumbs = GafferUI.BreadCrumbsWidget( path ) + + t = crumbs._BreadCrumbsWidget__textWidget + self.assertEqual( t.getText(), "" ) + + t.setText( "parent/" ) + self.assertEqual( t.getText(), "" ) + self.assertEqual( str( path ), "/parent" ) + + t.setText( "child.grandChild/" ) + self.assertEqual( t.getText(), "" ) + self.assertEqual( str( path ), "/parent/child/grandChild" ) + + t.setText( "brother" ) + self.assertEqual( t.getText(), "brother" ) + self.assertEqual( str( path ), "/parent/child/grandChild" ) + + path.setFromString( "/" ) + t.setText( "brother/" ) + self.assertEqual( t.getText(), "" ) + self.assertEqual( str( path ), "/brother" ) + + def testFilter( self ) : + + filter = Gaffer.MatchPatternPathFilter( [ "a*" ] ) + path = Gaffer.DictPath( { "abc" : "contents", "xyz" : "contents" }, "/", filter = filter ) + + crumbs = GafferUI.BreadCrumbsWidget( path ) + + t = crumbs._BreadCrumbsWidget__textWidget + + t.setText( "abc/" ) + self.assertEqual( t.getText(), "" ) + self.assertEqual( str( path ), "/abc" ) + + path.setFromString( "/" ) + + t.setText( "xyz/" ) + self.assertEqual( t.getText(), "xyz/" ) + self.assertEqual( str( path ), "/" ) + + +if __name__ == "__main__" : + unittest.main() diff --git a/python/GafferUITest/__init__.py b/python/GafferUITest/__init__.py index c89c10304a2..d647ae96598 100644 --- a/python/GafferUITest/__init__.py +++ b/python/GafferUITest/__init__.py @@ -136,6 +136,7 @@ from .ColorChooserTest import ColorChooserTest from .ContextTrackerTest import ContextTrackerTest from .MetadataAlgoTest import MetadataAlgoTest +from .BreadCrumbsWidgetTest import BreadCrumbsWidgetTest if __name__ == "__main__": unittest.main() diff --git a/resources/graphics.py b/resources/graphics.py index 1f43569cc81..2c01f4bd130 100644 --- a/resources/graphics.py +++ b/resources/graphics.py @@ -528,6 +528,7 @@ "searchFieldBackground", "search", "searchOn", + "home", ], }, diff --git a/resources/graphics.svg b/resources/graphics.svg index 2ff7b2a7a1e..b1f20eb6210 100644 --- a/resources/graphics.svg +++ b/resources/graphics.svg @@ -109,7 +109,7 @@ + + + @@ -3981,6 +3993,14 @@ id="path20339" style="fill:#272727;fill-opacity:1;stroke:#272727;stroke-width:1.92047;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> + + + + + + A + From 4841b52a99742d0921da9db0b637f6ef6c2261e0 Mon Sep 17 00:00:00 2001 From: Eric Mehl Date: Fri, 17 Apr 2026 15:25:21 -0400 Subject: [PATCH 03/18] GadgetWidget : `mouseMove` to overlay first --- python/GafferUI/GadgetWidget.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/python/GafferUI/GadgetWidget.py b/python/GafferUI/GadgetWidget.py index 24b89c726fd..3bd818b89ee 100644 --- a/python/GafferUI/GadgetWidget.py +++ b/python/GafferUI/GadgetWidget.py @@ -216,15 +216,15 @@ def __buttonDoubleClick( self, widget, event ) : def __mouseMove( self, widget, event ) : - if not self._makeCurrent() : + # We get given mouse moves before they're given to the overlay items, + # so we must ignore them so they can be used by the overlay. + if self._qtWidget().itemAt( event.line.p0.x, event.line.p0.y ) is not None : return False - self.__viewportGadget.mouseMoveSignal()( self.__viewportGadget, event ) + if not self._makeCurrent() : + return False - # we always return false so that any overlay items will get appropriate - # move/enter/leave events, otherwise highlighting for buttons etc can go - # awry. - return False + return self.__viewportGadget.mouseMoveSignal()( self.__viewportGadget, event ) def __dragBegin( self, widget, event ) : From ad83ebb901f2316bd24d0268097809ee3f353823 Mon Sep 17 00:00:00 2001 From: Eric Mehl Date: Mon, 20 Apr 2026 16:44:16 -0400 Subject: [PATCH 04/18] GraphEditor : History forward and back buttons --- Changes.md | 4 +- python/GafferUI/GraphEditor.py | 148 +++++++++++++++++++++++++++++++-- resources/graphics.py | 8 +- resources/graphics.svg | 123 +++++++++++++++++---------- 4 files changed, 227 insertions(+), 56 deletions(-) diff --git a/Changes.md b/Changes.md index 68bdb6bfdfe..178880fc150 100644 --- a/Changes.md +++ b/Changes.md @@ -7,7 +7,9 @@ Improvements - ShaderTweaks : Added support for `{shaderType=someShaderType}` qualifiers in parameter names, allowing tweaking of a parameter on all shaders of a given type (#6838). - Scene Editors : The effects of the `render:inclusions`, `render:exclusions` and `render:additionalLights` options are now represented in the Scene Editors. As these options result in the RenderSetAdaptor pruning scene locations at render time, the Hierarchy View, Attribute Editor and Light Editor now display the same pruned scene hierarchy provided to the renderer. - SetEditor, PrimitiveInspector, UVInspector : Added inspection of scene edits performed by render adaptors registered to `client = "SceneEditor"`. -- Graph Editor : Added location bar for additional control of the Graph Editor's root. Text and button interactions can be used to navigate the node hierarchy. +- Graph Editor : + - Added location bar for additional control of the Graph Editor's root. Text and button interactions can be used to navigate the node hierarchy. + - Added forward and back buttons to step through the history of the editor's root nodes. Fixes ----- diff --git a/python/GafferUI/GraphEditor.py b/python/GafferUI/GraphEditor.py index 679dffe1714..44e8a6c26b8 100644 --- a/python/GafferUI/GraphEditor.py +++ b/python/GafferUI/GraphEditor.py @@ -64,6 +64,144 @@ def _filter( self, paths, canceller ) : class GraphEditor( GafferUI.Editor ) : + class __HistoryWidget( GafferUI.Widget ) : + + # \todo It would be nice to get the `GraphEditor` from the hierarchy. + # But the ancestors of this widget are two `ListContainer` objects (including + # in `_postConstructor()`). + # `Widget.parent()` should be taking care of crossing the QGraphicsProxy + # line. `self.ancestor( GafferUI.GraphEditor )` works in `__moveHistoryIndex` + # below. + def __init__( self, graphEditor, **kw ) : + + self.__row = GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) + + GafferUI.Widget.__init__( self, self.__row, **kw ) + + self.__graphEditor = weakref.ref( graphEditor ) + + assert( self.__graphEditor() is not None ) + self.__rootChangedConnection = self.__graphEditor().graphGadget().rootChangedSignal().connectFront( Gaffer.WeakMethod( self.__rootChanged ), scoped = True ) + self.__graphEditor().graphGadgetWidget().keyPressSignal().connect( Gaffer.WeakMethod( self.__keyPress ) ) + + self.__history = [ ( self.__graphEditor().scriptNode(), None ) ] + + with self.__row : + self.__backButton = GafferUI.Button( "", "historyBack.png", hasFrame = False, toolTip = "Step back in Graph Editor history. Right-click for past-nodes popup menu.[[]" ) + self.__forwardButton = GafferUI.Button( "", "historyForward.png", hasFrame = False, toolTip = "Step forward in Graph Editor history. Right-click for future-nodes popup menu.[]]" ) + self.__backButton.buttonPressSignal().connect( Gaffer.WeakMethod( self.__backButtonPressed ) ) + self.__forwardButton.buttonPressSignal().connect( Gaffer.WeakMethod( self.__forwardButtonPressed ) ) + + # A list of tuples of the form `( rootNode, frame )`. A frame value of `None` + # indicates framing to fit all child nodes. + self.__history = [ ( self.__graphEditor().scriptNode(), None ) ] + self.__historyIndex = 0 + self.__historyPopup = None + + self.__updateHistoryButtonsEnabled() + + + # Returns the frame to use for the given root node or `None` to indicate default framing. + def frame( self, rootNode ) : + + frame = None + for i in range( self.__historyIndex, -1, -1 ) : + historyNode, historyFrame = self.__history[i] + if historyNode.isSame( rootNode ) and historyFrame is not None : + frame = historyFrame + break + + return frame + + def __keyPress( self, widget, event ) : + + if event.key == "BracketLeft" and not event.modifiers : + self.__moveHistoryIndex( -1 ) + return True + elif event.key == "BracketRight" and not event.modifiers : + self.__moveHistoryIndex( 1 ) + return True + + def __backButtonPressed( self, button, event ) : + + if event.buttons == event.Buttons.Left : + self.__moveHistoryIndex( -1 ) + elif event.buttons == event.Buttons.Right : + self.__showHistoryPopup( range( self.__historyIndex - 1, -1, -1 ), button ) + + def __forwardButtonPressed( self, button, event ) : + + if event.buttons == event.Buttons.Left : + self.__moveHistoryIndex( 1 ) + elif event.buttons == event.Buttons.Right : + self.__showHistoryPopup( range( self.__historyIndex + 1, len( self.__history ), 1 ), button ) + + def __rootChanged( self, graphGadget, previousRoot ) : + + self.__history = self.__history[:self.__historyIndex + 1] + self.__history[self.__historyIndex] = ( self.__history[self.__historyIndex][0], self.__graphEditor()._GraphEditor__currentFrame() ) + + self.__history.append( ( graphGadget.getRoot(), None ) ) + self.__historyIndex += 1 + self.__updateHistoryButtonsEnabled() + + def __moveHistoryIndex( self, delta ) : + + if self.__historyIndex + delta < 0 or self.__historyIndex + delta >= len( self.__history ) : + return + + self.__history[self.__historyIndex] = ( self.__history[self.__historyIndex][0], self.__graphEditor()._GraphEditor__currentFrame() ) + + self.__historyIndex += delta + + # \todo Where do we go if the node has been deleted? + graphEditor = self.ancestor( GafferUI.GraphEditor ) + assert( graphEditor is not None ) + rootNode = self.__history[self.__historyIndex][0] + if rootNode.isSame( graphEditor.scriptNode() ) or graphEditor.scriptNode().isAncestorOf( rootNode ) : + with Gaffer.Signals.BlockedConnection( self.__rootChangedConnection ) : + if rootNode.isSame( graphEditor.graphGadget().getRoot() ) : + # `GraphGadget` won't emit `rootChangedSignal()` because the roots are the same. + # Framing might be different, so we do it ourselves. + frame = self.frame( rootNode ) + if frame is not None : + graphEditor.graphGadgetWidget().getViewportGadget().frame( + imath.Box3f( imath.V3f( frame.min().x, frame.min().y, 0 ), imath.V3f( frame.max().x, frame.max().y, 0 ) ) + ) + else : + self.__frame( graphEditor.graphGadget().getRoot().children( Gaffer.Node ) ) + graphEditor.graphGadget().setRoot( rootNode ) + + + + self.__updateHistoryButtonsEnabled() + + def __showHistoryPopup(self, historyRange, popupParent ) : + + graphEditor = self.ancestor( GafferUI.GraphEditor ) + menuDefinition = IECore.MenuDefinition() + prefix = "/" + for counter, i in enumerate( historyRange ) : + menuDefinition.append( + prefix + str( counter ), + { + "label" : self.__history[i][0].relativeName( graphEditor.scriptNode() ) if not self.__history[i][0].isSame( graphEditor.scriptNode() ) else "Script Root", + "command" : functools.partial( Gaffer.WeakMethod( self.__moveHistoryIndex ), i - self.__historyIndex ), + } + ) + + if counter == 10 : + prefix = "/More/" + menuDefinition.append( "/Divider", { "divider" : True } ) + + self.__historyPopup = GafferUI.Menu( menuDefinition ) + self.__historyPopup.popup( popupParent, position = imath.V2i( popupParent.bound().min().x, popupParent.bound().max().y ) ) + + def __updateHistoryButtonsEnabled( self ) : + + self.__backButton.setEnabled( self.__historyIndex > 0 ) + self.__forwardButton.setEnabled( self.__historyIndex < ( len( self.__history ) - 1 ) ) + def __init__( self, scriptNode, **kw ) : # We want to disable precise navigation motions as they interfere @@ -97,10 +235,12 @@ def __init__( self, scriptNode, **kw ) : self.__rootPathChangedConnection = self.__rootPath.pathChangedSignal().connect( Gaffer.WeakMethod( self.__rootPathChanged ), scoped = True ) with GafferUI.ListContainer( borderWidth = 8, spacing = 0 ) as overlay : - with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal ) : + with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) : + self.__historyWidget = self.__HistoryWidget( self ) GafferUI.BreadCrumbsWidget( self.__rootPath, "Node Path" ) GafferUI.Spacer( imath.V2i( 1 ) ) + GafferUI.MenuButton( image = "annotations.png", hasFrame = False, menu = GafferUI.Menu( @@ -612,12 +752,8 @@ def __currentFrame( self ) : def __rootChanged( self, graphGadget, previousRoot ) : - # save/restore the current framing so jumping in - # and out of Boxes isn't a confusing experience. - - Gaffer.Metadata.registerValue( previousRoot, "ui:graphEditor{}:framing".format( id( self ) ), self.__currentFrame(), persistent = False ) + frame = self.__historyWidget.frame( graphGadget.getRoot() ) - frame = Gaffer.Metadata.value( self.graphGadget().getRoot(), "ui:graphEditor{}:framing".format( id( self ) ) ) if frame is not None : self.graphGadgetWidget().getViewportGadget().frame( imath.Box3f( imath.V3f( frame.min().x, frame.min().y, 0 ), imath.V3f( frame.max().x, frame.max().y, 0 ) ) diff --git a/resources/graphics.py b/resources/graphics.py index 2c01f4bd130..fbcf13a2955 100644 --- a/resources/graphics.py +++ b/resources/graphics.py @@ -325,6 +325,8 @@ "ids" : [ "annotations", + "historyBack", + "historyForward", ], }, @@ -532,13 +534,13 @@ ], }, - + "nodeEditor" : { - + "options" : { "validatePixelAlignment" : True, }, - + "ids" : [ "rendererArnoldOnIcon", "rendererArnoldOffIcon", diff --git a/resources/graphics.svg b/resources/graphics.svg index b1f20eb6210..e0ec2fc045e 100644 --- a/resources/graphics.svg +++ b/resources/graphics.svg @@ -175,7 +175,7 @@ + gradientTransform="matrix(-1.8190548,0,0,-2.0056446,1019.0357,-2735.4846)"> - + + + + + - - - - + id="g3004" + style="display:inline;stroke:#282828;stroke-opacity:1" + transform="translate(42,142.00001)" + inkscape:label="GraphEditor"> + id="path2303" + style="display:inline;fill:url(#backgroundLighter);fill-opacity:0.992157;fill-rule:evenodd;stroke:#3c3c3c;stroke-opacity:0.992157" + inkscape:label="historyBackArrow" + d="M 66,2355.3622 H 56 v 6 l -11,-10.5 11,-10.5 v 6 h 10" + sodipodi:nodetypes="ccccccc" /> + id="path334" + style="display:inline;fill:url(#backgroundLighter);fill-opacity:0.992157;fill-rule:evenodd;stroke:#3c3c3c;stroke-opacity:0.992157" + inkscape:label="historyForwardArrow" + d="M 66,2355.3622 H 56 v 6 l -11,-10.5 11,-10.5 v 6 h 10" + sodipodi:nodetypes="ccccccc" + transform="matrix(-1,0,0,1,146,0)" /> + + + + + + - + From 9ab1fcc5bce7286b98c67caf252407f0c954e3de Mon Sep 17 00:00:00 2001 From: Eric Mehl Date: Thu, 23 Apr 2026 17:26:30 -0400 Subject: [PATCH 05/18] fixup! BreadCrumbsWidget : Widget with interactive path --- python/GafferUI/BreadCrumbsWidget.py | 2 -- python/GafferUI/_StyleSheet.py | 8 ++++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/python/GafferUI/BreadCrumbsWidget.py b/python/GafferUI/BreadCrumbsWidget.py index 506b848fb2d..aedc3821bb1 100644 --- a/python/GafferUI/BreadCrumbsWidget.py +++ b/python/GafferUI/BreadCrumbsWidget.py @@ -54,8 +54,6 @@ def __init__( self, path, popupMenuTitle = "Path Item", **kw ) : GafferUI.Widget.__init__( self, self.__row, **kw ) - self.__row._qtWidget().setObjectName( "gafferBreadCrumbs" ) - with self.__row : self.__pathButtonContainer = GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) diff --git a/python/GafferUI/_StyleSheet.py b/python/GafferUI/_StyleSheet.py index 848f04431fc..c3d6b633329 100644 --- a/python/GafferUI/_StyleSheet.py +++ b/python/GafferUI/_StyleSheet.py @@ -1777,7 +1777,7 @@ def styleColor( key ) : border: 1px solid $brightColor; } - #gafferBreadCrumbs { + [gafferClass="GafferUI.BreadCrumbsWidget"] { border: 1px solid transparent; border-bottom-color: $tintDarkerStronger; border-right-color: $tintDarkerStronger; @@ -1785,18 +1785,18 @@ def styleColor( key ) : border-radius: $widgetCornerRadius; } - #gafferBreadCrumbs QLineEdit { + [gafferClass="GafferUI.BreadCrumbsWidget"] QLineEdit { border: 0; border-radius: 0; background-color: transparent; } - #gafferBreadCrumbs QPushButton[gafferWithFrame="false"] { + [gafferClass="GafferUI.BreadCrumbsWidget"] QPushButton[gafferWithFrame="false"] { margin: 1px; padding: 4px; } - #gafferBreadCrumbs QPushButton[gafferWithFrame="true"] { + [gafferClass="GafferUI.BreadCrumbsWidget"] QPushButton[gafferWithFrame="true"] { margin: 0px; font-weight: normal; } From e7a929c5a2870d2dd4b21e19a4f92684aeeb6cc8 Mon Sep 17 00:00:00 2001 From: Eric Mehl Date: Fri, 24 Apr 2026 12:10:10 -0400 Subject: [PATCH 06/18] fixup! BreadCrumbsWidget : Widget with interactive path --- python/GafferUI/BreadCrumbsWidget.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/python/GafferUI/BreadCrumbsWidget.py b/python/GafferUI/BreadCrumbsWidget.py index aedc3821bb1..041dcc96349 100644 --- a/python/GafferUI/BreadCrumbsWidget.py +++ b/python/GafferUI/BreadCrumbsWidget.py @@ -58,11 +58,12 @@ def __init__( self, path, popupMenuTitle = "Path Item", **kw ) : self.__pathButtonContainer = GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) self.__textWidget = GafferUI.TextWidget( toolTip = - "Right-click for contents menu." - "
Down for contents menu." - "
Up to change to container path." - "
Tab for auto-complete." - "
Home to return to root." + "## Actions\n\n" + "- Right-click for contents menu.\n" + "- Down to show children.\n" + "- Up to go to parent.\n" + "- Tab for auto-complete.\n" + "- Home to return to root." ) self.__textWidget.keyPressSignal().connect( Gaffer.WeakMethod( self.__textKeyPress ) ) From a3a5af95e60a559d7620517fb3d24ec974d8760c Mon Sep 17 00:00:00 2001 From: Eric Mehl Date: Fri, 24 Apr 2026 12:21:05 -0400 Subject: [PATCH 07/18] fixup! BreadCrumbsWidget : Widget with interactive path --- python/GafferUI/BreadCrumbsWidget.py | 1 - 1 file changed, 1 deletion(-) diff --git a/python/GafferUI/BreadCrumbsWidget.py b/python/GafferUI/BreadCrumbsWidget.py index 041dcc96349..0de7958aa49 100644 --- a/python/GafferUI/BreadCrumbsWidget.py +++ b/python/GafferUI/BreadCrumbsWidget.py @@ -76,7 +76,6 @@ def __init__( self, path, popupMenuTitle = "Path Item", **kw ) : self.__popupMenuTitle = popupMenuTitle - self.__path = Gaffer.DictPath( {}, "/" ) # Updated in `setPath()` self.setPath( path ) def setPath( self, path ) : From 2d8aec01e3aa192d15caeb188591d37840f0e7f8 Mon Sep 17 00:00:00 2001 From: Eric Mehl Date: Fri, 24 Apr 2026 12:33:17 -0400 Subject: [PATCH 08/18] fixup! BreadCrumbsWidget : Widget with interactive path --- python/GafferUI/BreadCrumbsWidget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/GafferUI/BreadCrumbsWidget.py b/python/GafferUI/BreadCrumbsWidget.py index 0de7958aa49..630f8494249 100644 --- a/python/GafferUI/BreadCrumbsWidget.py +++ b/python/GafferUI/BreadCrumbsWidget.py @@ -81,7 +81,7 @@ def __init__( self, path, popupMenuTitle = "Path Item", **kw ) : def setPath( self, path ) : self.__path = path - self.__path.pathChangedSignal().connect( Gaffer.WeakMethod( self.__pathChanged, fallbackResult = None ) ) + self.__pathChangedConnection = self.__path.pathChangedSignal().connect( Gaffer.WeakMethod( self.__pathChanged ), scoped = True ) self.__updateWidgets() def getPath( self ) : From 7010f39ff9c5b57987c8848fd32fb2e5b1bf711c Mon Sep 17 00:00:00 2001 From: Eric Mehl Date: Fri, 24 Apr 2026 12:33:24 -0400 Subject: [PATCH 09/18] fixup! BreadCrumbsWidget : Widget with interactive path --- python/GafferUI/BreadCrumbsWidget.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/python/GafferUI/BreadCrumbsWidget.py b/python/GafferUI/BreadCrumbsWidget.py index 630f8494249..1a637edcad1 100644 --- a/python/GafferUI/BreadCrumbsWidget.py +++ b/python/GafferUI/BreadCrumbsWidget.py @@ -90,9 +90,7 @@ def getPath( self ) : def __updateWidgets( self ) : - ## \todo Is it worth keeping buttons that are still valid? - while len( self.__pathButtonContainer ) > 0 : - self.__pathButtonContainer.remove( self.__pathButtonContainer[0] ) + del self.__pathButtonContainer[:] path = self.__path.copy() path.setFromString( path.root() ) From 4858df03f192d82e50cd7661a70b57ffc8388700 Mon Sep 17 00:00:00 2001 From: Eric Mehl Date: Fri, 24 Apr 2026 14:50:10 -0400 Subject: [PATCH 10/18] fixup! BreadCrumbsWidget : Widget with interactive path --- python/GafferUI/BreadCrumbsWidget.py | 12 +----------- python/GafferUI/Button.py | 2 ++ python/GafferUI/_StyleSheet.py | 14 +++++++++----- 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/python/GafferUI/BreadCrumbsWidget.py b/python/GafferUI/BreadCrumbsWidget.py index 1a637edcad1..a74e29f1605 100644 --- a/python/GafferUI/BreadCrumbsWidget.py +++ b/python/GafferUI/BreadCrumbsWidget.py @@ -114,12 +114,10 @@ def __pathWidgets( self, path ) : path[-1] if len( path ) > 0 else "", image = "home.png" if len( path ) == 0 else None, hasFrame = False, - highlightOnOver = False, + highlightOnOver = True, toolTip = "Click to set as current path." + ( "
Right-click for adjacent paths menu." if len( path ) > 0 else "" ) ) pathButton.buttonPressSignal().connect( functools.partial( Gaffer.WeakMethod( self.__pathButtonPress ), path ) ) - pathButton.enterSignal().connect( Gaffer.WeakMethod( self.__pathButtonEnter ) ) - pathButton.leaveSignal().connect( Gaffer.WeakMethod( self.__pathButtonLeave ) ) return ( pathButton, GafferUI.Label( "/" ) ) @@ -175,14 +173,6 @@ def __pathButtonPress( self, path, button, event ) : return False - def __pathButtonEnter( self, button ) : - - button.setHasFrame( True ) - - def __pathButtonLeave( self, button ) : - - button.setHasFrame( False ) - def __textChanged( self, textWidget ) : popupRequested = False diff --git a/python/GafferUI/Button.py b/python/GafferUI/Button.py index fa2d8c7a414..83add4dd716 100644 --- a/python/GafferUI/Button.py +++ b/python/GafferUI/Button.py @@ -180,9 +180,11 @@ def __updateIcon( self ) : def __enter( self, widget ) : self.__highlightForHover = True + self.setHighlighted( True ) self.__updateIcon() def __leave( self, widget ) : self.__highlightForHover = False + self.setHighlighted( False ) self.__updateIcon() diff --git a/python/GafferUI/_StyleSheet.py b/python/GafferUI/_StyleSheet.py index c3d6b633329..e8f00b56b4c 100644 --- a/python/GafferUI/_StyleSheet.py +++ b/python/GafferUI/_StyleSheet.py @@ -1777,7 +1777,7 @@ def styleColor( key ) : border: 1px solid $brightColor; } - [gafferClass="GafferUI.BreadCrumbsWidget"] { + QWidget[gafferClass="GafferUI.BreadCrumbsWidget"] { border: 1px solid transparent; border-bottom-color: $tintDarkerStronger; border-right-color: $tintDarkerStronger; @@ -1785,20 +1785,24 @@ def styleColor( key ) : border-radius: $widgetCornerRadius; } - [gafferClass="GafferUI.BreadCrumbsWidget"] QLineEdit { + QWidget[gafferClass="GafferUI.BreadCrumbsWidget"] QLineEdit { border: 0; border-radius: 0; background-color: transparent; } - [gafferClass="GafferUI.BreadCrumbsWidget"] QPushButton[gafferWithFrame="false"] { + QWidget[gafferClass="GafferUI.BreadCrumbsWidget"] QPushButton { margin: 1px; padding: 4px; } - [gafferClass="GafferUI.BreadCrumbsWidget"] QPushButton[gafferWithFrame="true"] { + QWidget[gafferClass="GafferUI.BreadCrumbsWidget"] QPushButton[gafferHighlighted="true"] { margin: 0px; - font-weight: normal; + border: 1px solid $backgroundDarkHighlight; + border-top-color: $backgroundLightHighlight; + border-left-color: $backgroundLightHighlight; + background-color : qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 $backgroundLightHighlight, stop: 0.1 $backgroundLight, stop: 0.90 $backgroundLightLowlight); + border-radius: 4px; } """ From bff245db62e5bc320f3ffa5019471cb96177fdb3 Mon Sep 17 00:00:00 2001 From: Eric Mehl Date: Fri, 24 Apr 2026 15:10:19 -0400 Subject: [PATCH 11/18] fixup! BreadCrumbsWidget : Widget with interactive path --- python/GafferUI/BreadCrumbsWidget.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/python/GafferUI/BreadCrumbsWidget.py b/python/GafferUI/BreadCrumbsWidget.py index a74e29f1605..fd79c3e159d 100644 --- a/python/GafferUI/BreadCrumbsWidget.py +++ b/python/GafferUI/BreadCrumbsWidget.py @@ -177,6 +177,8 @@ def __textChanged( self, textWidget ) : popupRequested = False if self.__popupMenu is not None and self.__popupMenu.visible() : + # Dispose of current menu safely. We can be called from the keypress + # forwarding code of the menu, so we can't destroy it immediately. GafferUI.WidgetAlgo.keepUntilIdle( self.__popupMenu ) self.__popupMenu = None popupRequested = True From 11c7a794c02670aaa71ca2b6a9f1b764b18c29e3 Mon Sep 17 00:00:00 2001 From: Eric Mehl Date: Fri, 24 Apr 2026 15:24:34 -0400 Subject: [PATCH 12/18] fixup! BreadCrumbsWidget : Widget with interactive path --- python/GafferUI/GraphEditor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/GafferUI/GraphEditor.py b/python/GafferUI/GraphEditor.py index 44e8a6c26b8..b4b85c732bb 100644 --- a/python/GafferUI/GraphEditor.py +++ b/python/GafferUI/GraphEditor.py @@ -57,7 +57,7 @@ def _filter( self, paths, canceller ) : result = [] for p in paths : n = self.__scriptRoot().descendant( str( p ).lstrip( "/" ).replace( "/", "." ) ) - if isinstance( n, Gaffer.Node ) and Gaffer.Metadata.value( n, "ui:childNodesAreViewable" ) or False : + if isinstance( n, Gaffer.Node ) and Gaffer.Metadata.value( n, "ui:childNodesAreViewable" ) : result.append( p ) return result From 5acc1855797655fd94fefccec4f043506b4c330f Mon Sep 17 00:00:00 2001 From: Eric Mehl Date: Fri, 24 Apr 2026 15:41:09 -0400 Subject: [PATCH 13/18] fixup! BreadCrumbsWidget : Widget with interactive path --- python/GafferUI/GraphEditor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/GafferUI/GraphEditor.py b/python/GafferUI/GraphEditor.py index b4b85c732bb..f3750a2e6d7 100644 --- a/python/GafferUI/GraphEditor.py +++ b/python/GafferUI/GraphEditor.py @@ -50,13 +50,13 @@ def __init__( self, scriptRoot, userData = {} ) : Gaffer.PathFilter.__init__( self, userData ) - self.__scriptRoot = weakref.ref( scriptRoot ) + self.__scriptRoot = scriptRoot def _filter( self, paths, canceller ) : result = [] for p in paths : - n = self.__scriptRoot().descendant( str( p ).lstrip( "/" ).replace( "/", "." ) ) + n = self.__scriptRoot.descendant( str( p ).lstrip( "/" ).replace( "/", "." ) ) if isinstance( n, Gaffer.Node ) and Gaffer.Metadata.value( n, "ui:childNodesAreViewable" ) : result.append( p ) From e9b38960a9052e9fde37fcba43344e2ee1345ad2 Mon Sep 17 00:00:00 2001 From: Eric Mehl Date: Fri, 24 Apr 2026 15:42:55 -0400 Subject: [PATCH 14/18] fixup! BreadCrumbsWidget : Widget with interactive path --- python/GafferUI/GraphEditor.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/python/GafferUI/GraphEditor.py b/python/GafferUI/GraphEditor.py index f3750a2e6d7..8489fa8e413 100644 --- a/python/GafferUI/GraphEditor.py +++ b/python/GafferUI/GraphEditor.py @@ -230,8 +230,7 @@ def __init__( self, scriptNode, **kw ) : self.__dragEnterPointer = None self.__gadgetWidget.getViewportGadget().preRenderSignal().connect( Gaffer.WeakMethod( self.__preRender ) ) - self.__viewableChildrenPathFilter = _ViewableChildrenPathFilter( self.scriptNode() ) - self.__rootPath = Gaffer.GraphComponentPath( self.scriptNode(), [], filter = self.__viewableChildrenPathFilter ) + self.__rootPath = Gaffer.GraphComponentPath( self.scriptNode(), [], filter = _ViewableChildrenPathFilter( self.scriptNode() ) ) self.__rootPathChangedConnection = self.__rootPath.pathChangedSignal().connect( Gaffer.WeakMethod( self.__rootPathChanged ), scoped = True ) with GafferUI.ListContainer( borderWidth = 8, spacing = 0 ) as overlay : From eae563be6723bc7fb70562728536146f202a9958 Mon Sep 17 00:00:00 2001 From: Eric Mehl Date: Fri, 24 Apr 2026 15:44:39 -0400 Subject: [PATCH 15/18] fixup! BreadCrumbsWidget : Widget with interactive path --- python/GafferUI/BreadCrumbsWidget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/GafferUI/BreadCrumbsWidget.py b/python/GafferUI/BreadCrumbsWidget.py index fd79c3e159d..d8220085cab 100644 --- a/python/GafferUI/BreadCrumbsWidget.py +++ b/python/GafferUI/BreadCrumbsWidget.py @@ -115,7 +115,7 @@ def __pathWidgets( self, path ) : image = "home.png" if len( path ) == 0 else None, hasFrame = False, highlightOnOver = True, - toolTip = "Click to set as current path." + ( "
Right-click for adjacent paths menu." if len( path ) > 0 else "" ) + toolTip = "Click to navigate here." + ( "
Right-click to choose a sibling." if len( path ) > 0 else "" ) ) pathButton.buttonPressSignal().connect( functools.partial( Gaffer.WeakMethod( self.__pathButtonPress ), path ) ) From 4576a73b0b571b7683b97b5830bf5dc38cff5807 Mon Sep 17 00:00:00 2001 From: Eric Mehl Date: Fri, 24 Apr 2026 15:48:28 -0400 Subject: [PATCH 16/18] fixup! GraphEditor : History forward and back buttons --- python/GafferUI/GraphEditor.py | 278 ++++++++++++++++----------------- 1 file changed, 139 insertions(+), 139 deletions(-) diff --git a/python/GafferUI/GraphEditor.py b/python/GafferUI/GraphEditor.py index 8489fa8e413..9975ccb2c35 100644 --- a/python/GafferUI/GraphEditor.py +++ b/python/GafferUI/GraphEditor.py @@ -64,144 +64,6 @@ def _filter( self, paths, canceller ) : class GraphEditor( GafferUI.Editor ) : - class __HistoryWidget( GafferUI.Widget ) : - - # \todo It would be nice to get the `GraphEditor` from the hierarchy. - # But the ancestors of this widget are two `ListContainer` objects (including - # in `_postConstructor()`). - # `Widget.parent()` should be taking care of crossing the QGraphicsProxy - # line. `self.ancestor( GafferUI.GraphEditor )` works in `__moveHistoryIndex` - # below. - def __init__( self, graphEditor, **kw ) : - - self.__row = GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) - - GafferUI.Widget.__init__( self, self.__row, **kw ) - - self.__graphEditor = weakref.ref( graphEditor ) - - assert( self.__graphEditor() is not None ) - self.__rootChangedConnection = self.__graphEditor().graphGadget().rootChangedSignal().connectFront( Gaffer.WeakMethod( self.__rootChanged ), scoped = True ) - self.__graphEditor().graphGadgetWidget().keyPressSignal().connect( Gaffer.WeakMethod( self.__keyPress ) ) - - self.__history = [ ( self.__graphEditor().scriptNode(), None ) ] - - with self.__row : - self.__backButton = GafferUI.Button( "", "historyBack.png", hasFrame = False, toolTip = "Step back in Graph Editor history. Right-click for past-nodes popup menu.[[]" ) - self.__forwardButton = GafferUI.Button( "", "historyForward.png", hasFrame = False, toolTip = "Step forward in Graph Editor history. Right-click for future-nodes popup menu.[]]" ) - self.__backButton.buttonPressSignal().connect( Gaffer.WeakMethod( self.__backButtonPressed ) ) - self.__forwardButton.buttonPressSignal().connect( Gaffer.WeakMethod( self.__forwardButtonPressed ) ) - - # A list of tuples of the form `( rootNode, frame )`. A frame value of `None` - # indicates framing to fit all child nodes. - self.__history = [ ( self.__graphEditor().scriptNode(), None ) ] - self.__historyIndex = 0 - self.__historyPopup = None - - self.__updateHistoryButtonsEnabled() - - - # Returns the frame to use for the given root node or `None` to indicate default framing. - def frame( self, rootNode ) : - - frame = None - for i in range( self.__historyIndex, -1, -1 ) : - historyNode, historyFrame = self.__history[i] - if historyNode.isSame( rootNode ) and historyFrame is not None : - frame = historyFrame - break - - return frame - - def __keyPress( self, widget, event ) : - - if event.key == "BracketLeft" and not event.modifiers : - self.__moveHistoryIndex( -1 ) - return True - elif event.key == "BracketRight" and not event.modifiers : - self.__moveHistoryIndex( 1 ) - return True - - def __backButtonPressed( self, button, event ) : - - if event.buttons == event.Buttons.Left : - self.__moveHistoryIndex( -1 ) - elif event.buttons == event.Buttons.Right : - self.__showHistoryPopup( range( self.__historyIndex - 1, -1, -1 ), button ) - - def __forwardButtonPressed( self, button, event ) : - - if event.buttons == event.Buttons.Left : - self.__moveHistoryIndex( 1 ) - elif event.buttons == event.Buttons.Right : - self.__showHistoryPopup( range( self.__historyIndex + 1, len( self.__history ), 1 ), button ) - - def __rootChanged( self, graphGadget, previousRoot ) : - - self.__history = self.__history[:self.__historyIndex + 1] - self.__history[self.__historyIndex] = ( self.__history[self.__historyIndex][0], self.__graphEditor()._GraphEditor__currentFrame() ) - - self.__history.append( ( graphGadget.getRoot(), None ) ) - self.__historyIndex += 1 - self.__updateHistoryButtonsEnabled() - - def __moveHistoryIndex( self, delta ) : - - if self.__historyIndex + delta < 0 or self.__historyIndex + delta >= len( self.__history ) : - return - - self.__history[self.__historyIndex] = ( self.__history[self.__historyIndex][0], self.__graphEditor()._GraphEditor__currentFrame() ) - - self.__historyIndex += delta - - # \todo Where do we go if the node has been deleted? - graphEditor = self.ancestor( GafferUI.GraphEditor ) - assert( graphEditor is not None ) - rootNode = self.__history[self.__historyIndex][0] - if rootNode.isSame( graphEditor.scriptNode() ) or graphEditor.scriptNode().isAncestorOf( rootNode ) : - with Gaffer.Signals.BlockedConnection( self.__rootChangedConnection ) : - if rootNode.isSame( graphEditor.graphGadget().getRoot() ) : - # `GraphGadget` won't emit `rootChangedSignal()` because the roots are the same. - # Framing might be different, so we do it ourselves. - frame = self.frame( rootNode ) - if frame is not None : - graphEditor.graphGadgetWidget().getViewportGadget().frame( - imath.Box3f( imath.V3f( frame.min().x, frame.min().y, 0 ), imath.V3f( frame.max().x, frame.max().y, 0 ) ) - ) - else : - self.__frame( graphEditor.graphGadget().getRoot().children( Gaffer.Node ) ) - graphEditor.graphGadget().setRoot( rootNode ) - - - - self.__updateHistoryButtonsEnabled() - - def __showHistoryPopup(self, historyRange, popupParent ) : - - graphEditor = self.ancestor( GafferUI.GraphEditor ) - menuDefinition = IECore.MenuDefinition() - prefix = "/" - for counter, i in enumerate( historyRange ) : - menuDefinition.append( - prefix + str( counter ), - { - "label" : self.__history[i][0].relativeName( graphEditor.scriptNode() ) if not self.__history[i][0].isSame( graphEditor.scriptNode() ) else "Script Root", - "command" : functools.partial( Gaffer.WeakMethod( self.__moveHistoryIndex ), i - self.__historyIndex ), - } - ) - - if counter == 10 : - prefix = "/More/" - menuDefinition.append( "/Divider", { "divider" : True } ) - - self.__historyPopup = GafferUI.Menu( menuDefinition ) - self.__historyPopup.popup( popupParent, position = imath.V2i( popupParent.bound().min().x, popupParent.bound().max().y ) ) - - def __updateHistoryButtonsEnabled( self ) : - - self.__backButton.setEnabled( self.__historyIndex > 0 ) - self.__forwardButton.setEnabled( self.__historyIndex < ( len( self.__history ) - 1 ) ) - def __init__( self, scriptNode, **kw ) : # We want to disable precise navigation motions as they interfere @@ -235,7 +97,7 @@ def __init__( self, scriptNode, **kw ) : with GafferUI.ListContainer( borderWidth = 8, spacing = 0 ) as overlay : with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) : - self.__historyWidget = self.__HistoryWidget( self ) + self.__historyWidget = _HistoryWidget( self ) GafferUI.BreadCrumbsWidget( self.__rootPath, "Node Path" ) GafferUI.Spacer( imath.V2i( 1 ) ) @@ -999,3 +861,141 @@ def __enabledPlugForEditing( node ) : return enabledPlug GafferUI.Editor.registerType( "GraphEditor", GraphEditor ) + +class _HistoryWidget( GafferUI.Widget ) : + + # \todo It would be nice to get the `GraphEditor` from the hierarchy. + # But the ancestors of this widget are two `ListContainer` objects (including + # in `_postConstructor()`). + # `Widget.parent()` should be taking care of crossing the QGraphicsProxy + # line. `self.ancestor( GafferUI.GraphEditor )` works in `__moveHistoryIndex` + # below. + def __init__( self, graphEditor, **kw ) : + + self.__row = GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) + + GafferUI.Widget.__init__( self, self.__row, **kw ) + + self.__graphEditor = weakref.ref( graphEditor ) + + assert( self.__graphEditor() is not None ) + self.__rootChangedConnection = self.__graphEditor().graphGadget().rootChangedSignal().connectFront( Gaffer.WeakMethod( self.__rootChanged ), scoped = True ) + self.__graphEditor().graphGadgetWidget().keyPressSignal().connect( Gaffer.WeakMethod( self.__keyPress ) ) + + self.__history = [ ( self.__graphEditor().scriptNode(), None ) ] + + with self.__row : + self.__backButton = GafferUI.Button( "", "historyBack.png", hasFrame = False, toolTip = "Step back in Graph Editor history. Right-click for past-nodes popup menu.[[]" ) + self.__forwardButton = GafferUI.Button( "", "historyForward.png", hasFrame = False, toolTip = "Step forward in Graph Editor history. Right-click for future-nodes popup menu.[]]" ) + self.__backButton.buttonPressSignal().connect( Gaffer.WeakMethod( self.__backButtonPressed ) ) + self.__forwardButton.buttonPressSignal().connect( Gaffer.WeakMethod( self.__forwardButtonPressed ) ) + + # A list of tuples of the form `( rootNode, frame )`. A frame value of `None` + # indicates framing to fit all child nodes. + self.__history = [ ( self.__graphEditor().scriptNode(), None ) ] + self.__historyIndex = 0 + self.__historyPopup = None + + self.__updateHistoryButtonsEnabled() + + + # Returns the frame to use for the given root node or `None` to indicate default framing. + def frame( self, rootNode ) : + + frame = None + for i in range( self.__historyIndex, -1, -1 ) : + historyNode, historyFrame = self.__history[i] + if historyNode.isSame( rootNode ) and historyFrame is not None : + frame = historyFrame + break + + return frame + + def __keyPress( self, widget, event ) : + + if event.key == "BracketLeft" and not event.modifiers : + self.__moveHistoryIndex( -1 ) + return True + elif event.key == "BracketRight" and not event.modifiers : + self.__moveHistoryIndex( 1 ) + return True + + def __backButtonPressed( self, button, event ) : + + if event.buttons == event.Buttons.Left : + self.__moveHistoryIndex( -1 ) + elif event.buttons == event.Buttons.Right : + self.__showHistoryPopup( range( self.__historyIndex - 1, -1, -1 ), button ) + + def __forwardButtonPressed( self, button, event ) : + + if event.buttons == event.Buttons.Left : + self.__moveHistoryIndex( 1 ) + elif event.buttons == event.Buttons.Right : + self.__showHistoryPopup( range( self.__historyIndex + 1, len( self.__history ), 1 ), button ) + + def __rootChanged( self, graphGadget, previousRoot ) : + + self.__history = self.__history[:self.__historyIndex + 1] + self.__history[self.__historyIndex] = ( self.__history[self.__historyIndex][0], self.__graphEditor()._GraphEditor__currentFrame() ) + + self.__history.append( ( graphGadget.getRoot(), None ) ) + self.__historyIndex += 1 + self.__updateHistoryButtonsEnabled() + + def __moveHistoryIndex( self, delta ) : + + if self.__historyIndex + delta < 0 or self.__historyIndex + delta >= len( self.__history ) : + return + + self.__history[self.__historyIndex] = ( self.__history[self.__historyIndex][0], self.__graphEditor()._GraphEditor__currentFrame() ) + + self.__historyIndex += delta + + # \todo Where do we go if the node has been deleted? + graphEditor = self.ancestor( GafferUI.GraphEditor ) + assert( graphEditor is not None ) + rootNode = self.__history[self.__historyIndex][0] + if rootNode.isSame( graphEditor.scriptNode() ) or graphEditor.scriptNode().isAncestorOf( rootNode ) : + with Gaffer.Signals.BlockedConnection( self.__rootChangedConnection ) : + if rootNode.isSame( graphEditor.graphGadget().getRoot() ) : + # `GraphGadget` won't emit `rootChangedSignal()` because the roots are the same. + # Framing might be different, so we do it ourselves. + frame = self.frame( rootNode ) + if frame is not None : + graphEditor.graphGadgetWidget().getViewportGadget().frame( + imath.Box3f( imath.V3f( frame.min().x, frame.min().y, 0 ), imath.V3f( frame.max().x, frame.max().y, 0 ) ) + ) + else : + self.__frame( graphEditor.graphGadget().getRoot().children( Gaffer.Node ) ) + graphEditor.graphGadget().setRoot( rootNode ) + + + + self.__updateHistoryButtonsEnabled() + + def __showHistoryPopup(self, historyRange, popupParent ) : + + graphEditor = self.ancestor( GafferUI.GraphEditor ) + menuDefinition = IECore.MenuDefinition() + prefix = "/" + for counter, i in enumerate( historyRange ) : + menuDefinition.append( + prefix + str( counter ), + { + "label" : self.__history[i][0].relativeName( graphEditor.scriptNode() ) if not self.__history[i][0].isSame( graphEditor.scriptNode() ) else "Script Root", + "command" : functools.partial( Gaffer.WeakMethod( self.__moveHistoryIndex ), i - self.__historyIndex ), + } + ) + + if counter == 10 : + prefix = "/More/" + menuDefinition.append( "/Divider", { "divider" : True } ) + + self.__historyPopup = GafferUI.Menu( menuDefinition ) + self.__historyPopup.popup( popupParent, position = imath.V2i( popupParent.bound().min().x, popupParent.bound().max().y ) ) + + def __updateHistoryButtonsEnabled( self ) : + + self.__backButton.setEnabled( self.__historyIndex > 0 ) + self.__forwardButton.setEnabled( self.__historyIndex < ( len( self.__history ) - 1 ) ) From 7ab46dd5e87115b89342e2519804a7ea970628f6 Mon Sep 17 00:00:00 2001 From: Eric Mehl Date: Fri, 24 Apr 2026 15:49:50 -0400 Subject: [PATCH 17/18] fixup! GraphEditor : History forward and back buttons --- python/GafferUI/GraphEditor.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/python/GafferUI/GraphEditor.py b/python/GafferUI/GraphEditor.py index 9975ccb2c35..89aba4c7070 100644 --- a/python/GafferUI/GraphEditor.py +++ b/python/GafferUI/GraphEditor.py @@ -882,8 +882,6 @@ def __init__( self, graphEditor, **kw ) : self.__rootChangedConnection = self.__graphEditor().graphGadget().rootChangedSignal().connectFront( Gaffer.WeakMethod( self.__rootChanged ), scoped = True ) self.__graphEditor().graphGadgetWidget().keyPressSignal().connect( Gaffer.WeakMethod( self.__keyPress ) ) - self.__history = [ ( self.__graphEditor().scriptNode(), None ) ] - with self.__row : self.__backButton = GafferUI.Button( "", "historyBack.png", hasFrame = False, toolTip = "Step back in Graph Editor history. Right-click for past-nodes popup menu.[[]" ) self.__forwardButton = GafferUI.Button( "", "historyForward.png", hasFrame = False, toolTip = "Step forward in Graph Editor history. Right-click for future-nodes popup menu.[]]" ) From f983563742e120d6dda1e501514f401d09f7cebe Mon Sep 17 00:00:00 2001 From: Eric Mehl Date: Fri, 24 Apr 2026 16:31:59 -0400 Subject: [PATCH 18/18] fixup! GraphEditor : History forward and back buttons --- python/GafferUI/GraphEditor.py | 116 +++++++++++++++------------------ 1 file changed, 51 insertions(+), 65 deletions(-) diff --git a/python/GafferUI/GraphEditor.py b/python/GafferUI/GraphEditor.py index 89aba4c7070..86993dff49a 100644 --- a/python/GafferUI/GraphEditor.py +++ b/python/GafferUI/GraphEditor.py @@ -37,7 +37,6 @@ import functools import imath -import weakref import IECore @@ -62,6 +61,17 @@ def _filter( self, paths, canceller ) : return result +def _currentFrame( viewportGadget ) : + + rasterMin = viewportGadget.rasterToWorldSpace( imath.V2f( 0 ) ).p0 + rasterMax = viewportGadget.rasterToWorldSpace( imath.V2f( viewportGadget.getViewport() ) ).p0 + + frame = imath.Box2f() + frame.extendBy( imath.V2f( rasterMin[0], rasterMin[1] ) ) + frame.extendBy( imath.V2f( rasterMax[0], rasterMax[1] ) ) + + return frame + class GraphEditor( GafferUI.Editor ) : def __init__( self, scriptNode, **kw ) : @@ -97,7 +107,7 @@ def __init__( self, scriptNode, **kw ) : with GafferUI.ListContainer( borderWidth = 8, spacing = 0 ) as overlay : with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) : - self.__historyWidget = _HistoryWidget( self ) + self.__historyWidget = _HistoryWidget( self.graphGadget(), self.scriptNode() ) GafferUI.BreadCrumbsWidget( self.__rootPath, "Node Path" ) GafferUI.Spacer( imath.V2i( 1 ) ) @@ -481,6 +491,12 @@ def __keyPress( self, widget, event ) : enabledPlug.setValue( not enabled ) return True + elif event.key == "BracketLeft" and not event.modifiers : + self.__historyWidget.moveHistoryIndex( -1 ) + return True + elif event.key == "BracketRight" and not event.modifiers : + self.__historyWidget.moveHistoryIndex( 1 ) + return True return False @@ -513,7 +529,7 @@ def __frame( self, nodes, extend = False, at = None ) : # we're extending the existing framing, which we assume the # user was happy with other than it not showing the nodes in question. # so we just take the union of the existing frame and the one for the nodes. - cb = self.__currentFrame() + cb = _currentFrame( self.graphGadgetWidget().getViewportGadget() ) bound.extendBy( imath.Box3f( imath.V3f( cb.min().x, cb.min().y, 0 ), imath.V3f( cb.max().x, cb.max().y, 0 ) ) ) else : # we're reframing from scratch, so the frame for the nodes is all we need. @@ -599,18 +615,6 @@ def __dropNodes( self, dragData ) : return [] - def __currentFrame( self ) : - viewportGadget = self.graphGadgetWidget().getViewportGadget() - - rasterMin = viewportGadget.rasterToWorldSpace( imath.V2f( 0 ) ).p0 - rasterMax = viewportGadget.rasterToWorldSpace( imath.V2f( viewportGadget.getViewport() ) ).p0 - - frame = imath.Box2f() - frame.extendBy( imath.V2f( rasterMin[0], rasterMin[1] ) ) - frame.extendBy( imath.V2f( rasterMax[0], rasterMax[1] ) ) - - return frame - def __rootChanged( self, graphGadget, previousRoot ) : frame = self.__historyWidget.frame( graphGadget.getRoot() ) @@ -864,23 +868,16 @@ def __enabledPlugForEditing( node ) : class _HistoryWidget( GafferUI.Widget ) : - # \todo It would be nice to get the `GraphEditor` from the hierarchy. - # But the ancestors of this widget are two `ListContainer` objects (including - # in `_postConstructor()`). - # `Widget.parent()` should be taking care of crossing the QGraphicsProxy - # line. `self.ancestor( GafferUI.GraphEditor )` works in `__moveHistoryIndex` - # below. - def __init__( self, graphEditor, **kw ) : + def __init__( self, graphGadget, scriptNode, **kw ) : self.__row = GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) GafferUI.Widget.__init__( self, self.__row, **kw ) - self.__graphEditor = weakref.ref( graphEditor ) + self.__graphGadget = graphGadget + self.__scriptNode = scriptNode - assert( self.__graphEditor() is not None ) - self.__rootChangedConnection = self.__graphEditor().graphGadget().rootChangedSignal().connectFront( Gaffer.WeakMethod( self.__rootChanged ), scoped = True ) - self.__graphEditor().graphGadgetWidget().keyPressSignal().connect( Gaffer.WeakMethod( self.__keyPress ) ) + self.__rootChangedConnection = self.__graphGadget.rootChangedSignal().connectFront( Gaffer.WeakMethod( self.__rootChanged ), scoped = True ) with self.__row : self.__backButton = GafferUI.Button( "", "historyBack.png", hasFrame = False, toolTip = "Step back in Graph Editor history. Right-click for past-nodes popup menu.[[]" ) @@ -890,7 +887,7 @@ def __init__( self, graphEditor, **kw ) : # A list of tuples of the form `( rootNode, frame )`. A frame value of `None` # indicates framing to fit all child nodes. - self.__history = [ ( self.__graphEditor().scriptNode(), None ) ] + self.__history = [ ( self.__scriptNode, None ) ] self.__historyIndex = 0 self.__historyPopup = None @@ -909,44 +906,12 @@ def frame( self, rootNode ) : return frame - def __keyPress( self, widget, event ) : - - if event.key == "BracketLeft" and not event.modifiers : - self.__moveHistoryIndex( -1 ) - return True - elif event.key == "BracketRight" and not event.modifiers : - self.__moveHistoryIndex( 1 ) - return True - - def __backButtonPressed( self, button, event ) : - - if event.buttons == event.Buttons.Left : - self.__moveHistoryIndex( -1 ) - elif event.buttons == event.Buttons.Right : - self.__showHistoryPopup( range( self.__historyIndex - 1, -1, -1 ), button ) - - def __forwardButtonPressed( self, button, event ) : - - if event.buttons == event.Buttons.Left : - self.__moveHistoryIndex( 1 ) - elif event.buttons == event.Buttons.Right : - self.__showHistoryPopup( range( self.__historyIndex + 1, len( self.__history ), 1 ), button ) - - def __rootChanged( self, graphGadget, previousRoot ) : - - self.__history = self.__history[:self.__historyIndex + 1] - self.__history[self.__historyIndex] = ( self.__history[self.__historyIndex][0], self.__graphEditor()._GraphEditor__currentFrame() ) - - self.__history.append( ( graphGadget.getRoot(), None ) ) - self.__historyIndex += 1 - self.__updateHistoryButtonsEnabled() - - def __moveHistoryIndex( self, delta ) : + def moveHistoryIndex( self, delta ) : if self.__historyIndex + delta < 0 or self.__historyIndex + delta >= len( self.__history ) : return - self.__history[self.__historyIndex] = ( self.__history[self.__historyIndex][0], self.__graphEditor()._GraphEditor__currentFrame() ) + self.__history[self.__historyIndex] = ( self.__history[self.__historyIndex][0], _currentFrame( self.__graphGadget.parent() ) ) self.__historyIndex += delta @@ -954,7 +919,7 @@ def __moveHistoryIndex( self, delta ) : graphEditor = self.ancestor( GafferUI.GraphEditor ) assert( graphEditor is not None ) rootNode = self.__history[self.__historyIndex][0] - if rootNode.isSame( graphEditor.scriptNode() ) or graphEditor.scriptNode().isAncestorOf( rootNode ) : + if rootNode.isSame( self.__scriptNode ) or self.__scriptNode.isAncestorOf( rootNode ) : with Gaffer.Signals.BlockedConnection( self.__rootChangedConnection ) : if rootNode.isSame( graphEditor.graphGadget().getRoot() ) : # `GraphGadget` won't emit `rootChangedSignal()` because the roots are the same. @@ -966,10 +931,31 @@ def __moveHistoryIndex( self, delta ) : ) else : self.__frame( graphEditor.graphGadget().getRoot().children( Gaffer.Node ) ) - graphEditor.graphGadget().setRoot( rootNode ) + self.__graphGadget.setRoot( rootNode ) + + self.__updateHistoryButtonsEnabled() + + def __backButtonPressed( self, button, event ) : + if event.buttons == event.Buttons.Left : + self.moveHistoryIndex( -1 ) + elif event.buttons == event.Buttons.Right : + self.__showHistoryPopup( range( self.__historyIndex - 1, -1, -1 ), button ) + + def __forwardButtonPressed( self, button, event ) : + + if event.buttons == event.Buttons.Left : + self.moveHistoryIndex( 1 ) + elif event.buttons == event.Buttons.Right : + self.__showHistoryPopup( range( self.__historyIndex + 1, len( self.__history ), 1 ), button ) + def __rootChanged( self, graphGadget, previousRoot ) : + self.__history = self.__history[:self.__historyIndex + 1] + self.__history[self.__historyIndex] = ( self.__history[self.__historyIndex][0], _currentFrame( self.__graphGadget.parent() ) ) + + self.__history.append( ( graphGadget.getRoot(), None ) ) + self.__historyIndex += 1 self.__updateHistoryButtonsEnabled() def __showHistoryPopup(self, historyRange, popupParent ) : @@ -981,8 +967,8 @@ def __showHistoryPopup(self, historyRange, popupParent ) : menuDefinition.append( prefix + str( counter ), { - "label" : self.__history[i][0].relativeName( graphEditor.scriptNode() ) if not self.__history[i][0].isSame( graphEditor.scriptNode() ) else "Script Root", - "command" : functools.partial( Gaffer.WeakMethod( self.__moveHistoryIndex ), i - self.__historyIndex ), + "label" : self.__history[i][0].relativeName( self.__scriptNode ) if not self.__history[i][0].isSame( self.__scriptNode ) else "Script Root", + "command" : functools.partial( Gaffer.WeakMethod( self.moveHistoryIndex ), i - self.__historyIndex ), } )