Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6465633
GadgetWidget : Don't lose item focus on leave
ericmehl Apr 15, 2026
66b4ff9
BreadCrumbsWidget : Widget with interactive path
ericmehl Apr 2, 2026
4841b52
GadgetWidget : `mouseMove` to overlay first
ericmehl Apr 17, 2026
ad83ebb
GraphEditor : History forward and back buttons
ericmehl Apr 20, 2026
9ab1fcc
fixup! BreadCrumbsWidget : Widget with interactive path
ericmehl Apr 23, 2026
e7a929c
fixup! BreadCrumbsWidget : Widget with interactive path
ericmehl Apr 24, 2026
a3a5af9
fixup! BreadCrumbsWidget : Widget with interactive path
ericmehl Apr 24, 2026
2d8aec0
fixup! BreadCrumbsWidget : Widget with interactive path
ericmehl Apr 24, 2026
7010f39
fixup! BreadCrumbsWidget : Widget with interactive path
ericmehl Apr 24, 2026
4858df0
fixup! BreadCrumbsWidget : Widget with interactive path
ericmehl Apr 24, 2026
bff245d
fixup! BreadCrumbsWidget : Widget with interactive path
ericmehl Apr 24, 2026
11c7a79
fixup! BreadCrumbsWidget : Widget with interactive path
ericmehl Apr 24, 2026
5acc185
fixup! BreadCrumbsWidget : Widget with interactive path
ericmehl Apr 24, 2026
e9b3896
fixup! BreadCrumbsWidget : Widget with interactive path
ericmehl Apr 24, 2026
eae563b
fixup! BreadCrumbsWidget : Widget with interactive path
ericmehl Apr 24, 2026
4576a73
fixup! GraphEditor : History forward and back buttons
ericmehl Apr 24, 2026
7ab46dd
fixup! GraphEditor : History forward and back buttons
ericmehl Apr 24, 2026
f983563
fixup! GraphEditor : History forward and back buttons
ericmehl Apr 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if "control of the Graph Editor's root" means as much to the average user as "navigation through Boxes and References"?

- Added forward and back buttons to step through the history of the editor's root nodes.

Fixes
-----
Expand All @@ -25,6 +28,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)
========

Expand Down
4 changes: 4 additions & 0 deletions python/Gaffer/GraphComponentPath.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 :
Expand Down
358 changes: 358 additions & 0 deletions python/GafferUI/BreadCrumbsWidget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,358 @@
##########################################################################
#
# 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 )

with self.__row :
self.__pathButtonContainer = GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 )

self.__textWidget = GafferUI.TextWidget( toolTip =
"## Actions\n\n"
"- Right-click for contents menu.\n"
"- <kbd>Down</kbd> to show children.\n"
"- <kbd>Up</kbd> to go to parent.\n"
"- <kbd>Tab</kbd> for auto-complete.\n"
"- <kbd>Home</kbd> 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.setPath( path )

def setPath( self, path ) :

self.__path = path
self.__pathChangedConnection = self.__path.pathChangedSignal().connect( Gaffer.WeakMethod( self.__pathChanged ), scoped = True )
self.__updateWidgets()

def getPath( self ) :

return self.__path

def __updateWidgets( self ) :

del self.__pathButtonContainer[:]

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 = True,
toolTip = "Click to navigate here." + ( "<br>Right-click to choose a sibling." if len( path ) > 0 else "" )
)
pathButton.buttonPressSignal().connect( functools.partial( Gaffer.WeakMethod( self.__pathButtonPress ), path ) )

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 )
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original plan was for this to be a widget that worked purely on Path objects, and wouldn't know anything about the GraphEditor. We should either keep to that, or move it into GraphEditor.py as a private class.


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 __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 )
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be good to document the need for this (or avoid it).

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 ),
}
)
Comment on lines +313 to +318
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image

From a user's perspective I find it a bit odd seeing "Assets" as a choice in the menu above when it appears to do nothing. Maybe we should disable entries that are ancestors of the current path, or only show their siblings?


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 )
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find the "Node Path" menu title a bit unnecessary in the Tab completions menu, and distracting when there are only a few completions. Do we need it at all?

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()`?
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That does seem pretty logical to me.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now keyboardMode of the QT widget is determined by a boolean parameter to popup() named grabFocus. Should we remove grabFocus and add keyboardMode? I assume that would be a 1.7 API change?

It looks like we only use that in PathWidget. So there's only one place for us to change it.


## \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()
Loading
Loading