Skip to content

Commit

Permalink
Merge pull request #5901 from johnhaddon/annotationSubstitutions
Browse files Browse the repository at this point in the history
AnnotationsGadget : Add `{plug}` syntax for substituting node values
  • Loading branch information
johnhaddon authored Jun 20, 2024
2 parents 71c670f + 854415c commit ed515cd
Show file tree
Hide file tree
Showing 8 changed files with 1,048 additions and 44 deletions.
9 changes: 9 additions & 0 deletions Changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,22 @@ Improvements

- ColorChooser : Added channel names to identify sliders.
- RenderPassEditor : Added "Select Affected Objects" popup menu item.
- Annotations :
- Added support for `{plug}` value substitutions in node annotations.
- Added <kbd>Ctrl</kbd> + <kbd>Enter</kbd> keyboard shortcut to annotation dialogue. This applies the annotation and closes the dialogue.

Fixes
-----

- Cycles : Fixed rendering to the Catalogue using the batch Render node (#5905). Note that rendering a mixture of Catalogue and file outputs is still not supported, and in this case any file outputs will be ignored.
- CodeWidget : Fixed bug that could prevent changes from being committed while the completion menu was visible.

API
---

- AnnotationsGadget : Added `annotationText()` method.
- ParallelAlgoTest : Added `UIThreadCallHandler.receive()` method.

1.4.7.0 (relative to 1.4.6.0)
=======

Expand Down
75 changes: 68 additions & 7 deletions include/GafferUI/AnnotationsGadget.h
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
#pragma once

#include "Gaffer/MetadataAlgo.h"
#include "Gaffer/ParallelAlgo.h"

#include "GafferUI/Gadget.h"

Expand Down Expand Up @@ -74,6 +75,10 @@ class GAFFERUI_API AnnotationsGadget : public Gadget
void setVisibleAnnotations( const IECore::StringAlgo::MatchPattern &patterns );
const IECore::StringAlgo::MatchPattern &getVisibleAnnotations() const;

/// Returns the text currently being rendered for the specified
/// annotation. Only really intended for use in the unit tests.
const std::string &annotationText( const Gaffer::Node *node, IECore::InternedString annotation = "user" ) const;

bool acceptsParent( const GraphComponent *potentialParent ) const override;

protected :
Expand All @@ -90,29 +95,85 @@ class GAFFERUI_API AnnotationsGadget : public Gadget

private :

GraphGadget *graphGadget();
const GraphGadget *graphGadget() const;

struct Annotations;

// Update process
// ==============
//
// We query annotation metadata and store it ready for rendering in our
// `m_annotations` data structure. This occurs in synchronous, lazy and
// asynchronous phases as performance requirements dictate.
//
// In the first phase, these two methods ensure that `m_annotations`
// always has an entry for each NodeGadget being drawn by the
// GraphGadget. This is done synchronously with the addition and removal
// of children.
void graphGadgetChildAdded( GraphComponent *child );
void graphGadgetChildRemoved( const GraphComponent *child );
// These accessors can then be used to find the annotations (if any)
// for a node.
Annotations *annotations( const Gaffer::Node *node );
const Annotations *annotations( const Gaffer::Node *node ) const;
// We then use `nodeMetadataChanged()` to dirty individual annotations
// when the metadata has changed. We don't query the metadata at this
// point, as it's fairly typical to receive many metadata edits at once
// and we want to batch the updates. We might not even be visible when
// the edits are made.
void nodeMetadataChanged( IECore::TypeId nodeTypeId, IECore::InternedString key, Gaffer::Node *node );
void update() const;
// We lazily call `update()` from `renderLayer()` to query all dirty
// metadata just in time for rendering. Such update are fairly
// infrequent because annotations are edited infrequently.
void update();
// Some annotations use `{}` syntax to substitute in the values of
// plugs. For these we use `plugDirtied()` to check if the substitutions
// are affected and dirty them when necessary. Plugs are dirtied
// frequently and many don't affect the substitutions at all, so this is
// performed at a finer level of granularity than `update()`.
void plugDirtied( const Gaffer::Plug *plug, Annotations *annotations );
// If the substitutions are from computed plugs, then we also need to
// update when the context changes.
void scriptContextChanged();
// Some plug substitutions may depend on computes, in which case we must
// perform the substitutions in a BackgroundTask to avoid blocking the
// UI. This function schedules such a task, or if the values are not
// computes, does the substitutions directly on the UI thread. This is
// done on a per-node basis, so that slow updates for one node do not
// prevent other nodes updating rapidly.
void schedulePlugValueSubstitutions( const Gaffer::Node *node, Annotations *annotations );
// These two functions do the actual work of calculating and applying
// substitutions.
std::unordered_map<IECore::InternedString, std::string> substitutedRenderText( const Gaffer::Node *node, const Annotations &annotations );
void applySubstitutedRenderText( const std::unordered_map<IECore::InternedString, std::string> &renderText, Annotations &annotations );
// When we are hidden, we want to cancel all background tasks.
void visibilityChanged();

struct StandardAnnotation : public Gaffer::MetadataAlgo::Annotation
{
StandardAnnotation( const Gaffer::MetadataAlgo::Annotation &a, IECore::InternedString name ) : Annotation( a ), name( name ) {}
IECore::InternedString name;
std::string renderText;
};

struct Annotations
{
bool dirty = true;
std::vector<Gaffer::MetadataAlgo::Annotation> standardAnnotations;
std::vector<StandardAnnotation> standardAnnotations;
bool bookmarked = false;
IECore::InternedString numericBookmark;
bool renderable = false;
bool hasPlugValueSubstitutions = false;
bool hasContextSensitiveSubstitutions = false;
Gaffer::Signals::ScopedConnection plugDirtiedConnection;
std::unique_ptr<Gaffer::BackgroundTask> substitutionsTask;
};

Gaffer::Signals::ScopedConnection m_graphGadgetChildAddedConnection;
Gaffer::Signals::ScopedConnection m_graphGadgetChildRemovedConnection;
Gaffer::Signals::ScopedConnection m_scriptContextChangedConnection;

using AnnotationsContainer = std::unordered_map<const NodeGadget *, Annotations>;
mutable AnnotationsContainer m_annotations;
mutable bool m_dirty;
AnnotationsContainer m_annotations;
bool m_dirty;

IECore::StringAlgo::MatchPattern m_visibleAnnotations;

Expand Down
16 changes: 11 additions & 5 deletions python/GafferTest/ParallelAlgoTest.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,22 @@ def __callOnUIThread( self, f ) :

self.__queue.put( f )

# Waits for a single use of `callOnUIThread()`, raising
# a test failure if none arises before `timeout` seconds.
def assertCalled( self, timeout = 30.0 ) :
# Waits for a single use of `callOnUIThread()` and returns the functor
# that was passed. It is the caller's responsibility to call the
# functor. Raises a test failure if no call arises before `timeout`
# seconds.
def receive( self, timeout = 30.0 ) :

try :
f = self.__queue.get( block = True, timeout = timeout )
return self.__queue.get( block = True, timeout = timeout )
except queue.Empty :
raise AssertionError( "UIThread call not made within {} seconds".format( timeout ) )

f()
# Waits for and handles a single use of `callOnUIThread()`, raising a
# test failure if none arises before `timeout` seconds.
def assertCalled( self, timeout = 30.0 ) :

self.receive( timeout )()

# Asserts that no further uses of `callOnUIThread()` will
# be made with this handler. This is checked on context exit.
Expand Down
121 changes: 119 additions & 2 deletions python/GafferUI/AnnotationsUI.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
##########################################################################

import functools
import re
import imath

import IECore
Expand Down Expand Up @@ -71,6 +72,72 @@ def __annotate( node, name, menu ) :
dialogue = __AnnotationsDialogue( node, name )
dialogue.wait( parentWindow = menu.ancestor( GafferUI.Window ) )

class _AnnotationsHighlighter( GafferUI.CodeWidget.Highlighter ) :

__substitutionRe = re.compile( r"(\{[^}]+\})" )

def __init__( self, node ) :

GafferUI.CodeWidget.Highlighter.__init__( self )
self.__node = node

def highlights( self, line, previousHighlightType ) :

result = []

l = 0
for token in self.__substitutionRe.split( line ) :
if (
len( token ) > 2 and
token[0] == "{" and token[-1] == "}" and
isinstance( self.__node.descendant( token[1:-1] ), Gaffer.ValuePlug )
) :
result.append(
self.Highlight( l, l + len( token ), self.Type.Keyword )
)
l += len( token )

return result

class _AnnotationsCompleter( GafferUI.CodeWidget.Completer ) :

__incompleteSubstitutionRe = re.compile( r"\{([^.}][^}]*$)" )

def __init__( self, node ) :

GafferUI.CodeWidget.Completer.__init__( self )
self.__node = node

def completions( self, text ) :

m = self.__incompleteSubstitutionRe.search( text )
if m is None :
return []

parentPath, _, childName = m.group( 1 ).rpartition( "." )
parent = self.__node.descendant( parentPath ) if parentPath else self.__node
if parent is None :
return []

result = []
for plug in Gaffer.Plug.Range( parent ) :
if not hasattr( plug, "getValue" ) and not len( plug ) :
continue
if plug.getName().startswith( childName ) :
childPath = plug.relativeName( self.__node )
result.append(
self.Completion(
"{prefix}{{{childPath}{closingBrace}".format(
prefix = text[:m.start()],
childPath = childPath,
closingBrace = "}" if hasattr( plug, "getValue" ) else ""
),
label = childPath
)
)

return result

class __AnnotationsDialogue( GafferUI.Dialogue ) :

def __init__( self, node, name ) :
Expand All @@ -85,13 +152,21 @@ def __init__( self, node, name ) :

with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Vertical, spacing = 4 ) as layout :

self.__textWidget = GafferUI.MultiLineTextWidget(
self.__textWidget = GafferUI.CodeWidget(
text = annotation.text() if annotation else "",
placeholderText = "Tip : Use {plugName} to include plug values",
)
self.__textWidget.setHighlighter( _AnnotationsHighlighter( node ) )
self.__textWidget.setCompleter( _AnnotationsCompleter( node ) )
self.__textWidget.textChangedSignal().connect(
Gaffer.WeakMethod( self.__updateButtonStatus ), scoped = False
)

self.__textWidget.activatedSignal().connect(
Gaffer.WeakMethod( self.__textActivated ), scoped = False
)
self.__textWidget.contextMenuSignal().connect(
Gaffer.WeakMethod( self.__textWidgetContextMenu ), scoped = False
)
if not template :
self.__colorChooser = GafferUI.ColorChooser(
annotation.color() if annotation else imath.Color3f( 0.15, 0.26, 0.26 ),
Expand Down Expand Up @@ -147,3 +222,45 @@ def __makeAnnotation( self ) :
)
else :
return Gaffer.MetadataAlgo.Annotation( self.__textWidget.getText() )

def __textActivated( self, *unused ) :

if self.__annotateButton.getEnabled() :
self.__annotateButton.clickedSignal()( self.__annotateButton )

def __textWidgetContextMenu( self, *unused ) :

menuDefinition = IECore.MenuDefinition()

def menuLabel( name ) :

if "_" in name :
name = IECore.CamelCase.fromSpaced( name.replace( "_", " " ) )
return IECore.CamelCase.toSpaced( name )

def walkPlugs( graphComponent ) :

if graphComponent.getName().startswith( "__" ) :
return

if isinstance( graphComponent, Gaffer.ValuePlug ) and hasattr( graphComponent, "getValue" ) :
relativeName = graphComponent.relativeName( self.__node )
menuDefinition.append(
"/Insert Plug Value/{}".format( "/".join( menuLabel( n ) for n in relativeName.split( "." ) ) ),
{
"command" : functools.partial( Gaffer.WeakMethod( self.__textWidget.insertText ), f"{{{relativeName}}}" ),
}
)
else :
for plug in Gaffer.Plug.InputRange( graphComponent ) :
walkPlugs( plug )

walkPlugs( self.__node )

if not menuDefinition.size() :
menuDefinition.append( "/Insert Plug Value/No plugs available", { "active" : False } )

self.__popupMenu = GafferUI.Menu( menuDefinition )
self.__popupMenu.popup( parent = self )

return True
Loading

0 comments on commit ed515cd

Please sign in to comment.