From 42ba6c663a12a4b685cbf1017b9d8ef2f490cb89 Mon Sep 17 00:00:00 2001 From: John Haddon Date: Mon, 8 Jul 2024 12:26:28 +0100 Subject: [PATCH 1/9] Loop : Add `previousIteration()` method --- Changes.md | 5 +++++ include/Gaffer/Loop.h | 5 +++++ python/GafferTest/LoopTest.py | 20 +++++++++++++++++ src/Gaffer/Loop.cpp | 23 ++++++++++++++++++++ src/GafferModule/ContextProcessorBinding.cpp | 12 ++++++++++ 5 files changed, 65 insertions(+) diff --git a/Changes.md b/Changes.md index 5028dd5f94..50c50f0ff6 100644 --- a/Changes.md +++ b/Changes.md @@ -12,6 +12,11 @@ Fixes - LightEditor, RenderPassEditor : Added missing icon representing use of the `CreateIfMissing` tweak mode in the history window. +API +--- + +- Loop : Added `previousIteration()` method. + 1.4.9.0 (relative to 1.4.8.0) ======= diff --git a/include/Gaffer/Loop.h b/include/Gaffer/Loop.h index f0990dc296..94b3b66f2d 100644 --- a/include/Gaffer/Loop.h +++ b/include/Gaffer/Loop.h @@ -85,6 +85,11 @@ class GAFFER_API Loop : public ComputeNode /// the next iteration of the loop (relative to the current context). ContextPtr nextIterationContext() const; + /// Returns the input plug and context that form the previous iteration of the loop + /// with respect to the `output` plug and the current context. Returns `{ nullptr, nullptr }` + /// if there is no such iteration. + std::pair previousIteration( const ValuePlug *output ) const; + void affects( const Plug *input, DependencyNode::AffectedPlugsContainer &outputs ) const override; protected : diff --git a/python/GafferTest/LoopTest.py b/python/GafferTest/LoopTest.py index d9ba6877d4..fb9a7d7b9f 100644 --- a/python/GafferTest/LoopTest.py +++ b/python/GafferTest/LoopTest.py @@ -355,5 +355,25 @@ def testNextLoopIteration( self ) : loop["indexVariable"].setValue( "" ) self.assertIsNone( loop.nextIterationContext() ) + def testPreviousIteration( self ) : + + loop = self.intLoop() + loop["next"].setInput( loop["previous"] ) + loop["iterations"].setValue( 10 ) + + iteration = loop.previousIteration( loop["out"] ) + self.assertTrue( iteration[0].isSame( loop["next"] ) ) + self.assertEqual( iteration[1]["loop:index"], 9 ) + + for i in range( 0, 9 ) : + with iteration[1] : + iteration = loop.previousIteration( loop["previous"] ) + self.assertTrue( iteration[0].isSame( loop["next"] ) ) + self.assertEqual( iteration[1]["loop:index"], 8 - i ) + + iteration = loop.previousIteration( loop["previous"] ) + self.assertTrue( iteration[0].isSame( loop["in"] ) ) + self.assertNotIn( "loop:index", iteration[1] ) + if __name__ == "__main__": unittest.main() diff --git a/src/Gaffer/Loop.cpp b/src/Gaffer/Loop.cpp index beca06e714..a9006a44d2 100644 --- a/src/Gaffer/Loop.cpp +++ b/src/Gaffer/Loop.cpp @@ -193,6 +193,29 @@ ContextPtr Loop::nextIterationContext() const return nullptr; } +std::pair Loop::previousIteration( const ValuePlug *output ) const +{ + int index = -1; + IECore::InternedString indexVariable; + if( const ValuePlug *plug = sourcePlug( output, Context::current(), index, indexVariable ) ) + { + ContextPtr context = new Context( *Context::current() ); + + if( index >= 0 ) + { + context->set( indexVariable, index ); + } + else + { + context->remove( indexVariable ); + } + + return { plug, context }; + } + + return { nullptr, nullptr }; +} + void Loop::affects( const Plug *input, DependencyNode::AffectedPlugsContainer &outputs ) const { ComputeNode::affects( input, outputs ); diff --git a/src/GafferModule/ContextProcessorBinding.cpp b/src/GafferModule/ContextProcessorBinding.cpp index a83b3b1ab1..a0b2707fa0 100644 --- a/src/GafferModule/ContextProcessorBinding.cpp +++ b/src/GafferModule/ContextProcessorBinding.cpp @@ -76,6 +76,17 @@ ContextPtr nextIterationContextWrapper( Loop &loop ) return loop.nextIterationContext(); } +object previousIterationWrapper( Loop &loop, const Gaffer::ValuePlug &output ) +{ + std::pair result; + { + IECorePython::ScopedGILRelease gilRelease; + result = loop.previousIteration( &output ); + } + return boost::python::make_tuple( ValuePlugPtr( const_cast( result.first ) ), result.second ); +} + + ContextPtr inPlugContext( const ContextProcessor &n ) { IECorePython::ScopedGILRelease gilRelease; @@ -196,6 +207,7 @@ void GafferModule::bindContextProcessor() DependencyNodeClass() .def( "setup", &setupLoop ) .def( "nextIterationContext", &nextIterationContextWrapper ) + .def( "previousIteration", &previousIterationWrapper ) ; DependencyNodeClass() From 1db30861e940c1750a4ba081aa889a317234470f Mon Sep 17 00:00:00 2001 From: John Haddon Date: Mon, 1 Jul 2024 16:26:26 +0100 Subject: [PATCH 2/9] Context : Add fast self-comparison path to `operator==` --- src/Gaffer/Context.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Gaffer/Context.cpp b/src/Gaffer/Context.cpp index 02118f681c..1fb71db69e 100644 --- a/src/Gaffer/Context.cpp +++ b/src/Gaffer/Context.cpp @@ -443,7 +443,7 @@ IECore::MurmurHash Context::hash() const bool Context::operator == ( const Context &other ) const { - return m_map == other.m_map; + return this == &other || m_map == other.m_map; } bool Context::operator != ( const Context &other ) const From fe475c9ee26758daae7cf895cd81ef008aeb7473 Mon Sep 17 00:00:00 2001 From: John Haddon Date: Thu, 20 Jun 2024 15:20:10 +0100 Subject: [PATCH 3/9] ContextTracker : Add new UI class for tracking active contexts --- Changes.md | 1 + include/GafferUI/ContextTracker.h | 126 +++++ python/GafferUITest/ContextTrackerTest.py | 613 ++++++++++++++++++++++ python/GafferUITest/__init__.py | 1 + src/GafferUI/ContextTracker.cpp | 344 ++++++++++++ src/GafferUIModule/GraphGadgetBinding.cpp | 34 ++ 6 files changed, 1119 insertions(+) create mode 100644 include/GafferUI/ContextTracker.h create mode 100644 python/GafferUITest/ContextTrackerTest.py create mode 100644 src/GafferUI/ContextTracker.cpp diff --git a/Changes.md b/Changes.md index 50c50f0ff6..86a37a7777 100644 --- a/Changes.md +++ b/Changes.md @@ -15,6 +15,7 @@ Fixes API --- +- ContextTracker : Added a new class that determines what contexts nodes are evaluated in relative to the focus node. This allows UI components to provide improved context-sensitive feedback to the user. - Loop : Added `previousIteration()` method. 1.4.9.0 (relative to 1.4.8.0) diff --git a/include/GafferUI/ContextTracker.h b/include/GafferUI/ContextTracker.h new file mode 100644 index 0000000000..5186d2f8eb --- /dev/null +++ b/include/GafferUI/ContextTracker.h @@ -0,0 +1,126 @@ +////////////////////////////////////////////////////////////////////////// +// +// Copyright (c) 2024, Cinesite VFX Ltd. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above +// copyright notice, this list of conditions and the following +// disclaimer. +// +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following +// disclaimer in the documentation and/or other materials provided with +// the distribution. +// +// * Neither the name of John Haddon nor the names of +// any other contributors to this software may be used to endorse or +// promote products derived from this software without specific prior +// written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +// IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +// THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +// PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +// EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +// PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +// LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +// NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +// +////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include "GafferUI/Export.h" + +#include "Gaffer/Set.h" +#include "Gaffer/Signals.h" + +#include "IECore/RefCounted.h" + +#include + +namespace Gaffer +{ + +class Plug; +IE_CORE_FORWARDDECLARE( Node ); +IE_CORE_FORWARDDECLARE( Context ) + +} // namespace Gaffer + +namespace GafferUI +{ + +/// Utility class for UI components which display context-sensitive information +/// to users. This tracks which upstream nodes contribute to the result at a +/// particular target node, and also what context they should be evaluated in +/// with respect to that node. +class GAFFERUI_API ContextTracker final : public IECore::RefCounted, public Gaffer::Signals::Trackable +{ + + public : + + /// Constructs an instance that will track the graph upstream of the + /// target `node`, taking into account what connections are active in + /// the target `context`. + ContextTracker( const Gaffer::NodePtr &node, const Gaffer::ContextPtr &context ); + ~ContextTracker() override; + + IE_CORE_DECLAREMEMBERPTR( ContextTracker ); + + /// Target + /// ====== + + const Gaffer::Node *targetNode() const; + const Gaffer::Context *targetContext() const; + + /// Queries + /// ======= + + /// Returns true if the specified plug or node contributes to the + /// evaluation of the target. + bool isTracked( const Gaffer::Plug *plug ) const; + bool isTracked( const Gaffer::Node *node ) const; + + /// Returns the most suitable context for the UI to evaluate a plug or + /// node in. This will always return a valid context, even if the plug + /// or node has not been tracked. + Gaffer::ConstContextPtr context( const Gaffer::Plug *plug ) const; + Gaffer::ConstContextPtr context( const Gaffer::Node *node ) const; + + private : + + void plugDirtied( const Gaffer::Plug *plug ); + void contextChanged( IECore::InternedString variable ); + void update(); + const Gaffer::Context *findPlugContext( const Gaffer::Plug *plug ) const; + + Gaffer::ConstNodePtr m_node; + Gaffer::ConstContextPtr m_context; + + struct NodeData + { + Gaffer::ConstContextPtr context = nullptr; + // If `true`, then all input plugs on the node are assumed to be + // active in the Node's context. This is just an optimisation that + // allows us to keep the size of `m_plugContexts` to a minimum. + bool allInputsActive = false; + }; + + using NodeContexts = std::unordered_map; + NodeContexts m_nodeContexts; + using PlugContexts = std::unordered_map; + // Stores plug-specific contexts, which take precedence over `m_nodeContexts`. + PlugContexts m_plugContexts; + +}; + +IE_CORE_DECLAREPTR( ContextTracker ) + +} // namespace GafferUI diff --git a/python/GafferUITest/ContextTrackerTest.py b/python/GafferUITest/ContextTrackerTest.py new file mode 100644 index 0000000000..a94cab30df --- /dev/null +++ b/python/GafferUITest/ContextTrackerTest.py @@ -0,0 +1,613 @@ +########################################################################## +# +# Copyright (c) 2024, Cinesite VFX Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above +# copyright notice, this list of conditions and the following +# disclaimer. +# +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following +# disclaimer in the documentation and/or other materials provided with +# the distribution. +# +# * Neither the name of John Haddon nor the names of +# any other contributors to this software may be used to endorse or +# promote products derived from this software without specific prior +# written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +########################################################################## + +import unittest + +import Gaffer +import GafferTest +import GafferUI +import GafferUITest + +class ContextTrackerTest( GafferUITest.TestCase ) : + + def testSimpleNodes( self ) : + + script = Gaffer.ScriptNode() + + script["add1"] = GafferTest.AddNode() + script["add2"] = GafferTest.AddNode() + script["add3"] = GafferTest.AddNode() + script["add4"] = GafferTest.AddNode() + script["unconnected"] = GafferTest.AddNode() + + script["add3"]["op1"].setInput( script["add1"]["sum"] ) + script["add3"]["op2"].setInput( script["add2"]["sum"] ) + script["add4"]["op1"].setInput( script["add3"]["sum"] ) + + context = Gaffer.Context() + tracker = GafferUI.ContextTracker( script["add4"], context ) + + def assertExpectedContexts() : + + # Untracked nodes and plugs fall back to using the target + # context, so everything has the same context. + + for node in Gaffer.Node.Range( script ) : + self.assertEqual( tracker.context( node ), context ) + for plug in Gaffer.Plug.RecursiveRange( node ) : + self.assertEqual( tracker.context( plug ), context ) + + assertExpectedContexts( ) + + for node in [ script["add1"], script["add2"], script["add3"], script["add4"] ] : + for graphComponent in [ node, node["op1"], node["op2"], node["sum"], node["enabled"] ] : + self.assertTrue( tracker.isTracked( graphComponent ), graphComponent.fullName() ) + + for graphComponent in [ script["unconnected"], script["unconnected"]["op1"], script["unconnected"]["op2"], script["unconnected"]["sum"], script["unconnected"]["enabled"] ] : + self.assertFalse( tracker.isTracked( graphComponent ) ) + + script["add3"]["enabled"].setValue( False ) + + assertExpectedContexts( ) + + self.assertTrue( tracker.isTracked( script["add4"] ) ) + self.assertTrue( tracker.isTracked( script["add3"] ) ) + self.assertTrue( tracker.isTracked( script["add3"]["op1"] ) ) + self.assertFalse( tracker.isTracked( script["add3"]["op2"] ) ) + self.assertTrue( tracker.isTracked( script["add1"] ) ) + self.assertFalse( tracker.isTracked( script["add2"] ) ) + + def testSwitch( self ) : + + # Static case - switch will have internal pass-through connections. + + script = Gaffer.ScriptNode() + + script["add1"] = GafferTest.AddNode() + script["add2"] = GafferTest.AddNode() + + script["switch"] = Gaffer.Switch() + script["switch"].setup( script["add1"]["sum"] ) + script["switch"]["in"][0].setInput( script["add1"]["sum"] ) + script["switch"]["in"][1].setInput( script["add2"]["sum"] ) + + context = Gaffer.Context() + tracker = GafferUI.ContextTracker( script["switch"], context ) + + def assertExpectedContexts() : + + # Untracked nodes and plugs fall back to using the target + # context, so everything has the same context. + + for node in [ script["add1"], script["add2"], script["switch"] ] : + self.assertEqual( tracker.context( node ), context, node.fullName() ) + for plug in Gaffer.Plug.RecursiveRange( node ) : + self.assertEqual( tracker.context( plug ), context, plug.fullName() ) + + assertExpectedContexts() + + self.assertTrue( tracker.isTracked( script["switch"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["out"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["index"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["enabled"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["in"][0] ) ) + self.assertFalse( tracker.isTracked( script["switch"]["in"][1] ) ) + self.assertTrue( tracker.isTracked( script["add1"] ) ) + self.assertFalse( tracker.isTracked( script["add2"] ) ) + + script["switch"]["index"].setValue( 1 ) + + assertExpectedContexts() + + self.assertTrue( tracker.isTracked( script["switch"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["out"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["index"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["enabled"] ) ) + self.assertFalse( tracker.isTracked( script["switch"]["in"][0] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["in"][1] ) ) + self.assertFalse( tracker.isTracked( script["add1"] ) ) + self.assertTrue( tracker.isTracked( script["add2"] ) ) + + script["switch"]["enabled"].setValue( False ) + + assertExpectedContexts() + + self.assertTrue( tracker.isTracked( script["switch"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["out"] ) ) + self.assertFalse( tracker.isTracked( script["switch"]["index"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["enabled"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["in"][0] ) ) + self.assertFalse( tracker.isTracked( script["switch"]["in"][1] ) ) + self.assertTrue( tracker.isTracked( script["add1"] ) ) + self.assertFalse( tracker.isTracked( script["add2"] ) ) + + # Dynamic case - switch will compute input on the fly. + + script["add3"] = GafferTest.AddNode() + script["switch"]["index"].setInput( script["add3"]["sum"] ) + script["switch"]["enabled"].setValue( True ) + + assertExpectedContexts() + + self.assertTrue( tracker.isTracked( script["switch"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["out"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["index"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["enabled"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["in"][0] ) ) + self.assertFalse( tracker.isTracked( script["switch"]["in"][1] ) ) + self.assertTrue( tracker.isTracked( script["add1"] ) ) + self.assertFalse( tracker.isTracked( script["add2"] ) ) + self.assertTrue( tracker.isTracked( script["add3"] ) ) + self.assertEqual( tracker.context( script["add3"] ), context ) + + script["add3"]["op1"].setValue( 1 ) + + assertExpectedContexts() + + self.assertTrue( tracker.isTracked( script["switch"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["out"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["index"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["enabled"] ) ) + self.assertFalse( tracker.isTracked( script["switch"]["in"][0] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["in"][1] ) ) + self.assertFalse( tracker.isTracked( script["add1"] ) ) + self.assertTrue( tracker.isTracked( script["add2"] ) ) + self.assertTrue( tracker.isTracked( script["add3"] ) ) + self.assertEqual( tracker.context( script["add3"] ), context ) + + def testNameSwitch( self ) : + + # Static case - switch will have internal pass-through connections. + + script = Gaffer.ScriptNode() + + script["add1"] = GafferTest.AddNode() + script["add2"] = GafferTest.AddNode() + + script["switch"] = Gaffer.NameSwitch() + script["switch"].setup( script["add1"]["sum"] ) + script["switch"]["in"][0]["value"].setInput( script["add1"]["sum"] ) + script["switch"]["in"][1]["value"].setInput( script["add2"]["sum"] ) + script["switch"]["in"][1]["name"].setValue( "add2" ) + + context = Gaffer.Context() + tracker = GafferUI.ContextTracker( script["switch"], context ) + + def assertExpectedContexts() : + + # Untracked nodes and plugs fall back to using the target + # context, so everything has the same context. + + for node in [ script["add1"], script["add2"], script["switch"] ] : + self.assertEqual( tracker.context( node ), context ) + for plug in Gaffer.Plug.RecursiveRange( node ) : + self.assertEqual( tracker.context( plug ), context ) + + assertExpectedContexts() + + self.assertTrue( tracker.isTracked( script["switch"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["out"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["selector"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["in"][0] ) ) + self.assertFalse( tracker.isTracked( script["switch"]["in"][1] ) ) + self.assertTrue( tracker.isTracked( script["add1"] ) ) + self.assertFalse( tracker.isTracked( script["add2"] ) ) + + script["switch"]["selector"].setValue( "add2" ) + + assertExpectedContexts() + + self.assertTrue( tracker.isTracked( script["switch"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["out"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["selector"] ) ) + self.assertFalse( tracker.isTracked( script["switch"]["in"][0] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["in"][1] ) ) + self.assertFalse( tracker.isTracked( script["add1"] ) ) + self.assertTrue( tracker.isTracked( script["add2"] ) ) + + script["switch"]["enabled"].setValue( False ) + + assertExpectedContexts() + + self.assertTrue( tracker.isTracked( script["switch"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["out"] ) ) + self.assertFalse( tracker.isTracked( script["switch"]["selector"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["in"][0] ) ) + self.assertFalse( tracker.isTracked( script["switch"]["in"][1] ) ) + self.assertTrue( tracker.isTracked( script["add1"] ) ) + self.assertFalse( tracker.isTracked( script["add2"] ) ) + + # Dynamic case - switch will compute input on the fly. + + stringNode = GafferTest.StringInOutNode() + script["switch"]["selector"].setInput( stringNode["out"] ) + script["switch"]["enabled"].setValue( True ) + + assertExpectedContexts() + + self.assertTrue( tracker.isTracked( script["switch"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["out"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["selector"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["in"][0] ) ) + self.assertFalse( tracker.isTracked( script["switch"]["in"][1] ) ) + self.assertTrue( tracker.isTracked( script["add1"] ) ) + self.assertFalse( tracker.isTracked( script["add2"] ) ) + self.assertTrue( tracker.isTracked( stringNode ) ) + self.assertEqual( tracker.context( stringNode ), context ) + + stringNode["in"].setValue( "add2" ) + + assertExpectedContexts() + + self.assertTrue( tracker.isTracked( script["switch"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["out"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["selector"] ) ) + self.assertFalse( tracker.isTracked( script["switch"]["in"][0] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["in"][1] ) ) + self.assertFalse( tracker.isTracked( script["add1"] ) ) + self.assertTrue( tracker.isTracked( script["add2"] ) ) + self.assertTrue( tracker.isTracked( stringNode ) ) + self.assertEqual( tracker.context( stringNode ), context ) + + def testMultipleActiveNameSwitchBranches( self ) : + + script = Gaffer.ScriptNode() + + script["add1"] = GafferTest.AddNode() + script["add2"] = GafferTest.AddNode() + + script["switch"] = Gaffer.NameSwitch() + script["switch"].setup( script["add1"]["sum"] ) + script["switch"]["in"][0]["value"].setInput( script["add1"]["sum"] ) + script["switch"]["in"][1]["value"].setInput( script["add2"]["sum"] ) + script["switch"]["in"][1]["name"].setValue( "add2" ) + script["switch"]["selector"].setValue( "${selector}" ) + + script["contextVariables"] = Gaffer.ContextVariables() + script["contextVariables"].setup( script["switch"]["out"]["value"] ) + script["contextVariables"]["in"].setInput( script["switch"]["out"]["value"] ) + script["contextVariables"]["variables"].addChild( Gaffer.NameValuePlug( "selector", "add2" ) ) + + script["add3"] = GafferTest.AddNode() + script["add3"]["op1"].setInput( script["switch"]["out"]["value"] ) + script["add3"]["op2"].setInput( script["contextVariables"]["out"] ) + + context = Gaffer.Context() + tracker = GafferUI.ContextTracker( script["add3"], context ) + + self.assertEqual( tracker.context( script["add3"] ), context ) + self.assertEqual( tracker.context( script["switch"] ), context ) + self.assertEqual( tracker.context( script["switch"]["in"][0]["value"] ), context ) + self.assertEqual( tracker.context( script["switch"]["in"][1]["value"] ), script["contextVariables"].inPlugContext() ) + self.assertEqual( tracker.context( script["add1"] ), context ) + self.assertEqual( tracker.context( script["add2"] ), script["contextVariables"].inPlugContext() ) + + def testNameSwitchNamesAndEnabled( self ) : + + script = Gaffer.ScriptNode() + + script["add1"] = GafferTest.AddNode() + script["add2"] = GafferTest.AddNode() + script["add3"] = GafferTest.AddNode() + script["add4"] = GafferTest.AddNode() + script["add5"] = GafferTest.AddNode() + + script["switch"] = Gaffer.NameSwitch() + script["switch"]["selector"].setValue( "four" ) + script["switch"].setup( script["add1"]["sum"] ) + script["switch"]["in"].resize( 5 ) + script["switch"]["in"][0]["value"].setInput( script["add1"]["sum"] ) + script["switch"]["in"][0]["name"].setValue( "one" ) + script["switch"]["in"][1]["value"].setInput( script["add2"]["sum"] ) + script["switch"]["in"][1]["name"].setValue( "two" ) + script["switch"]["in"][2]["value"].setInput( script["add3"]["sum"] ) + script["switch"]["in"][2]["name"].setValue( "three" ) + script["switch"]["in"][2]["enabled"].setValue( False ) + script["switch"]["in"][3]["value"].setInput( script["add4"]["sum"] ) + script["switch"]["in"][3]["name"].setValue( "four" ) + script["switch"]["in"][4]["value"].setInput( script["add5"]["sum"] ) + script["switch"]["in"][4]["name"].setValue( "five" ) + + script["add6"] = GafferTest.AddNode() + script["add6"]["op1"].setInput( script["switch"]["out"]["value"] ) + + context = Gaffer.Context() + tracker = GafferUI.ContextTracker( script["add6"], context ) + + # Default input `name` and `enabled` are never evaluated and `value` + # isn't currently active. + self.assertFalse( tracker.isTracked( script["switch"]["in"][0]["enabled"] ) ) + self.assertFalse( tracker.isTracked( script["switch"]["in"][0]["name"] ) ) + self.assertFalse( tracker.isTracked( script["switch"]["in"][0]["value"] ) ) + # Next input should be evaluated, but it doesn't match so `value` + # won't be active. + self.assertTrue( tracker.isTracked( script["switch"]["in"][1]["enabled"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["in"][1]["name"] ) ) + self.assertFalse( tracker.isTracked( script["switch"]["in"][1]["value"] ) ) + # Next input would be evaluated, but it is disabled so `name` isn't evaluated. + self.assertTrue( tracker.isTracked( script["switch"]["in"][2]["enabled"] ) ) + self.assertFalse( tracker.isTracked( script["switch"]["in"][2]["name"] ) ) + self.assertFalse( tracker.isTracked( script["switch"]["in"][2]["value"] ) ) + # Next input will be evaluated and will match, so `value` will be active too. + self.assertTrue( tracker.isTracked( script["switch"]["in"][3]["enabled"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["in"][3]["name"] ) ) + self.assertTrue( tracker.isTracked( script["switch"]["in"][3]["value"] ) ) + # Last input will be ignored because a match has already been found. + self.assertFalse( tracker.isTracked( script["switch"]["in"][4]["enabled"] ) ) + self.assertFalse( tracker.isTracked( script["switch"]["in"][4]["name"] ) ) + self.assertFalse( tracker.isTracked( script["switch"]["in"][4]["value"] ) ) + + script["switch"]["enabled"].setValue( False ) + + for plug in list( Gaffer.NameValuePlug.Range( script["switch"]["in"] ) ) : + self.assertFalse( tracker.isTracked( plug["name"] ), plug["name"].fullName() ) + self.assertFalse( tracker.isTracked( plug["enabled"] ), plug["enabled"].fullName() ) + self.assertEqual( + tracker.isTracked( plug["value"] ), + plug["value"].isSame( script["switch"]["in"][0]["value"] ), + plug["value"].fullName() + ) + + def testContextProcessors( self ) : + + script = Gaffer.ScriptNode() + + script["add"] = GafferTest.AddNode() + + script["contextVariables"] = Gaffer.ContextVariables() + script["contextVariables"].setup( script["add"]["sum"] ) + script["contextVariables"]["in"].setInput( script["add"]["sum"] ) + + context = Gaffer.Context() + tracker = GafferUI.ContextTracker( script["contextVariables"], context ) + + self.assertTrue( tracker.isTracked( script["contextVariables"] ) ) + self.assertTrue( tracker.isTracked( script["contextVariables"]["enabled"] ) ) + self.assertTrue( tracker.isTracked( script["contextVariables"]["variables"] ) ) + self.assertEqual( tracker.context( script["contextVariables"] ), context ) + self.assertTrue( tracker.isTracked( script["add"] ) ) + self.assertEqual( tracker.context( script["add"] ), context ) + + script["contextVariables"]["variables"].addChild( Gaffer.NameValuePlug( "test", 2 ) ) + + self.assertTrue( tracker.isTracked( script["contextVariables"] ) ) + self.assertTrue( tracker.isTracked( script["contextVariables"]["enabled"] ) ) + self.assertTrue( tracker.isTracked( script["contextVariables"]["variables"] ) ) + self.assertTrue( tracker.isTracked( script["contextVariables"]["variables"][0] ) ) + self.assertTrue( tracker.isTracked( script["contextVariables"]["variables"][0]["name"] ) ) + self.assertTrue( tracker.isTracked( script["contextVariables"]["variables"][0]["value"] ) ) + self.assertEqual( tracker.context( script["contextVariables"] ), context ) + self.assertTrue( tracker.isTracked( script["add"] ) ) + self.assertEqual( tracker.context( script["add"] ), script["contextVariables"].inPlugContext() ) + + script["contextVariables"]["enabled"].setValue( False ) + + self.assertTrue( tracker.isTracked( script["contextVariables"] ) ) + self.assertTrue( tracker.isTracked( script["contextVariables"]["enabled"] ) ) + self.assertFalse( tracker.isTracked( script["contextVariables"]["variables"] ) ) + self.assertFalse( tracker.isTracked( script["contextVariables"]["variables"][0] ) ) + self.assertFalse( tracker.isTracked( script["contextVariables"]["variables"][0]["name"] ) ) + self.assertFalse( tracker.isTracked( script["contextVariables"]["variables"][0]["value"] ) ) + self.assertEqual( tracker.context( script["contextVariables"] ), context ) + self.assertTrue( tracker.isTracked( script["add"] ) ) + self.assertEqual( tracker.context( script["add"] ), context ) + + def testContextForInactiveInputs( self ) : + + script = Gaffer.ScriptNode() + + script["add"] = GafferTest.AddNode() + script["add"]["enabled"].setValue( False ) + + script["contextVariables"] = Gaffer.ContextVariables() + script["contextVariables"].setup( script["add"]["sum"] ) + script["contextVariables"]["in"].setInput( script["add"]["sum"] ) + script["contextVariables"]["variables"].addChild( Gaffer.NameValuePlug( "test", 2 ) ) + + context = Gaffer.Context() + tracker = GafferUI.ContextTracker( script["contextVariables"], context ) + + # Even though `op2` is inactive, it still makes most sense to evaluate it + # in the modified context, because that is the context it will be active in + # if the node is enabled. + self.assertFalse( tracker.isTracked( script["add"]["op2"] ) ) + self.assertEqual( tracker.context( script["add"]["op2"] ), script["contextVariables"].inPlugContext() ) + + def testPlugWithoutNode( self ) : + + plug = Gaffer.IntPlug() + + script = Gaffer.ScriptNode() + script["node"] = GafferTest.AddNode() + script["node"]["op1"].setInput( plug ) + + context = Gaffer.Context() + tracker = GafferUI.ContextTracker( script["node"], context ) + + self.assertTrue( tracker.isTracked( script["node"] ) ) + self.assertEqual( tracker.context( script["node"] ), context ) + self.assertTrue( tracker.isTracked( script["node"]["op1"] ) ) + self.assertEqual( tracker.context( script["node"]["op1"] ), context ) + self.assertTrue( tracker.isTracked( plug ) ) + self.assertEqual( tracker.context( plug ), context ) + + def testLoop( self ) : + + script = Gaffer.ScriptNode() + + script["loopSource"] = GafferTest.AddNode() + script["loopBody"] = GafferTest.AddNode() + + script["loop"] = Gaffer.Loop() + script["loop"].setup( script["loopSource"]["sum"] ) + script["loop"]["in"].setInput( script["loopSource"]["sum"] ) + + script["loopBody"]["op1"].setInput( script["loop"]["previous"] ) + script["loopBody"]["op2"].setValue( 2 ) + script["loop"]["next"].setInput( script["loopBody"]["sum"] ) + + script["loop"]["iterations"].setValue( 10 ) + self.assertEqual( script["loop"]["out"].getValue(), 20 ) + + context = Gaffer.Context() + tracker = GafferUI.ContextTracker( script["loop"], context ) + + self.assertTrue( tracker.isTracked( script["loop"] ) ) + self.assertEqual( tracker.context( script["loop"] ), context ) + self.assertTrue( tracker.isTracked( script["loop"]["iterations"] ) ) + self.assertEqual( tracker.context( script["loop"]["iterations"] ), context ) + self.assertTrue( tracker.isTracked( script["loop"]["indexVariable"] ) ) + self.assertEqual( tracker.context( script["loop"]["indexVariable"] ), context ) + self.assertTrue( tracker.isTracked( script["loopSource"] ) ) + self.assertEqual( tracker.context( script["loopSource"] ), context ) + self.assertTrue( tracker.isTracked( script["loop"]["next"] ) ) + lastIterationContext = script["loop"].previousIteration( script["loop"]["out"] )[1] + self.assertEqual( tracker.context( script["loop"]["next"] ), lastIterationContext ) + self.assertTrue( tracker.isTracked( script["loopBody"] ) ) + self.assertEqual( tracker.context( script["loopBody"] ), lastIterationContext ) + + def assertDisabledLoop() : + + self.assertTrue( tracker.isTracked( script["loop"] ) ) + self.assertEqual( tracker.context( script["loop"] ), context ) + self.assertEqual( tracker.isTracked( script["loop"]["iterations"] ), script["loop"]["enabled"].getValue() ) + self.assertEqual( tracker.context( script["loop"]["iterations"] ), context ) + self.assertEqual( tracker.isTracked( script["loop"]["indexVariable"] ), script["loop"]["enabled"].getValue() ) + self.assertEqual( tracker.context( script["loop"]["indexVariable"] ), context ) + self.assertTrue( tracker.isTracked( script["loopSource"] ) ) + self.assertEqual( tracker.context( script["loopSource"] ), context ) + self.assertFalse( tracker.isTracked( script["loop"]["next"] ) ) + self.assertEqual( tracker.context( script["loop"]["next"] ), context ) + self.assertFalse( tracker.isTracked( script["loopBody"] ) ) + self.assertEqual( tracker.context( script["loopBody"] ), context ) + + script["loop"]["enabled"].setValue( False ) + assertDisabledLoop() + + script["loop"]["enabled"].setValue( True ) + script["loop"]["iterations"].setValue( 0 ) + assertDisabledLoop() + + def testLoopEvaluatesAllIterations( self ) : + + script = Gaffer.ScriptNode() + + script["loopSource"] = GafferTest.AddNode() + script["loopBody"] = GafferTest.AddNode() + + script["loopIndexQuery"] = Gaffer.ContextQuery() + indexQuery = script["loopIndexQuery"].addQuery( Gaffer.IntPlug() ) + indexQuery["name"].setValue( "loop:index" ) + + script["loopSwitch"] = Gaffer.Switch() + script["loopSwitch"].setup( script["loopBody"]["op1"] ) + script["loopSwitch"]["index"].setInput( script["loopIndexQuery"]["out"][0]["value"] ) + + script["loop"] = Gaffer.Loop() + script["loop"].setup( script["loopSource"]["sum"] ) + script["loop"]["in"].setInput( script["loopSource"]["sum"] ) + + script["loopBody"]["op1"].setInput( script["loop"]["previous"] ) + script["loopBody"]["op2"].setInput( script["loopSwitch"]["out"] ) + script["loop"]["next"].setInput( script["loopBody"]["sum"] ) + + iterations = 10 + script["loop"]["iterations"].setValue( iterations ) + + for i in range( 0, iterations ) : + switchInput = GafferTest.AddNode() + script[f"switchInput{i}"] = switchInput + switchInput["op1"].setValue( i ) + script["loopSwitch"]["in"][i].setInput( switchInput["sum"] ) + + self.assertEqual( script["loop"]["out"].getValue(), sum( range( 0, iterations ) ) ) + + context = Gaffer.Context() + tracker = GafferUI.ContextTracker( script["loop"], context ) + + for i in range( 0, iterations ) : + switchInput = script["loopSwitch"]["in"][i] + self.assertTrue( tracker.isTracked( switchInput ), switchInput.fullName() ) + self.assertEqual( tracker.context( switchInput )["loop:index"], i ) + self.assertTrue( tracker.isTracked( switchInput.source() ), switchInput.source().fullName() ) + self.assertEqual( tracker.context( switchInput.source() )["loop:index"], i ) + self.assertTrue( tracker.isTracked( switchInput.source().node() ), switchInput.source().node().fullName() ) + self.assertEqual( tracker.context( switchInput.source().node() )["loop:index"], i ) + + def testMultiplexedBox( self ) : + + script = Gaffer.ScriptNode() + + script["addA"] = GafferTest.AddNode() + script["addB"] = GafferTest.AddNode() + + script["box"] = Gaffer.Box() + script["box"]["addA"] = GafferTest.AddNode() + script["box"]["addB"] = GafferTest.AddNode() + + Gaffer.PlugAlgo.promoteWithName( script["box"]["addA"]["op1"], "opA" ) + Gaffer.PlugAlgo.promoteWithName( script["box"]["addB"]["op1"], "opB" ) + script["box"]["opA"].setInput( script["addA"]["sum"] ) + script["box"]["opB"].setInput( script["addB"]["sum"] ) + + Gaffer.PlugAlgo.promoteWithName( script["box"]["addA"]["sum"], "sumA" ) + Gaffer.PlugAlgo.promoteWithName( script["box"]["addB"]["sum"], "sumB" ) + script["box"]["sumA"].setInput( script["box"]["addA"]["sum"] ) + script["box"]["sumB"].setInput( script["box"]["addB"]["sum"] ) + + script["resultA"] = GafferTest.AddNode() + script["resultA"]["op1"].setInput( script["box"]["sumA"] ) + + script["resultB"] = GafferTest.AddNode() + script["resultB"]["op1"].setInput( script["box"]["sumB"] ) + + context = Gaffer.Context() + tracker = GafferUI.ContextTracker( script["resultA"], context ) + + self.assertTrue( tracker.isTracked( script["resultA"] ) ) + self.assertFalse( tracker.isTracked( script["resultB"] ) ) + self.assertTrue( tracker.isTracked( script["box"]["sumA"] ) ) + self.assertFalse( tracker.isTracked( script["box"]["sumB"] ) ) + self.assertTrue( tracker.isTracked( script["box"] ) ) + self.assertTrue( tracker.isTracked( script["box"]["addA"] ) ) + self.assertFalse( tracker.isTracked( script["box"]["addB"] ) ) + self.assertTrue( tracker.isTracked( script["box"]["opA"] ) ) + self.assertFalse( tracker.isTracked( script["box"]["opB"] ) ) + self.assertTrue( tracker.isTracked( script["addA"] ) ) + self.assertFalse( tracker.isTracked( script["addB"] ) ) + +if __name__ == "__main__": + unittest.main() diff --git a/python/GafferUITest/__init__.py b/python/GafferUITest/__init__.py index 6e2635e70c..f899661351 100644 --- a/python/GafferUITest/__init__.py +++ b/python/GafferUITest/__init__.py @@ -134,6 +134,7 @@ from .AnnotationsGadgetTest import AnnotationsGadgetTest from .PopupWindowTest import PopupWindowTest from .ColorChooserTest import ColorChooserTest +from .ContextTrackerTest import ContextTrackerTest if __name__ == "__main__": unittest.main() diff --git a/src/GafferUI/ContextTracker.cpp b/src/GafferUI/ContextTracker.cpp new file mode 100644 index 0000000000..d9057d71fa --- /dev/null +++ b/src/GafferUI/ContextTracker.cpp @@ -0,0 +1,344 @@ +////////////////////////////////////////////////////////////////////////// +// +// Copyright (c) 2024, Cinesite VFX Ltd. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above +// copyright notice, this list of conditions and the following +// disclaimer. +// +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following +// disclaimer in the documentation and/or other materials provided with +// the distribution. +// +// * Neither the name of John Haddon nor the names of +// any other contributors to this software may be used to endorse or +// promote products derived from this software without specific prior +// written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +// IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +// THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +// PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +// EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +// PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +// LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +// NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +// +////////////////////////////////////////////////////////////////////////// + +#include "GafferUI/ContextTracker.h" + +#include "Gaffer/Context.h" +#include "Gaffer/ContextVariables.h" +#include "Gaffer/Loop.h" +#include "Gaffer/NameSwitch.h" +#include "Gaffer/Switch.h" + +#include "boost/algorithm/string.hpp" +#include "boost/algorithm/string/predicate.hpp" +#include "boost/bind/bind.hpp" +#include "boost/bind/placeholders.hpp" + +#include + +using namespace Gaffer; +using namespace GafferUI; +using namespace IECore; +using namespace boost::placeholders; +using namespace std; + +ContextTracker::ContextTracker( const Gaffer::NodePtr &node, const Gaffer::ContextPtr &context ) + : m_node( node ), m_context( context ) +{ + node->plugDirtiedSignal().connect( boost::bind( &ContextTracker::plugDirtied, this, ::_1 ) ); + context->changedSignal().connect( boost::bind( &ContextTracker::contextChanged, this, ::_2 ) ); + update(); +} + +ContextTracker::~ContextTracker() +{ + disconnectTrackedConnections(); +} + +const Gaffer::Node *ContextTracker::targetNode() const +{ + return m_node.get(); +} + +const Gaffer::Context *ContextTracker::targetContext() const +{ + return m_context.get(); +} + +bool ContextTracker::isTracked( const Gaffer::Plug *plug ) const +{ + if( findPlugContext( plug ) ) + { + return true; + } + + if( plug->direction() != Plug::In ) + { + return false; + } + + auto it = m_nodeContexts.find( plug->node() ); + return it != m_nodeContexts.end() && it->second.allInputsActive; +} + +bool ContextTracker::isTracked( const Gaffer::Node *node ) const +{ + return m_nodeContexts.find( node ) != m_nodeContexts.end(); +} + +Gaffer::ConstContextPtr ContextTracker::context( const Gaffer::Plug *plug ) const +{ + if( const Context *c = findPlugContext( plug ) ) + { + return c; + } + + return context( plug->node() ); +} + +Gaffer::ConstContextPtr ContextTracker::context( const Gaffer::Node *node ) const +{ + auto it = m_nodeContexts.find( node ); + return it != m_nodeContexts.end() ? it->second.context : m_context; +} + +void ContextTracker::plugDirtied( const Gaffer::Plug *plug ) +{ + update(); +} + +void ContextTracker::contextChanged( IECore::InternedString variable ) +{ + update(); +} + +void ContextTracker::update() +{ + m_nodeContexts.clear(); + m_plugContexts.clear(); + + std::deque> toVisit; + + for( Plug::RecursiveOutputIterator it( m_node.get() ); !it.done(); ++it ) + { + toVisit.push_back( { it->get(), m_context } ); + it.prune(); + } + + std::unordered_set visited; + + while( !toVisit.empty() ) + { + // Get next plug to visit, and early out if we've already visited it in + // this context. + + auto [plug, context] = toVisit.front(); + toVisit.pop_front(); + + MurmurHash visitHash = context->hash(); + visitHash.append( (uintptr_t)plug ); + if( !visited.insert( visitHash ).second ) + { + continue; + } + + // If this is the first time we have visited the node and/or plug, then + // record the context. + + const Node *node = plug->node(); + NodeData &nodeData = m_nodeContexts[node]; + if( !nodeData.context ) + { + nodeData.context = context; + } + + if( !node || plug->direction() == Plug::Out || !nodeData.allInputsActive || *context != *nodeData.context ) + { + m_plugContexts.insert( { plug, context } ); + } + + // Arrange to visit any inputs to this plug, including + // inputs to its descendants. + + if( auto input = plug->getInput() ) + { + toVisit.push_back( { input, context } ); + } + else + { + for( Plug::RecursiveInputIterator it( plug ); !it.done(); ++it ) + { + if( auto input = (*it)->getInput() ) + { + toVisit.push_back( { input, context } ); + it.prune(); + } + } + } + + // If the plug isn't an output plug on a node, or it has an input + // connection, then we're done here and can continue to the next one. + + if( plug->direction() != Plug::Out || !plug->node() ) + { + continue; + } + + Context::Scope scopedContext( context.get() ); + + if( plug->getInput() ) + { + // The plug value isn't computed, so we _should_ be done. But + // there's a wrinkle : switches have an optimisation where they make + // a pass-through connection to avoid the compute when the index is + // constant. We still consider the `index` plug to be active in this + // case, so need to manually add it to the traversal. + if( auto switchNode = runTimeCast( node ) ) + { + if( plug == switchNode->outPlug() || switchNode->outPlug()->isAncestorOf( plug ) ) + { + toVisit.push_back( { switchNode->enabledPlug(), context } ); + if( switchNode->enabledPlug()->getValue() ) + { + toVisit.push_back( { switchNode->indexPlug(), context } ); + } + } + } + continue; + } + + // Plug is an output whose value may be computed. We want to visit only + // the input plugs that will be used by the compute, accounting for any + // changes in context the compute will make. A few special cases for the + // most common nodes are sufficient to provide the user with good + // feedback about what parts of the graph are active. + + if( auto dependencyNode = runTimeCast( node ) ) + { + if( auto enabledPlug = dependencyNode->enabledPlug() ) + { + if( !enabledPlug->getValue() ) + { + // Node is disabled, so we only need to visit the + // pass-through input, if any. + if( auto inPlug = dependencyNode->correspondingInput( plug ) ) + { + toVisit.push_back( { inPlug, context } ); + toVisit.push_back( { enabledPlug, context } ); + } + continue; + } + } + } + + if( auto nameSwitch = runTimeCast( node ) ) + { + if( plug == nameSwitch->getChild( "__outIndex" ) ) + { + toVisit.push_back( { nameSwitch->selectorPlug(), context } ); + const string selector = nameSwitch->selectorPlug()->getValue(); + if( const ArrayPlug *in = nameSwitch->inPlugs() ) + { + for( int i = 1, e = in->children().size(); i < e; ++i ) + { + auto p = in->getChild( i ); + toVisit.push_back( { p->enabledPlug(), context } ); + if( !p->enabledPlug()->getValue() ) + { + continue; + } + const string name = p->namePlug()->getValue(); + toVisit.push_back( { p->namePlug(), context } ); + if( !name.empty() && StringAlgo::matchMultiple( selector, name ) ) + { + break; + } + } + } + continue; + } + // Fall through so other outputs are covered by the Switch + // base class handling below. + } + + if( auto switchNode = runTimeCast( node ) ) + { + if( const Plug *activeIn = switchNode->activeInPlug( plug ) ) + { + toVisit.push_back( { switchNode->enabledPlug(), context } ); + toVisit.push_back( { switchNode->indexPlug(), context } ); + toVisit.push_back( { activeIn, context } ); + } + continue; + } + + if( auto contextProcessor = runTimeCast( node ) ) + { + if( plug == contextProcessor->outPlug() ) + { + // Assume all input plugs are used to generate the context. + nodeData.allInputsActive = true; + // Visit main input in processed context. + ConstContextPtr inContext = contextProcessor->inPlugContext(); + toVisit.push_back( { contextProcessor->inPlug(), inContext } ); + } + continue; + } + + if( auto loop = runTimeCast( node ) ) + { + if( auto valuePlug = runTimeCast( plug ) ) + { + auto [previousPlug, previousContext] = loop->previousIteration( valuePlug ); + if( previousPlug ) + { + toVisit.push_back( { loop->indexVariablePlug(), context } ); + if( plug == loop->outPlug() || loop->outPlug()->isAncestorOf( plug ) ) + { + toVisit.push_back( { loop->iterationsPlug(), context } ); + } + toVisit.push_back( { previousPlug, previousContext } ); + } + } + continue; + } + + // Generic behaviour for all other nodes : assume the compute depends on + // every input plug. + + for( const auto &inputPlug : Plug::InputRange( *node ) ) + { + nodeData.allInputsActive = true; + toVisit.push_back( { inputPlug.get(), context } ); + } + } +} + +const Gaffer::Context *ContextTracker::findPlugContext( const Gaffer::Plug *plug ) const +{ + while( plug ) + { + auto it = m_plugContexts.find( plug ); + if( it != m_plugContexts.end() ) + { + return it->second.get(); + } + + plug = plug->parent(); + } + + return nullptr; +} diff --git a/src/GafferUIModule/GraphGadgetBinding.cpp b/src/GafferUIModule/GraphGadgetBinding.cpp index 62db552896..1344319ee2 100644 --- a/src/GafferUIModule/GraphGadgetBinding.cpp +++ b/src/GafferUIModule/GraphGadgetBinding.cpp @@ -48,9 +48,11 @@ #include "GafferUI/GraphLayout.h" #include "GafferUI/NodeGadget.h" #include "GafferUI/StandardGraphLayout.h" +#include "GafferUI/ContextTracker.h" #include "GafferBindings/SignalBinding.h" +#include "Gaffer/Context.h" #include "Gaffer/Node.h" using namespace boost::python; @@ -220,6 +222,28 @@ void layoutNodes( const GraphLayout &layout, GraphGadget &graph, Gaffer::Set *no layout.layoutNodes( &graph, nodes ); } +NodePtr targetNodeWrapper( const ContextTracker &contextTracker ) +{ + return const_cast( contextTracker.targetNode() ); +} + +ContextPtr targetContextWrapper( const ContextTracker &contextTracker ) +{ + return const_cast( contextTracker.targetContext() ); +} + +ContextPtr contextWrapper1( const ContextTracker &contextTracker, const Node &node, bool copy = false ) +{ + ConstContextPtr c = contextTracker.context( &node ); + return copy ? new Context( *c ) : boost::const_pointer_cast( c ); +} + +ContextPtr contextWrapper2( const ContextTracker &contextTracker, const Plug &plug, bool copy = false ) +{ + ConstContextPtr c = contextTracker.context( &plug ); + return copy ? new Context( *c ) : boost::const_pointer_cast( c ); +} + } // namespace namespace GafferUIModule @@ -323,4 +347,14 @@ void GafferUIModule::bindGraphGadget() .def( "getNodeSeparationScale", &StandardGraphLayout::getNodeSeparationScale ) ; + IECorePython::RefCountedClass( "ContextTracker" ) + .def( init() ) + .def( "targetNode", &targetNodeWrapper ) + .def( "targetContext", &targetContextWrapper ) + .def( "isTracked", (bool (ContextTracker::*)( const Plug *plug ) const)&ContextTracker::isTracked ) + .def( "isTracked", (bool (ContextTracker::*)( const Node *node ) const)&ContextTracker::isTracked ) + .def( "context", &contextWrapper1, ( arg( "node" ), arg( "_copy" ) = true ) ) + .def( "context", &contextWrapper2, ( arg( "plug" ), arg( "_copy" ) = true ) ) + ; + } From 2851e920153cfeaa6e797d798cf5dc6a9e53dc8d Mon Sep 17 00:00:00 2001 From: John Haddon Date: Fri, 21 Jun 2024 09:14:19 +0100 Subject: [PATCH 4/9] ContextTracker : Add `acquire()` methods --- include/GafferUI/ContextTracker.h | 21 ++++ python/GafferUITest/ContextTrackerTest.py | 97 +++++++++++++++++ src/GafferUI/ContextTracker.cpp | 125 +++++++++++++++++++++- src/GafferUIModule/GraphGadgetBinding.cpp | 3 + 4 files changed, 243 insertions(+), 3 deletions(-) diff --git a/include/GafferUI/ContextTracker.h b/include/GafferUI/ContextTracker.h index 5186d2f8eb..34e4c5c7c8 100644 --- a/include/GafferUI/ContextTracker.h +++ b/include/GafferUI/ContextTracker.h @@ -49,6 +49,7 @@ namespace Gaffer { class Plug; +class ScriptNode; IE_CORE_FORWARDDECLARE( Node ); IE_CORE_FORWARDDECLARE( Context ) @@ -74,6 +75,24 @@ class GAFFERUI_API ContextTracker final : public IECore::RefCounted, public Gaff IE_CORE_DECLAREMEMBERPTR( ContextTracker ); + /// Shared instances + /// ================ + /// + /// Tracking the upstream contexts can involve significant computation, + /// so it is recommended that ContextTracker instances are shared + /// between UI components. The `aquire()` methods maintain a pool of + /// instances for this purpose. Acquisition and destruction of shared + /// instances is not threadsafe, and must always be done on the UI + /// thread. + + /// Returns a shared instance for the target `node`. The node must + /// belong to a ScriptNode, so that `ScriptNode::context()` can be used + /// to provide the target context. + static Ptr acquire( const Gaffer::NodePtr &node ); + /// Returns an shared instance that will automatically track the focus + /// node in the specified `script`. + static Ptr acquireForFocus( Gaffer::ScriptNode *script ); + /// Target /// ====== @@ -96,6 +115,7 @@ class GAFFERUI_API ContextTracker final : public IECore::RefCounted, public Gaff private : + void updateNode( const Gaffer::NodePtr &node ); void plugDirtied( const Gaffer::Plug *plug ); void contextChanged( IECore::InternedString variable ); void update(); @@ -103,6 +123,7 @@ class GAFFERUI_API ContextTracker final : public IECore::RefCounted, public Gaff Gaffer::ConstNodePtr m_node; Gaffer::ConstContextPtr m_context; + Gaffer::Signals::ScopedConnection m_plugDirtiedConnection; struct NodeData { diff --git a/python/GafferUITest/ContextTrackerTest.py b/python/GafferUITest/ContextTrackerTest.py index a94cab30df..f1fc875916 100644 --- a/python/GafferUITest/ContextTrackerTest.py +++ b/python/GafferUITest/ContextTrackerTest.py @@ -609,5 +609,102 @@ def testMultiplexedBox( self ) : self.assertTrue( tracker.isTracked( script["addA"] ) ) self.assertFalse( tracker.isTracked( script["addB"] ) ) + def testAcquire( self ) : + + script = Gaffer.ScriptNode() + + script["add1"] = GafferTest.AddNode() + script["add2"] = GafferTest.AddNode() + + tracker1 = GafferUI.ContextTracker.acquire( script["add1"] ) + self.assertTrue( tracker1.isSame( GafferUI.ContextTracker.acquire( script["add1"] ) ) ) + self.assertTrue( tracker1.isTracked( script["add1"] ) ) + self.assertFalse( tracker1.isTracked( script["add2"] ) ) + + tracker2 = GafferUI.ContextTracker.acquire( script["add2"] ) + self.assertTrue( tracker2.isSame( GafferUI.ContextTracker.acquire( script["add2"] ) ) ) + self.assertTrue( tracker2.isTracked( script["add2"] ) ) + self.assertFalse( tracker2.isTracked( script["add1"] ) ) + + def testAcquireLifetime( self ) : + + script = Gaffer.ScriptNode() + + script["node"] = GafferTest.MultiplyNode() + nodeSlots = script["node"].plugDirtiedSignal().numSlots() + nodeRefCount = script["node"].refCount() + + tracker = GafferUI.ContextTracker.acquire( script["node"] ) + del tracker + + # Indicates that `tracker` was truly destroyed. + self.assertEqual( script["node"].plugDirtiedSignal().numSlots(), nodeSlots ) + self.assertEqual( script["node"].refCount(), nodeRefCount ) + + # Should be a whole new instance. + tracker = GafferUI.ContextTracker.acquire( script["node"] ) + self.assertTrue( tracker.isTracked( script["node"] ) ) + + def testAcquireForFocus( self ) : + + script = Gaffer.ScriptNode() + + script["add1"] = GafferTest.AddNode() + script["add2"] = GafferTest.AddNode() + + tracker = GafferUI.ContextTracker.acquireForFocus( script ) + self.assertTrue( tracker.isSame( GafferUI.ContextTracker.acquireForFocus( script ) ) ) + + self.assertFalse( tracker.isTracked( script["add1" ] ) ) + self.assertFalse( tracker.isTracked( script["add2" ] ) ) + + script.setFocus( script["add1"] ) + self.assertTrue( tracker.isTracked( script["add1" ] ) ) + self.assertFalse( tracker.isTracked( script["add2" ] ) ) + + script.setFocus( script["add2"] ) + self.assertFalse( tracker.isTracked( script["add1" ] ) ) + self.assertTrue( tracker.isTracked( script["add2" ] ) ) + + script.setFocus( None ) + self.assertFalse( tracker.isTracked( script["add1" ] ) ) + self.assertFalse( tracker.isTracked( script["add2" ] ) ) + + def testAcquireForFocusLifetime( self ) : + + script = Gaffer.ScriptNode() + contextSlots = script.context().changedSignal().numSlots() + contextRefCount = script.context().refCount() + + tracker = GafferUI.ContextTracker.acquireForFocus( script ) + del tracker + + # Indicates that `tracker` was truly destroyed. + self.assertEqual( script.context().changedSignal().numSlots(), contextSlots ) + self.assertEqual( script.context().refCount(), contextRefCount ) + + # Should be a whole new instance. + script["node"] = GafferTest.MultiplyNode() + script.setFocus( script["node"] ) + tracker = GafferUI.ContextTracker.acquireForFocus( script ) + self.assertTrue( tracker.isTracked( script["node"] ) ) + + def testAcquireNone( self ) : + + tracker1 = GafferUI.ContextTracker.acquire( None ) + self.assertTrue( tracker1.isSame( GafferUI.ContextTracker.acquire( None ) ) ) + + tracker2 = GafferUI.ContextTracker.acquireForFocus( None ) + self.assertTrue( tracker2.isSame( GafferUI.ContextTracker.acquireForFocus( None ) ) ) + + self.assertTrue( tracker1.isSame( tracker2 ) ) + self.assertEqual( tracker1.targetContext(), Gaffer.Context() ) + + node = GafferTest.AddNode() + self.assertFalse( tracker1.isTracked( node ) ) + self.assertFalse( tracker1.isTracked( node["sum"] ) ) + self.assertEqual( tracker1.context( node ), tracker1.targetContext() ) + self.assertEqual( tracker1.context( node["sum"] ), tracker1.targetContext() ) + if __name__ == "__main__": unittest.main() diff --git a/src/GafferUI/ContextTracker.cpp b/src/GafferUI/ContextTracker.cpp index d9057d71fa..8369c936a6 100644 --- a/src/GafferUI/ContextTracker.cpp +++ b/src/GafferUI/ContextTracker.cpp @@ -40,12 +40,16 @@ #include "Gaffer/ContextVariables.h" #include "Gaffer/Loop.h" #include "Gaffer/NameSwitch.h" +#include "Gaffer/ScriptNode.h" #include "Gaffer/Switch.h" #include "boost/algorithm/string.hpp" #include "boost/algorithm/string/predicate.hpp" #include "boost/bind/bind.hpp" #include "boost/bind/placeholders.hpp" +#include "boost/multi_index/member.hpp" +#include "boost/multi_index/hashed_index.hpp" +#include "boost/multi_index_container.hpp" #include @@ -55,19 +59,129 @@ using namespace IECore; using namespace boost::placeholders; using namespace std; +////////////////////////////////////////////////////////////////////////// +// Internal utilities +////////////////////////////////////////////////////////////////////////// + +namespace +{ + +using SharedInstance = std::pair; +using SharedInstances = boost::multi_index::multi_index_container< + SharedInstance, + boost::multi_index::indexed_by< + boost::multi_index::hashed_unique< + boost::multi_index::member + >, + boost::multi_index::hashed_non_unique< + boost::multi_index::member + > + > +>; + +SharedInstances &sharedInstances() +{ + static SharedInstances g_sharedInstances; + return g_sharedInstances; +} + +using SharedFocusInstance = std::pair; +using SharedFocusInstances = boost::multi_index::multi_index_container< + SharedFocusInstance, + boost::multi_index::indexed_by< + boost::multi_index::hashed_unique< + boost::multi_index::member + >, + boost::multi_index::hashed_non_unique< + boost::multi_index::member + > + > +>; + +SharedFocusInstances &sharedFocusInstances() +{ + static SharedFocusInstances g_sharedInstances; + return g_sharedInstances; +} + +} // namespace + +////////////////////////////////////////////////////////////////////////// +// ContextTracker +////////////////////////////////////////////////////////////////////////// + ContextTracker::ContextTracker( const Gaffer::NodePtr &node, const Gaffer::ContextPtr &context ) - : m_node( node ), m_context( context ) + : m_context( context ) { - node->plugDirtiedSignal().connect( boost::bind( &ContextTracker::plugDirtied, this, ::_1 ) ); context->changedSignal().connect( boost::bind( &ContextTracker::contextChanged, this, ::_2 ) ); - update(); + updateNode( node ); } ContextTracker::~ContextTracker() { + sharedInstances().get<1>().erase( this ); + sharedFocusInstances().get<1>().erase( this ); disconnectTrackedConnections(); } +ContextTrackerPtr ContextTracker::acquire( const Gaffer::NodePtr &node ) +{ + auto &instances = sharedInstances(); + auto it = instances.find( node.get() ); + if( it != instances.end() ) + { + return it->second; + } + + auto scriptNode = node ? node->scriptNode() : nullptr; + Ptr instance = new ContextTracker( node, scriptNode ? scriptNode->context() : new Context() ); + instances.insert( { node.get(), instance.get() } ); + return instance; +} + +ContextTrackerPtr ContextTracker::acquireForFocus( Gaffer::ScriptNode *script ) +{ + if( !script ) + { + return acquire( script ); + } + + auto &instances = sharedFocusInstances(); + auto it = instances.find( script ); + if( it != instances.end() ) + { + if( it->second->m_context == script->context() ) + { + return it->second; + } + else + { + // Contexts don't match. Only explanation is that the original + // ScriptNode has been destroyed and a new one created with the same + // address. Ditch the old instance and fall through to create a new + // one. + instances.erase( it ); + } + } + + Ptr instance = new ContextTracker( script->getFocus(), script->context() ); + script->focusChangedSignal().connect( boost::bind( &ContextTracker::updateNode, instance.get(), ::_2 ) ); + instances.insert( { script, instance.get() } ); + return instance; +} + +void ContextTracker::updateNode( const Gaffer::NodePtr &node ) +{ + m_plugDirtiedConnection.disconnect(); + m_node = node; + if( m_node ) + { + m_plugDirtiedConnection = node->plugDirtiedSignal().connect( boost::bind( &ContextTracker::plugDirtied, this, ::_1 ) ); + } + + update(); +} + const Gaffer::Node *ContextTracker::targetNode() const { return m_node.get(); @@ -130,6 +244,11 @@ void ContextTracker::update() m_nodeContexts.clear(); m_plugContexts.clear(); + if( !m_node ) + { + return; + } + std::deque> toVisit; for( Plug::RecursiveOutputIterator it( m_node.get() ); !it.done(); ++it ) diff --git a/src/GafferUIModule/GraphGadgetBinding.cpp b/src/GafferUIModule/GraphGadgetBinding.cpp index 1344319ee2..e3f092a24e 100644 --- a/src/GafferUIModule/GraphGadgetBinding.cpp +++ b/src/GafferUIModule/GraphGadgetBinding.cpp @@ -54,6 +54,7 @@ #include "Gaffer/Context.h" #include "Gaffer/Node.h" +#include "Gaffer/ScriptNode.h" using namespace boost::python; using namespace IECorePython; @@ -349,6 +350,8 @@ void GafferUIModule::bindGraphGadget() IECorePython::RefCountedClass( "ContextTracker" ) .def( init() ) + .def( "acquire", &ContextTracker::acquire ).staticmethod( "acquire" ) + .def( "acquireForFocus", &ContextTracker::acquireForFocus ).staticmethod( "acquireForFocus" ) .def( "targetNode", &targetNodeWrapper ) .def( "targetContext", &targetContextWrapper ) .def( "isTracked", (bool (ContextTracker::*)( const Plug *plug ) const)&ContextTracker::isTracked ) From 397ac1baae259d7ad79693333e005e8454e837e8 Mon Sep 17 00:00:00 2001 From: John Haddon Date: Mon, 24 Jun 2024 16:43:20 +0100 Subject: [PATCH 5/9] ContextTracker : Add asynchronous update mechanism --- include/GafferUI/ContextTracker.h | 33 ++- python/GafferUITest/ContextTrackerTest.py | 269 +++++++++++++++++++--- src/GafferUI/ContextTracker.cpp | 201 +++++++++++++--- src/GafferUIModule/GraphGadgetBinding.cpp | 45 +++- 4 files changed, 473 insertions(+), 75 deletions(-) diff --git a/include/GafferUI/ContextTracker.h b/include/GafferUI/ContextTracker.h index 34e4c5c7c8..e880c00008 100644 --- a/include/GafferUI/ContextTracker.h +++ b/include/GafferUI/ContextTracker.h @@ -38,16 +38,20 @@ #include "GafferUI/Export.h" +#include "Gaffer/Context.h" #include "Gaffer/Set.h" #include "Gaffer/Signals.h" +#include "IECore/Canceller.h" #include "IECore/RefCounted.h" #include +#include namespace Gaffer { +class BackgroundTask; class Plug; class ScriptNode; IE_CORE_FORWARDDECLARE( Node ); @@ -99,8 +103,25 @@ class GAFFERUI_API ContextTracker final : public IECore::RefCounted, public Gaff const Gaffer::Node *targetNode() const; const Gaffer::Context *targetContext() const; + /// Update and signalling + /// ===================== + /// + /// Updates are performed asynchronously in background tasks so that + /// the UI is never blocked. Clients should connect to `changedSignal()` + /// to be notified when updates are complete. + + /// Returns true if an update is in-progress, in which case queries will + /// return stale values. + bool updatePending() const; + using Signal = Gaffer::Signals::Signal>; + /// Signal emitted when the results of any queries have changed. + Signal &changedSignal(); + /// Queries /// ======= + /// + /// Queries return immediately so will not block the UI waiting for computation. + /// But while `updatePending()` is `true` they will return stale values. /// Returns true if the specified plug or node contributes to the /// evaluation of the target. @@ -118,13 +139,18 @@ class GAFFERUI_API ContextTracker final : public IECore::RefCounted, public Gaff void updateNode( const Gaffer::NodePtr &node ); void plugDirtied( const Gaffer::Plug *plug ); void contextChanged( IECore::InternedString variable ); - void update(); + void scheduleUpdate(); + void updateInBackground(); const Gaffer::Context *findPlugContext( const Gaffer::Plug *plug ) const; Gaffer::ConstNodePtr m_node; Gaffer::ConstContextPtr m_context; Gaffer::Signals::ScopedConnection m_plugDirtiedConnection; + Gaffer::Signals::ScopedConnection m_idleConnection; + std::unique_ptr m_updateTask; + Signal m_changedSignal; + struct NodeData { Gaffer::ConstContextPtr context = nullptr; @@ -140,6 +166,11 @@ class GAFFERUI_API ContextTracker final : public IECore::RefCounted, public Gaff // Stores plug-specific contexts, which take precedence over `m_nodeContexts`. PlugContexts m_plugContexts; + static void visit( + std::deque> &toVisit, + NodeContexts &nodeContexts, PlugContexts &plugContexts, const IECore::Canceller *canceller + ); + }; IE_CORE_DECLAREPTR( ContextTracker ) diff --git a/python/GafferUITest/ContextTrackerTest.py b/python/GafferUITest/ContextTrackerTest.py index f1fc875916..807be81246 100644 --- a/python/GafferUITest/ContextTrackerTest.py +++ b/python/GafferUITest/ContextTrackerTest.py @@ -34,8 +34,12 @@ # ########################################################################## +import inspect +import threading import unittest +import IECore + import Gaffer import GafferTest import GafferUI @@ -43,6 +47,15 @@ class ContextTrackerTest( GafferUITest.TestCase ) : + class UpdateHandler( GafferTest.ParallelAlgoTest.UIThreadCallHandler ) : + + def __exit__( self, type, value, traceBack ) : + + GafferUITest.TestCase().waitForIdle() + self.assertCalled() + + GafferTest.ParallelAlgoTest.UIThreadCallHandler.__exit__( self, type, value, traceBack ) + def testSimpleNodes( self ) : script = Gaffer.ScriptNode() @@ -58,7 +71,8 @@ def testSimpleNodes( self ) : script["add4"]["op1"].setInput( script["add3"]["sum"] ) context = Gaffer.Context() - tracker = GafferUI.ContextTracker( script["add4"], context ) + with self.UpdateHandler() : + tracker = GafferUI.ContextTracker( script["add4"], context ) def assertExpectedContexts() : @@ -79,7 +93,8 @@ def assertExpectedContexts() : for graphComponent in [ script["unconnected"], script["unconnected"]["op1"], script["unconnected"]["op2"], script["unconnected"]["sum"], script["unconnected"]["enabled"] ] : self.assertFalse( tracker.isTracked( graphComponent ) ) - script["add3"]["enabled"].setValue( False ) + with self.UpdateHandler() : + script["add3"]["enabled"].setValue( False ) assertExpectedContexts( ) @@ -105,7 +120,8 @@ def testSwitch( self ) : script["switch"]["in"][1].setInput( script["add2"]["sum"] ) context = Gaffer.Context() - tracker = GafferUI.ContextTracker( script["switch"], context ) + with self.UpdateHandler() : + tracker = GafferUI.ContextTracker( script["switch"], context ) def assertExpectedContexts() : @@ -128,7 +144,8 @@ def assertExpectedContexts() : self.assertTrue( tracker.isTracked( script["add1"] ) ) self.assertFalse( tracker.isTracked( script["add2"] ) ) - script["switch"]["index"].setValue( 1 ) + with self.UpdateHandler() : + script["switch"]["index"].setValue( 1 ) assertExpectedContexts() @@ -141,7 +158,8 @@ def assertExpectedContexts() : self.assertFalse( tracker.isTracked( script["add1"] ) ) self.assertTrue( tracker.isTracked( script["add2"] ) ) - script["switch"]["enabled"].setValue( False ) + with self.UpdateHandler() : + script["switch"]["enabled"].setValue( False ) assertExpectedContexts() @@ -156,9 +174,10 @@ def assertExpectedContexts() : # Dynamic case - switch will compute input on the fly. - script["add3"] = GafferTest.AddNode() - script["switch"]["index"].setInput( script["add3"]["sum"] ) - script["switch"]["enabled"].setValue( True ) + with self.UpdateHandler() : + script["add3"] = GafferTest.AddNode() + script["switch"]["index"].setInput( script["add3"]["sum"] ) + script["switch"]["enabled"].setValue( True ) assertExpectedContexts() @@ -173,7 +192,8 @@ def assertExpectedContexts() : self.assertTrue( tracker.isTracked( script["add3"] ) ) self.assertEqual( tracker.context( script["add3"] ), context ) - script["add3"]["op1"].setValue( 1 ) + with self.UpdateHandler() : + script["add3"]["op1"].setValue( 1 ) assertExpectedContexts() @@ -204,7 +224,8 @@ def testNameSwitch( self ) : script["switch"]["in"][1]["name"].setValue( "add2" ) context = Gaffer.Context() - tracker = GafferUI.ContextTracker( script["switch"], context ) + with self.UpdateHandler() : + tracker = GafferUI.ContextTracker( script["switch"], context ) def assertExpectedContexts() : @@ -226,7 +247,8 @@ def assertExpectedContexts() : self.assertTrue( tracker.isTracked( script["add1"] ) ) self.assertFalse( tracker.isTracked( script["add2"] ) ) - script["switch"]["selector"].setValue( "add2" ) + with self.UpdateHandler() : + script["switch"]["selector"].setValue( "add2" ) assertExpectedContexts() @@ -238,7 +260,8 @@ def assertExpectedContexts() : self.assertFalse( tracker.isTracked( script["add1"] ) ) self.assertTrue( tracker.isTracked( script["add2"] ) ) - script["switch"]["enabled"].setValue( False ) + with self.UpdateHandler() : + script["switch"]["enabled"].setValue( False ) assertExpectedContexts() @@ -252,9 +275,10 @@ def assertExpectedContexts() : # Dynamic case - switch will compute input on the fly. - stringNode = GafferTest.StringInOutNode() - script["switch"]["selector"].setInput( stringNode["out"] ) - script["switch"]["enabled"].setValue( True ) + with self.UpdateHandler() : + stringNode = GafferTest.StringInOutNode() + script["switch"]["selector"].setInput( stringNode["out"] ) + script["switch"]["enabled"].setValue( True ) assertExpectedContexts() @@ -268,7 +292,8 @@ def assertExpectedContexts() : self.assertTrue( tracker.isTracked( stringNode ) ) self.assertEqual( tracker.context( stringNode ), context ) - stringNode["in"].setValue( "add2" ) + with self.UpdateHandler() : + stringNode["in"].setValue( "add2" ) assertExpectedContexts() @@ -306,7 +331,8 @@ def testMultipleActiveNameSwitchBranches( self ) : script["add3"]["op2"].setInput( script["contextVariables"]["out"] ) context = Gaffer.Context() - tracker = GafferUI.ContextTracker( script["add3"], context ) + with self.UpdateHandler() : + tracker = GafferUI.ContextTracker( script["add3"], context ) self.assertEqual( tracker.context( script["add3"] ), context ) self.assertEqual( tracker.context( script["switch"] ), context ) @@ -345,7 +371,8 @@ def testNameSwitchNamesAndEnabled( self ) : script["add6"]["op1"].setInput( script["switch"]["out"]["value"] ) context = Gaffer.Context() - tracker = GafferUI.ContextTracker( script["add6"], context ) + with self.UpdateHandler() : + tracker = GafferUI.ContextTracker( script["add6"], context ) # Default input `name` and `enabled` are never evaluated and `value` # isn't currently active. @@ -370,7 +397,8 @@ def testNameSwitchNamesAndEnabled( self ) : self.assertFalse( tracker.isTracked( script["switch"]["in"][4]["name"] ) ) self.assertFalse( tracker.isTracked( script["switch"]["in"][4]["value"] ) ) - script["switch"]["enabled"].setValue( False ) + with self.UpdateHandler() : + script["switch"]["enabled"].setValue( False ) for plug in list( Gaffer.NameValuePlug.Range( script["switch"]["in"] ) ) : self.assertFalse( tracker.isTracked( plug["name"] ), plug["name"].fullName() ) @@ -392,7 +420,8 @@ def testContextProcessors( self ) : script["contextVariables"]["in"].setInput( script["add"]["sum"] ) context = Gaffer.Context() - tracker = GafferUI.ContextTracker( script["contextVariables"], context ) + with self.UpdateHandler() : + tracker = GafferUI.ContextTracker( script["contextVariables"], context ) self.assertTrue( tracker.isTracked( script["contextVariables"] ) ) self.assertTrue( tracker.isTracked( script["contextVariables"]["enabled"] ) ) @@ -401,7 +430,8 @@ def testContextProcessors( self ) : self.assertTrue( tracker.isTracked( script["add"] ) ) self.assertEqual( tracker.context( script["add"] ), context ) - script["contextVariables"]["variables"].addChild( Gaffer.NameValuePlug( "test", 2 ) ) + with self.UpdateHandler() : + script["contextVariables"]["variables"].addChild( Gaffer.NameValuePlug( "test", 2 ) ) self.assertTrue( tracker.isTracked( script["contextVariables"] ) ) self.assertTrue( tracker.isTracked( script["contextVariables"]["enabled"] ) ) @@ -413,7 +443,8 @@ def testContextProcessors( self ) : self.assertTrue( tracker.isTracked( script["add"] ) ) self.assertEqual( tracker.context( script["add"] ), script["contextVariables"].inPlugContext() ) - script["contextVariables"]["enabled"].setValue( False ) + with self.UpdateHandler() : + script["contextVariables"]["enabled"].setValue( False ) self.assertTrue( tracker.isTracked( script["contextVariables"] ) ) self.assertTrue( tracker.isTracked( script["contextVariables"]["enabled"] ) ) @@ -438,7 +469,8 @@ def testContextForInactiveInputs( self ) : script["contextVariables"]["variables"].addChild( Gaffer.NameValuePlug( "test", 2 ) ) context = Gaffer.Context() - tracker = GafferUI.ContextTracker( script["contextVariables"], context ) + with self.UpdateHandler() : + tracker = GafferUI.ContextTracker( script["contextVariables"], context ) # Even though `op2` is inactive, it still makes most sense to evaluate it # in the modified context, because that is the context it will be active in @@ -455,7 +487,8 @@ def testPlugWithoutNode( self ) : script["node"]["op1"].setInput( plug ) context = Gaffer.Context() - tracker = GafferUI.ContextTracker( script["node"], context ) + with self.UpdateHandler() : + tracker = GafferUI.ContextTracker( script["node"], context ) self.assertTrue( tracker.isTracked( script["node"] ) ) self.assertEqual( tracker.context( script["node"] ), context ) @@ -483,7 +516,8 @@ def testLoop( self ) : self.assertEqual( script["loop"]["out"].getValue(), 20 ) context = Gaffer.Context() - tracker = GafferUI.ContextTracker( script["loop"], context ) + with self.UpdateHandler() : + tracker = GafferUI.ContextTracker( script["loop"], context ) self.assertTrue( tracker.isTracked( script["loop"] ) ) self.assertEqual( tracker.context( script["loop"] ), context ) @@ -514,11 +548,13 @@ def assertDisabledLoop() : self.assertFalse( tracker.isTracked( script["loopBody"] ) ) self.assertEqual( tracker.context( script["loopBody"] ), context ) - script["loop"]["enabled"].setValue( False ) + with self.UpdateHandler() : + script["loop"]["enabled"].setValue( False ) assertDisabledLoop() - script["loop"]["enabled"].setValue( True ) - script["loop"]["iterations"].setValue( 0 ) + with self.UpdateHandler() : + script["loop"]["enabled"].setValue( True ) + script["loop"]["iterations"].setValue( 0 ) assertDisabledLoop() def testLoopEvaluatesAllIterations( self ) : @@ -556,7 +592,8 @@ def testLoopEvaluatesAllIterations( self ) : self.assertEqual( script["loop"]["out"].getValue(), sum( range( 0, iterations ) ) ) context = Gaffer.Context() - tracker = GafferUI.ContextTracker( script["loop"], context ) + with self.UpdateHandler() : + tracker = GafferUI.ContextTracker( script["loop"], context ) for i in range( 0, iterations ) : switchInput = script["loopSwitch"]["in"][i] @@ -595,7 +632,8 @@ def testMultiplexedBox( self ) : script["resultB"]["op1"].setInput( script["box"]["sumB"] ) context = Gaffer.Context() - tracker = GafferUI.ContextTracker( script["resultA"], context ) + with self.UpdateHandler() : + tracker = GafferUI.ContextTracker( script["resultA"], context ) self.assertTrue( tracker.isTracked( script["resultA"] ) ) self.assertFalse( tracker.isTracked( script["resultB"] ) ) @@ -616,12 +654,14 @@ def testAcquire( self ) : script["add1"] = GafferTest.AddNode() script["add2"] = GafferTest.AddNode() - tracker1 = GafferUI.ContextTracker.acquire( script["add1"] ) + with self.UpdateHandler() : + tracker1 = GafferUI.ContextTracker.acquire( script["add1"] ) self.assertTrue( tracker1.isSame( GafferUI.ContextTracker.acquire( script["add1"] ) ) ) self.assertTrue( tracker1.isTracked( script["add1"] ) ) self.assertFalse( tracker1.isTracked( script["add2"] ) ) - tracker2 = GafferUI.ContextTracker.acquire( script["add2"] ) + with self.UpdateHandler() : + tracker2 = GafferUI.ContextTracker.acquire( script["add2"] ) self.assertTrue( tracker2.isSame( GafferUI.ContextTracker.acquire( script["add2"] ) ) ) self.assertTrue( tracker2.isTracked( script["add2"] ) ) self.assertFalse( tracker2.isTracked( script["add1"] ) ) @@ -634,7 +674,8 @@ def testAcquireLifetime( self ) : nodeSlots = script["node"].plugDirtiedSignal().numSlots() nodeRefCount = script["node"].refCount() - tracker = GafferUI.ContextTracker.acquire( script["node"] ) + with self.UpdateHandler() : + tracker = GafferUI.ContextTracker.acquire( script["node"] ) del tracker # Indicates that `tracker` was truly destroyed. @@ -642,7 +683,8 @@ def testAcquireLifetime( self ) : self.assertEqual( script["node"].refCount(), nodeRefCount ) # Should be a whole new instance. - tracker = GafferUI.ContextTracker.acquire( script["node"] ) + with self.UpdateHandler() : + tracker = GafferUI.ContextTracker.acquire( script["node"] ) self.assertTrue( tracker.isTracked( script["node"] ) ) def testAcquireForFocus( self ) : @@ -658,11 +700,13 @@ def testAcquireForFocus( self ) : self.assertFalse( tracker.isTracked( script["add1" ] ) ) self.assertFalse( tracker.isTracked( script["add2" ] ) ) - script.setFocus( script["add1"] ) + with self.UpdateHandler() : + script.setFocus( script["add1"] ) self.assertTrue( tracker.isTracked( script["add1" ] ) ) self.assertFalse( tracker.isTracked( script["add2" ] ) ) - script.setFocus( script["add2"] ) + with self.UpdateHandler() : + script.setFocus( script["add2"] ) self.assertFalse( tracker.isTracked( script["add1" ] ) ) self.assertTrue( tracker.isTracked( script["add2" ] ) ) @@ -686,7 +730,8 @@ def testAcquireForFocusLifetime( self ) : # Should be a whole new instance. script["node"] = GafferTest.MultiplyNode() script.setFocus( script["node"] ) - tracker = GafferUI.ContextTracker.acquireForFocus( script ) + with self.UpdateHandler() : + tracker = GafferUI.ContextTracker.acquireForFocus( script ) self.assertTrue( tracker.isTracked( script["node"] ) ) def testAcquireNone( self ) : @@ -706,5 +751,155 @@ def testAcquireNone( self ) : self.assertEqual( tracker1.context( node ), tracker1.targetContext() ) self.assertEqual( tracker1.context( node["sum"] ), tracker1.targetContext() ) + def testCancellation( self ) : + + script = Gaffer.ScriptNode() + script["node"] = GafferTest.AddNode() + + ContextTrackerTest.expressionStartedCondition = threading.Condition() + + script["expression"] = Gaffer.Expression() + script["expression"].setExpression( inspect.cleandoc( + """ + import IECore + import GafferUITest + + if context.get( "waitForCancellation", True ) : + + # Let the test know the expression has started running. + with GafferUITest.ContextTrackerTest.expressionStartedCondition : + GafferUITest.ContextTrackerTest.expressionStartedCondition.notify() + + # Loop forever unless we're cancelled + while True : + IECore.Canceller.check( context.canceller() ) + + parent["node"]["enabled"] = True + """ + ) ) + + # Start an update, and wait for the expression to start on the + # background thread. + + context = Gaffer.Context() + with ContextTrackerTest.expressionStartedCondition : + tracker = GafferUI.ContextTracker( script["node"], context ) + self.waitForIdle() + ContextTrackerTest.expressionStartedCondition.wait() + + # The update won't have completed because the expression is stuck. + self.assertFalse( tracker.isTracked( script["node"] ) ) + + # Make a graph edit that will cancel the expression and restart + # the background task. + + with ContextTrackerTest.expressionStartedCondition : + with GafferTest.ParallelAlgoTest.UIThreadCallHandler() as handler : + script["node"]["op1"].setValue( 1 ) + handler.assertCalled() # Handle UI thread call made when background task detects cancellation. + self.waitForIdle() # Handle idle event used to restart update. + ContextTrackerTest.expressionStartedCondition.wait() + + # Again, the update won't have completed because the expression is stuck. + self.assertFalse( tracker.isTracked( script["node"] ) ) + + # Make a context edit that will cancel the expression and restart the + # background task. + + with self.UpdateHandler() as handler : + context["waitForCancellation"] = False + # Handles UI thread call made when background task is cancelled. + handler.assertCalled() + # `handler.__exit__()` then handles the events needed to restart + # and successfully complete the update. + + # This time we expect the update to have finished successfully. + + self.assertTrue( tracker.isTracked( script["node"] ) ) + + def testNoCancellersInCapturedContexts( self ) : + + script = Gaffer.ScriptNode() + script["add"] = GafferTest.AddNode() + script["contextVariables"] = Gaffer.ContextVariables() + script["contextVariables"].setup( script["add"]["sum"] ) + script["contextVariables"]["in"].setInput( script["add"]["sum"] ) + script["contextVariables"].addChild( Gaffer.NameValuePlug( "test", "test" ) ) + + context = Gaffer.Context() + with self.UpdateHandler() : + tracker = GafferUI.ContextTracker( script["contextVariables"], context ) + + for g in Gaffer.GraphComponent.Range( script ) : + self.assertIsNone( tracker.context( g ).canceller(), g.fullName() ) + + def testBadChangedSlot( self ) : + + script = Gaffer.ScriptNode() + script["add"] = GafferTest.AddNode() + + context = Gaffer.Context() + with self.UpdateHandler() : + tracker = GafferUI.ContextTracker( script["add"], context ) + + callsMade = 0 + def slot( tracker ) : + + nonlocal callsMade + callsMade += 1 + if callsMade == 2 : + raise RuntimeError( "Bad callback" ) + + for i in range( 0, 10 ) : + tracker.changedSignal().connect( slot, scoped = False ) + + with IECore.CapturingMessageHandler() as mh : + with self.UpdateHandler() : + script["add"]["op1"].setValue( 1 ) + + # One bad slot in the middle shouldn't prevent other slots being + # invoked. Instead, the error should just be reported as a message. + + self.assertEqual( callsMade, 10 ) + self.assertEqual( len( mh.messages ), 1 ) + self.assertIn( "RuntimeError: Bad callback", mh.messages[0].message ) + + def testDeleteDuringUpdate( self ) : + + for i in range( 0, 10 ) : + + script = Gaffer.ScriptNode() + script["add"] = GafferTest.AddNode() + + ContextTrackerTest.expressionStartedCondition = threading.Condition() + + script["expression"] = Gaffer.Expression() + script["expression"].setExpression( + inspect.cleandoc( + """ + import GafferUITest + with GafferUITest.ContextTrackerTest.expressionStartedCondition : + GafferUITest.ContextTrackerTest.expressionStartedCondition.notify() + + # Loop forever unless we're cancelled + while True : + IECore.Canceller.check( context.canceller() ) + + parent["add"]["enabled"] = True + """ + ) + ) + + context = Gaffer.Context() + + with ContextTrackerTest.expressionStartedCondition : + tracker = GafferUI.ContextTracker( script["add"], context ) + # Wait for the background update to start. + GafferUITest.TestCase().waitForIdle() + ContextTrackerTest.expressionStartedCondition.wait() + + # Blow everything away while the background update is still going on. + del tracker + if __name__ == "__main__": unittest.main() diff --git a/src/GafferUI/ContextTracker.cpp b/src/GafferUI/ContextTracker.cpp index 8369c936a6..8c0ab75e84 100644 --- a/src/GafferUI/ContextTracker.cpp +++ b/src/GafferUI/ContextTracker.cpp @@ -36,10 +36,15 @@ #include "GafferUI/ContextTracker.h" +#include "GafferUI/Gadget.h" + +#include "Gaffer/BackgroundTask.h" #include "Gaffer/Context.h" #include "Gaffer/ContextVariables.h" #include "Gaffer/Loop.h" #include "Gaffer/NameSwitch.h" +#include "Gaffer/ParallelAlgo.h" +#include "Gaffer/Process.h" #include "Gaffer/ScriptNode.h" #include "Gaffer/Switch.h" @@ -122,6 +127,7 @@ ContextTracker::~ContextTracker() sharedInstances().get<1>().erase( this ); sharedFocusInstances().get<1>().erase( this ); disconnectTrackedConnections(); + m_updateTask.reset(); } ContextTrackerPtr ContextTracker::acquire( const Gaffer::NodePtr &node ) @@ -172,6 +178,11 @@ ContextTrackerPtr ContextTracker::acquireForFocus( Gaffer::ScriptNode *script ) void ContextTracker::updateNode( const Gaffer::NodePtr &node ) { + if( node == m_node ) + { + return; + } + m_plugDirtiedConnection.disconnect(); m_node = node; if( m_node ) @@ -179,7 +190,7 @@ void ContextTracker::updateNode( const Gaffer::NodePtr &node ) m_plugDirtiedConnection = node->plugDirtiedSignal().connect( boost::bind( &ContextTracker::plugDirtied, this, ::_1 ) ); } - update(); + scheduleUpdate(); } const Gaffer::Node *ContextTracker::targetNode() const @@ -192,6 +203,145 @@ const Gaffer::Context *ContextTracker::targetContext() const return m_context.get(); } +void ContextTracker::scheduleUpdate() +{ + // Cancel old update. + m_updateTask.reset(); + + if( !m_node ) + { + // Don't need a BackgroundTask, so just do the update directly on the UI + // thread. + m_nodeContexts.clear(); + m_plugContexts.clear(); + m_idleConnection.disconnect(); + changedSignal()( *this ); + return; + } + + if( !m_node->scriptNode() ) + { + // ScriptNode is dying. Can't use a BackgroundTask and no need for + // update anyway. + m_idleConnection.disconnect(); + return; + } + + if( m_idleConnection.connected() ) + { + // Update already scheduled. + return; + } + + // Arrange to do the update on the next idle event. This allows us to avoid + // redundant restarts when `plugDirtied()` or `contextChanged()` is called + // multiple times in quick succession. + + m_idleConnection = Gadget::idleSignal().connect( + [thisRef = Ptr( this )] () { + thisRef->updateInBackground(); + } + ); +} + +void ContextTracker::updateInBackground() +{ + m_idleConnection.disconnect(); + + // Seed the list of plugs to visit with all the outputs of our node. + // We must take a copy of the context for this, because it will be used + // on the background thread and the original context may be modified on + // the main thread. + ConstContextPtr contextCopy = new Context( *m_context ); + std::deque> toVisit; + if( m_node ) + { + for( Plug::RecursiveOutputIterator it( m_node.get() ); !it.done(); ++it ) + { + toVisit.push_back( { it->get(), contextCopy } ); + it.prune(); + } + } + + Context::Scope scopedContext( contextCopy.get() ); + m_updateTask = ParallelAlgo::callOnBackgroundThread( + + /* subject = */ toVisit.empty() ? nullptr : toVisit.back().first, + + // OK to capture `this` without incrementing reference count, because + // ~UpstreamContext cancels background task and waits for it to + // complete. Therefore `this` will always outlive the task. + [toVisit, this] () mutable { + + PlugContexts plugContexts; + NodeContexts nodeContexts; + + // Alias for `this` to work around MSVC bug that prevents capturing + // `this` again in a nested lambda. + ContextTracker *that = this; + + try + { + ContextTracker::visit( toVisit, nodeContexts, plugContexts, Context::current()->canceller() ); + } + catch( const IECore::Cancelled & ) + { + // Cancellation could be for several reasons : + // + // 1. A graph edit is being made. + // 2. The context has changed or `updateNode()` has been called + // and we're scheduling a new update. + // 3. Our reference count has dropped to 0 and we're being + // deleted, and are cancelling the task from our destructor. + // + // In the first two cases we need to schedule a new update, but + // in the last case we mustn't do anything. + if( refCount() ) + { + ParallelAlgo::callOnUIThread( + // Need to own a reference via `thisRef`, because otherwise we could be deleted + // before `callOnUIThread()` gets to us. + [thisRef = Ptr( that )] () { + thisRef->m_updateTask.reset(); + thisRef->scheduleUpdate(); + } + ); + } + throw; + } + catch( const Gaffer::ProcessException &e ) + { + IECore::msg( IECore::Msg::Error, "ContextTracker::updateInBackground", e.what() ); + } + + if( refCount() ) + { + ParallelAlgo::callOnUIThread( + // Need to own a reference via `thisRef`, because otherwise we could be deleted + // before `callOnUIThread()` gets to us. + [thisRef = Ptr( that ), plugContexts = std::move( plugContexts ), nodeContexts = std::move( nodeContexts )] () mutable { + thisRef->m_nodeContexts.swap( nodeContexts ); + thisRef->m_plugContexts.swap( plugContexts ); + thisRef->m_updateTask.reset(); + thisRef->changedSignal()( *thisRef ); + } + ); + } + } + + ); +} + +bool ContextTracker::updatePending() const +{ + return m_idleConnection.connected() || m_updateTask; +} + +ContextTracker::Signal &ContextTracker::changedSignal() +{ + return m_changedSignal; +} + bool ContextTracker::isTracked( const Gaffer::Plug *plug ) const { if( findPlugContext( plug ) ) @@ -231,38 +381,30 @@ Gaffer::ConstContextPtr ContextTracker::context( const Gaffer::Node *node ) cons void ContextTracker::plugDirtied( const Gaffer::Plug *plug ) { - update(); + if( plug->direction() == Plug::Out ) + { + scheduleUpdate(); + } } void ContextTracker::contextChanged( IECore::InternedString variable ) { - update(); -} - -void ContextTracker::update() -{ - m_nodeContexts.clear(); - m_plugContexts.clear(); - - if( !m_node ) + if( !boost::starts_with( variable.string(), "ui:" ) ) { - return; - } - - std::deque> toVisit; - - for( Plug::RecursiveOutputIterator it( m_node.get() ); !it.done(); ++it ) - { - toVisit.push_back( { it->get(), m_context } ); - it.prune(); + scheduleUpdate(); } +} +void ContextTracker::visit( std::deque> &toVisit, NodeContexts &nodeContexts, PlugContexts &plugContexts, const IECore::Canceller *canceller ) +{ std::unordered_set visited; while( !toVisit.empty() ) { // Get next plug to visit, and early out if we've already visited it in - // this context. + // this context or if we have been cancelled. + + IECore::Canceller::check( canceller ); auto [plug, context] = toVisit.front(); toVisit.pop_front(); @@ -277,8 +419,10 @@ void ContextTracker::update() // If this is the first time we have visited the node and/or plug, then // record the context. + assert( !context->canceller() ); + const Node *node = plug->node(); - NodeData &nodeData = m_nodeContexts[node]; + NodeData &nodeData = nodeContexts[node]; if( !nodeData.context ) { nodeData.context = context; @@ -286,7 +430,7 @@ void ContextTracker::update() if( !node || plug->direction() == Plug::Out || !nodeData.allInputsActive || *context != *nodeData.context ) { - m_plugContexts.insert( { plug, context } ); + plugContexts.insert( { plug, context } ); } // Arrange to visit any inputs to this plug, including @@ -316,7 +460,12 @@ void ContextTracker::update() continue; } - Context::Scope scopedContext( context.get() ); + // Scope the context we're visiting before evaluating any plug + // values. We store contexts without a canceller (ready to return + // from `ContextTracker::context()`), so must also scope the canceller + // to allow any computes we trigger to be cancelled. + Context::EditableScope scopedContext( context.get() ); + scopedContext.setCanceller( canceller ); if( plug->getInput() ) { @@ -412,7 +561,7 @@ void ContextTracker::update() nodeData.allInputsActive = true; // Visit main input in processed context. ConstContextPtr inContext = contextProcessor->inPlugContext(); - toVisit.push_back( { contextProcessor->inPlug(), inContext } ); + toVisit.push_back( { contextProcessor->inPlug(), new Context( *inContext, /* omitCanceller = */ true ) } ); } continue; } @@ -429,7 +578,7 @@ void ContextTracker::update() { toVisit.push_back( { loop->iterationsPlug(), context } ); } - toVisit.push_back( { previousPlug, previousContext } ); + toVisit.push_back( { previousPlug, new Context( *previousContext, /* omitCanceller = */ true ) } ); } } continue; diff --git a/src/GafferUIModule/GraphGadgetBinding.cpp b/src/GafferUIModule/GraphGadgetBinding.cpp index e3f092a24e..31f647f711 100644 --- a/src/GafferUIModule/GraphGadgetBinding.cpp +++ b/src/GafferUIModule/GraphGadgetBinding.cpp @@ -59,6 +59,7 @@ using namespace boost::python; using namespace IECorePython; using namespace Gaffer; +using namespace GafferBindings; using namespace GafferUI; using namespace GafferUIBindings; @@ -233,6 +234,21 @@ ContextPtr targetContextWrapper( const ContextTracker &contextTracker ) return const_cast( contextTracker.targetContext() ); } +struct ContextTrackerSlotCaller +{ + void operator()( boost::python::object slot, ContextTracker &contextTracker ) + { + try + { + slot( ContextTrackerPtr( &contextTracker ) ); + } + catch( const boost::python::error_already_set & ) + { + ExceptionAlgo::translatePythonException(); + } + } +}; + ContextPtr contextWrapper1( const ContextTracker &contextTracker, const Node &node, bool copy = false ) { ConstContextPtr c = contextTracker.context( &node ); @@ -348,16 +364,23 @@ void GafferUIModule::bindGraphGadget() .def( "getNodeSeparationScale", &StandardGraphLayout::getNodeSeparationScale ) ; - IECorePython::RefCountedClass( "ContextTracker" ) - .def( init() ) - .def( "acquire", &ContextTracker::acquire ).staticmethod( "acquire" ) - .def( "acquireForFocus", &ContextTracker::acquireForFocus ).staticmethod( "acquireForFocus" ) - .def( "targetNode", &targetNodeWrapper ) - .def( "targetContext", &targetContextWrapper ) - .def( "isTracked", (bool (ContextTracker::*)( const Plug *plug ) const)&ContextTracker::isTracked ) - .def( "isTracked", (bool (ContextTracker::*)( const Node *node ) const)&ContextTracker::isTracked ) - .def( "context", &contextWrapper1, ( arg( "node" ), arg( "_copy" ) = true ) ) - .def( "context", &contextWrapper2, ( arg( "plug" ), arg( "_copy" ) = true ) ) - ; + { + scope s = IECorePython::RefCountedClass( "ContextTracker" ) + .def( init() ) + .def( "acquire", &ContextTracker::acquire ).staticmethod( "acquire" ) + .def( "acquireForFocus", &ContextTracker::acquireForFocus ).staticmethod( "acquireForFocus" ) + .def( "targetNode", &targetNodeWrapper ) + .def( "targetContext", &targetContextWrapper ) + .def( "isTracked", (bool (ContextTracker::*)( const Plug *plug ) const)&ContextTracker::isTracked ) + .def( "isTracked", (bool (ContextTracker::*)( const Node *node ) const)&ContextTracker::isTracked ) + .def( "context", &contextWrapper1, ( arg( "node" ), arg( "_copy" ) = true ) ) + .def( "context", &contextWrapper2, ( arg( "plug" ), arg( "_copy" ) = true ) ) + .def( "updatePending", &ContextTracker::updatePending ) + .def( "changedSignal", &ContextTracker::changedSignal, return_internal_reference<1>() ) + ; + + SignalClass, ContextTrackerSlotCaller>( "Signal" ); + + } } From 6bcdc6da89464d6e583f5f6eb6a7d98dd0d3898b Mon Sep 17 00:00:00 2001 From: John Haddon Date: Fri, 19 Jul 2024 16:25:44 +0100 Subject: [PATCH 6/9] ContextTracker : Add `isEnabled()` method We're going to evaluate the value of this plug anyway, and several clients can benefit from not having to repeat that evaluation (in their own background task) so it makes sense to provide it directly as a non-blocking method on the UI thread. --- include/GafferUI/ContextTracker.h | 6 ++ python/GafferUITest/ContextTrackerTest.py | 90 +++++++++++++++++++++++ src/GafferUI/ContextTracker.cpp | 31 ++++++-- src/GafferUIModule/GraphGadgetBinding.cpp | 2 + 4 files changed, 122 insertions(+), 7 deletions(-) diff --git a/include/GafferUI/ContextTracker.h b/include/GafferUI/ContextTracker.h index e880c00008..f65294af01 100644 --- a/include/GafferUI/ContextTracker.h +++ b/include/GafferUI/ContextTracker.h @@ -52,6 +52,7 @@ namespace Gaffer { class BackgroundTask; +class DependencyNode; class Plug; class ScriptNode; IE_CORE_FORWARDDECLARE( Node ); @@ -134,6 +135,10 @@ class GAFFERUI_API ContextTracker final : public IECore::RefCounted, public Gaff Gaffer::ConstContextPtr context( const Gaffer::Plug *plug ) const; Gaffer::ConstContextPtr context( const Gaffer::Node *node ) const; + /// If the node is tracked, returns the value of `node->enabledPlug()` + /// in `context( node )`. If the node is not tracked, returns `false`. + bool isEnabled( const Gaffer::DependencyNode *node ) const; + private : void updateNode( const Gaffer::NodePtr &node ); @@ -154,6 +159,7 @@ class GAFFERUI_API ContextTracker final : public IECore::RefCounted, public Gaff struct NodeData { Gaffer::ConstContextPtr context = nullptr; + bool dependencyNodeEnabled = false; // If `true`, then all input plugs on the node are assumed to be // active in the Node's context. This is just an optimisation that // allows us to keep the size of `m_plugContexts` to a minimum. diff --git a/python/GafferUITest/ContextTrackerTest.py b/python/GafferUITest/ContextTrackerTest.py index 807be81246..6603c0a135 100644 --- a/python/GafferUITest/ContextTrackerTest.py +++ b/python/GafferUITest/ContextTrackerTest.py @@ -901,5 +901,95 @@ def testDeleteDuringUpdate( self ) : # Blow everything away while the background update is still going on. del tracker + def testIsEnabled( self ) : + + # + # contextQuery -> add1 + # / \ + # | | + # contextVariables1 contextVariables2 + # | | + # \ / + # add2 + # + + script = Gaffer.ScriptNode() + + script["contextQuery"] = Gaffer.ContextQuery() + queryPlug = script["contextQuery"].addQuery( Gaffer.BoolPlug() ) + queryPlug["name"].setValue( "enabler" ) + queryPlug["value"].setValue( True ) + + script["add1"] = GafferTest.AddNode() + script["add1"]["enabled"].setInput( script["contextQuery"].outPlugFromQueryPlug( queryPlug )["value"] ) + + script["contextVariables1"] = Gaffer.ContextVariables() + script["contextVariables1"].setup( script["add1"]["sum" ] ) + script["contextVariables1"]["in"].setInput( script["add1"]["sum"] ) + + script["contextVariables2"] = Gaffer.ContextVariables() + script["contextVariables2"].setup( script["add1"]["sum" ] ) + script["contextVariables2"]["in"].setInput( script["add1"]["sum"] ) + + script["add2"] = GafferTest.AddNode() + script["add2"]["op1"].setInput( script["contextVariables1"]["out"] ) + script["add2"]["op2"].setInput( script["contextVariables2"]["out"] ) + + context = Gaffer.Context() + with self.UpdateHandler() : + tracker = GafferUI.ContextTracker( script["add2"], context ) + + # Everything is enabled. + + self.assertTrue( tracker.isEnabled( script["add1"] ) ) + self.assertTrue( tracker.isEnabled( script["contextVariables1"] ) ) + self.assertTrue( tracker.isEnabled( script["contextVariables2"] ) ) + self.assertTrue( tracker.isEnabled( script["add2"] ) ) + + # `add1` is disabled for both branches. + + with self.UpdateHandler() : + context["enabler"] = False + + self.assertFalse( tracker.isEnabled( script["add1"] ) ) + self.assertTrue( tracker.isEnabled( script["contextVariables1"] ) ) + self.assertTrue( tracker.isEnabled( script["contextVariables2"] ) ) + self.assertTrue( tracker.isEnabled( script["add2"] ) ) + + # Re-enable `add1` but only on the second branch. This is + # the second context visited, so `add1` is still reported + # as disabled. + + with self.UpdateHandler() : + script["contextVariables2"]["variables"].addChild( Gaffer.NameValuePlug( "enabler", True ) ) + + self.assertFalse( tracker.isEnabled( script["add1"] ) ) + self.assertTrue( tracker.isEnabled( script["contextVariables1"] ) ) + self.assertTrue( tracker.isEnabled( script["contextVariables2"] ) ) + self.assertTrue( tracker.isEnabled( script["add2"] ) ) + + # Re-enabled on the first branch too. Now `add1` is reported as enabled. + + with self.UpdateHandler() : + script["contextVariables1"]["variables"].addChild( Gaffer.NameValuePlug( "enabler", True ) ) + + self.assertTrue( tracker.isEnabled( script["add1"] ) ) + self.assertTrue( tracker.isEnabled( script["contextVariables1"] ) ) + self.assertTrue( tracker.isEnabled( script["contextVariables2"] ) ) + self.assertTrue( tracker.isEnabled( script["add2"] ) ) + + def testIsEnabledWithUntrackedNodes( self ) : + + script = Gaffer.ScriptNode() + + script["tracked"] = GafferTest.AddNode() + script["untracked"] = GafferTest.AddNode() + + with self.UpdateHandler() : + tracker = GafferUI.ContextTracker( script["tracked"], script.context() ) + + self.assertTrue( tracker.isEnabled( script["tracked" ] ) ) + self.assertFalse( tracker.isEnabled( script["untracked" ] ) ) + if __name__ == "__main__": unittest.main() diff --git a/src/GafferUI/ContextTracker.cpp b/src/GafferUI/ContextTracker.cpp index 8c0ab75e84..2dfb2192a2 100644 --- a/src/GafferUI/ContextTracker.cpp +++ b/src/GafferUI/ContextTracker.cpp @@ -44,6 +44,7 @@ #include "Gaffer/Loop.h" #include "Gaffer/NameSwitch.h" #include "Gaffer/ParallelAlgo.h" +#include "Gaffer/PlugAlgo.h" #include "Gaffer/Process.h" #include "Gaffer/ScriptNode.h" #include "Gaffer/Switch.h" @@ -379,6 +380,16 @@ Gaffer::ConstContextPtr ContextTracker::context( const Gaffer::Node *node ) cons return it != m_nodeContexts.end() ? it->second.context : m_context; } +bool ContextTracker::isEnabled( const Gaffer::DependencyNode *node ) const +{ + auto it = m_nodeContexts.find( node ); + if( it != m_nodeContexts.end() ) + { + return it->second.dependencyNodeEnabled; + } + return false; +} + void ContextTracker::plugDirtied( const Gaffer::Plug *plug ) { if( plug->direction() == Plug::Out ) @@ -416,6 +427,14 @@ void ContextTracker::visit( std::deque> continue; } + // Scope the context we're visiting before evaluating any plug + // values. We store contexts without a canceller (ready to return + // from `ContextTracker::context()`), so must also scope the canceller + // to allow any computes we trigger to be cancelled. + + Context::EditableScope scopedContext( context.get() ); + scopedContext.setCanceller( canceller ); + // If this is the first time we have visited the node and/or plug, then // record the context. @@ -426,6 +445,11 @@ void ContextTracker::visit( std::deque> if( !nodeData.context ) { nodeData.context = context; + if( auto dependencyNode = runTimeCast( node ) ) + { + auto enabledPlug = dependencyNode->enabledPlug(); + nodeData.dependencyNodeEnabled = enabledPlug ? enabledPlug->getValue() : true; + } } if( !node || plug->direction() == Plug::Out || !nodeData.allInputsActive || *context != *nodeData.context ) @@ -460,13 +484,6 @@ void ContextTracker::visit( std::deque> continue; } - // Scope the context we're visiting before evaluating any plug - // values. We store contexts without a canceller (ready to return - // from `ContextTracker::context()`), so must also scope the canceller - // to allow any computes we trigger to be cancelled. - Context::EditableScope scopedContext( context.get() ); - scopedContext.setCanceller( canceller ); - if( plug->getInput() ) { // The plug value isn't computed, so we _should_ be done. But diff --git a/src/GafferUIModule/GraphGadgetBinding.cpp b/src/GafferUIModule/GraphGadgetBinding.cpp index 31f647f711..8f3db2238b 100644 --- a/src/GafferUIModule/GraphGadgetBinding.cpp +++ b/src/GafferUIModule/GraphGadgetBinding.cpp @@ -53,6 +53,7 @@ #include "GafferBindings/SignalBinding.h" #include "Gaffer/Context.h" +#include "Gaffer/DependencyNode.h" #include "Gaffer/Node.h" #include "Gaffer/ScriptNode.h" @@ -375,6 +376,7 @@ void GafferUIModule::bindGraphGadget() .def( "isTracked", (bool (ContextTracker::*)( const Node *node ) const)&ContextTracker::isTracked ) .def( "context", &contextWrapper1, ( arg( "node" ), arg( "_copy" ) = true ) ) .def( "context", &contextWrapper2, ( arg( "plug" ), arg( "_copy" ) = true ) ) + .def( "isEnabled", &ContextTracker::isEnabled ) .def( "updatePending", &ContextTracker::updatePending ) .def( "changedSignal", &ContextTracker::changedSignal, return_internal_reference<1>() ) ; From 608216c379e0ee4a56e839580c4aeef96b7553c9 Mon Sep 17 00:00:00 2001 From: John Haddon Date: Thu, 20 Jun 2024 15:55:32 +0100 Subject: [PATCH 7/9] EditScopePlugValueWidget : Filter available nodes using ContextTracker --- Changes.md | 1 + python/GafferUI/EditScopeUI.py | 138 +++++++++++++++++++++++++-------- 2 files changed, 105 insertions(+), 34 deletions(-) diff --git a/Changes.md b/Changes.md index 86a37a7777..8d3c0faae9 100644 --- a/Changes.md +++ b/Changes.md @@ -6,6 +6,7 @@ Improvements - LightEditor : Values of inherited attributes are now displayed in the Light Editor. These are presented as dimmed "fallback" values. - LightEditor, RenderPassEditor : Fallback values shown in the history window are displayed with the same dimmed text colour used for fallback values in editor columns. +- EditScope : Filtered the EditScope menu to show only nodes that are active in the relevant context. Fixes ----- diff --git a/python/GafferUI/EditScopeUI.py b/python/GafferUI/EditScopeUI.py index 7f0dc3e8ab..aa038021d9 100644 --- a/python/GafferUI/EditScopeUI.py +++ b/python/GafferUI/EditScopeUI.py @@ -150,17 +150,38 @@ def __init__( self, plug, **kw ) : # run the default dropSignal handler from PlugValueWidget. self.dropSignal().connectFront( Gaffer.WeakMethod( self.__drop ), scoped = False ) + self.__updatePlugInputChangedConnection() + self.__acquireContextTracker() + def hasLabel( self ) : return True + def setPlugs( self, plugs ) : + + GafferUI.PlugValueWidget.setPlugs( self, plugs ) + self.__updatePlugInputChangedConnection() + self.__acquireContextTracker() + + def getToolTip( self ) : + + editScope = self.__editScope() + if editScope is None : + return "Edits will be made using the last relevant node, including nodes not in any EditScope." + + unusableReason = self.__unusableReason( editScope ) + if unusableReason : + return unusableReason + else : + return "Edits will be made in {}.".format( editScope.getName() ) + # We don't actually display values, but this is also called whenever the # input changes, which is when we need to update. def _updateFromValues( self, values, exception ) : editScope = self.__editScope() editScopeActive = editScope is not None - self.__updateMenuButton( editScope ) + self.__updateMenuButton() self.__navigationMenuButton.setEnabled( editScopeActive ) if editScopeActive : self.__editScopeNameChangedConnection = editScope.nameChangedSignal().connect( @@ -177,19 +198,55 @@ def _updateFromValues( self, values, exception ) : self._qtWidget().setProperty( "editScopeActive", GafferUI._Variant.toVariant( editScopeActive ) ) self._repolish() - def __updateMenuButton( self, editScope ) : + def __updatePlugInputChangedConnection( self ) : + + self.__plugInputChangedConnection = self.getPlug().node().plugInputChangedSignal().connect( + Gaffer.WeakMethod( self.__plugInputChanged ), scoped = True + ) + + def __plugInputChanged( self, plug ) : + + if plug.getName() == "in" and plug.parent() == self.getPlug().node() : + # The result of `__inputNode()` will have changed. + self.__acquireContextTracker() + + def __acquireContextTracker( self ) : + self.__contextTracker = GafferUI.ContextTracker.acquire( self.__inputNode() ) + self.__contextTrackerChangedConnection = self.__contextTracker.changedSignal().connect( + Gaffer.WeakMethod( self.__contextTrackerChanged ), scoped = True + ) + + if not self.__contextTracker.updatePending() : + self.__updateMenuButton() + else : + # We'll update later in `__contextTrackerChanged()`. + pass + + def __updateMenuButton( self ) : + + editScope = self.__editScope() self.__menuButton.setText( editScope.getName() if editScope is not None else "None" ) - self.__menuButton.setImage( self.__editScopeSwatch( editScope ) if editScope is not None else None ) + + if editScope is not None : + self.__menuButton.setImage( + self.__editScopeSwatch( editScope ) if not self.__unusableReason( editScope ) else "warningSmall.png" + ) + else : + self.__menuButton.setImage( None ) def __editScopeNameChanged( self, editScope, oldName ) : - self.__updateMenuButton( editScope ) + self.__updateMenuButton() def __editScopeMetadataChanged( self, editScope, key, reason ) : if key == "nodeGadget:color" : - self.__updateMenuButton( editScope ) + self.__updateMenuButton() + + def __contextTrackerChanged( self, contextTracker ) : + + self.__updateMenuButton() def __editScope( self ) : @@ -231,6 +288,20 @@ def __inputNode( self ) : return inputNode + def __activeEditScopes( self ) : + + node = self.__inputNode() + if node is None : + return [] + + result = Gaffer.NodeAlgo.findAllUpstream( node, self.__editScopePredicate ) + if self.__editScopePredicate( node ) : + result.insert( 0, node ) + + result = [ n for n in result if self.__contextTracker.isTracked( n ) ] + + return result + def __buildMenu( self, result, path, currentEditScope ) : result = IECore.MenuDefinition() @@ -269,6 +340,8 @@ def __buildMenu( self, result, path, currentEditScope ) : "label" : itemName, "checkBox" : editScope == currentEditScope, "icon" : self.__editScopeSwatch( editScope ), + "active" : not self.__unusableReason( editScope ), + "description" : self.__unusableReason( editScope ), } ) else : @@ -290,16 +363,10 @@ def __menuDefinition( self ) : if self.getPlug().getInput() is not None : currentEditScope = self.getPlug().getInput().parent() - node = self.__inputNode() - if node is not None : - upstream = Gaffer.NodeAlgo.findAllUpstream( node, self.__editScopePredicate ) - if self.__editScopePredicate( node ) : - upstream.insert( 0, node ) + activeEditScopes = self.__activeEditScopes() - downstream = Gaffer.NodeAlgo.findAllDownstream( node, self.__editScopePredicate ) - else : - upstream = [] - downstream = [] + node = self.__inputNode() + downstream = Gaffer.NodeAlgo.findAllDownstream( node, self.__editScopePredicate ) if node is not None else [] # Each child of the root will get its own section in the menu # if it has children. The section will be preceded by a divider @@ -321,13 +388,11 @@ def addToMenuHierarchy( editScope, root ) : currentNode = currentNode.setdefault( n.getName(), {} ) currentNode[editScope.getName()] = editScope - if upstream : - for editScope in sorted( upstream, key = lambda e : e.relativeName( e.scriptNode() ) ) : - addToMenuHierarchy( editScope, "Upstream" ) + for editScope in reversed( activeEditScopes ) : + addToMenuHierarchy( editScope, "Upstream" ) - if downstream : - for editScope in sorted( downstream, key = lambda e : e.relativeName( e.scriptNode() ) ) : - addToMenuHierarchy( editScope, "Downstream" ) + for editScope in sorted( downstream, key = lambda e : e.relativeName( e.scriptNode() ) ) : + addToMenuHierarchy( editScope, "Downstream" ) menuPath = Gaffer.DictPath( menuHierarchy, "/" ) @@ -447,32 +512,37 @@ def __dragLeave( self, widget, event ) : def __drop( self, widget, event ) : - inputNode = self.__inputNode() dropNode = self.__dropNode( event ) - if inputNode is None : - with GafferUI.PopupWindow() as self.__popup : - with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) : - GafferUI.Image( "warningSmall.png" ) - GafferUI.Label( "

The Edit Scope cannot be set while nothing is viewed

" ) - self.__popup.popup( parent = self ) - elif dropNode : - upstream = Gaffer.NodeAlgo.findAllUpstream( inputNode, self.__editScopePredicate ) - if self.__editScopePredicate( inputNode ) : - upstream.insert( 0, inputNode ) - - if dropNode in upstream : + if dropNode is not None : + + reason = self.__unusableReason( dropNode ) + if reason is None : self.__connectEditScope( dropNode ) else : with GafferUI.PopupWindow() as self.__popup : with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) : GafferUI.Image( "warningSmall.png" ) - GafferUI.Label( "

{} cannot be used as it is not upstream of {}

".format( dropNode.getName(), inputNode.getName() ) ) + GafferUI.Label( f"

{reason}

" ) self.__popup.popup( parent = self ) self.__frame.setHighlighted( False ) return True + def __unusableReason( self, editScope ) : + + name = editScope.relativeName( editScope.scriptNode() ) + inputNode = self.__inputNode() + if inputNode is None : + return f"{name} cannot be used while nothing is viewed." + elif not self.__contextTracker.isTracked( editScope ) : + inputNodeName = inputNode.relativeName( inputNode.scriptNode() ) + return f"{name} cannot be used as it is not upstream of {inputNodeName}." + elif not self.__contextTracker.isEnabled( editScope ) : + return f"{name} cannot be used as it is disabled." + else : + return None + # ProcessorWidget # =============== From d8e5fa0fcc80824d4cd42ec87a2caf4c52540aed Mon Sep 17 00:00:00 2001 From: John Haddon Date: Tue, 16 Jul 2024 17:02:23 +0100 Subject: [PATCH 8/9] EditScopePlugValueWidget : Add `Refresh` menu item This allows the user to get explicit feedback about ContextTracker updates in the unlikely event that they are slow enough to not be done before the menu is accessed. I've deliberately only shown the BusyWidget when the user "opts in" via a refresh as I think most updates are going to be so rapid that it would flicker annoyingly for them. --- python/GafferUI/EditScopeUI.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/python/GafferUI/EditScopeUI.py b/python/GafferUI/EditScopeUI.py index aa038021d9..3a96405334 100644 --- a/python/GafferUI/EditScopeUI.py +++ b/python/GafferUI/EditScopeUI.py @@ -127,6 +127,8 @@ def __init__( self, plug, **kw ) : with GafferUI.ListContainer( GafferUI.ListContainer.Orientation.Horizontal, spacing = 4 ) : GafferUI.Spacer( imath.V2i( 4, 1 ), imath.V2i( 4, 1 ) ) GafferUI.Label( "Edit Scope" ) + self.__busyWidget = GafferUI.BusyWidget( size = 18 ) + self.__busyWidget.setVisible( False ) self.__menuButton = GafferUI.MenuButton( "", menu = GafferUI.Menu( Gaffer.WeakMethod( self.__menuDefinition ) ), @@ -247,6 +249,7 @@ def __editScopeMetadataChanged( self, editScope, key, reason ) : def __contextTrackerChanged( self, contextTracker ) : self.__updateMenuButton() + self.__busyWidget.setVisible( False ) def __editScope( self ) : @@ -409,6 +412,10 @@ def addToMenuHierarchy( editScope, root ) : result.update( self.__buildMenu( result, category, currentEditScope ) ) + if self.__contextTracker.updatePending() : + result.append( "/__RefreshDivider__", { "divider" : True } ) + result.append( "/Refresh", { "command" : Gaffer.WeakMethod( self.__refreshMenu ) } ) + result.append( "/__NoneDivider__", { "divider" : True } ) result.append( "/None", { "command" : functools.partial( self.getPlug().setInput, None ) }, @@ -416,6 +423,13 @@ def addToMenuHierarchy( editScope, root ) : return result + def __refreshMenu( self ) : + + if self.__contextTracker.updatePending() : + # An update will already be in progress so we just show our busy + # widget until it is done. + self.__busyWidget.setVisible( True ) + def __navigationMenuDefinition( self ) : result = IECore.MenuDefinition() From 59b94e061f50e26e9dccbac2c1b2ee73436fbadf Mon Sep 17 00:00:00 2001 From: John Haddon Date: Fri, 12 Jul 2024 11:32:31 +0100 Subject: [PATCH 9/9] EditScopePlugValueWidget : Remove "Downstream" menu section If we're not showing upstream nodes that aren't active, I don't see any justification for showing downstream nodes that can never be active. This also lets us slightly simplify the menu building logic. --- python/GafferUI/EditScopeUI.py | 47 ++++++++-------------------------- 1 file changed, 10 insertions(+), 37 deletions(-) diff --git a/python/GafferUI/EditScopeUI.py b/python/GafferUI/EditScopeUI.py index 3a96405334..2013fd9e13 100644 --- a/python/GafferUI/EditScopeUI.py +++ b/python/GafferUI/EditScopeUI.py @@ -305,7 +305,7 @@ def __activeEditScopes( self ) : return result - def __buildMenu( self, result, path, currentEditScope ) : + def __buildMenu( self, path, currentEditScope ) : result = IECore.MenuDefinition() @@ -328,8 +328,7 @@ def __buildMenu( self, result, path, currentEditScope ) : singlesStack.extend( [ children[0] ] ) if currentEditScope is not None : - # Ignore the first entry, which is the menu category - node = currentEditScope.scriptNode().descendant( ".".join( childPath[1:] ) ) + node = currentEditScope.scriptNode().descendant( ".".join( childPath[:] ) ) icon = "menuBreadCrumb.png" if node.isAncestorOf( currentEditScope ) else None else : icon = None @@ -339,7 +338,6 @@ def __buildMenu( self, result, path, currentEditScope ) : itemName, { "command" : functools.partial( Gaffer.WeakMethod( self.__connectEditScope ), editScope ), - "active" : path[0] != "Downstream", "label" : itemName, "checkBox" : editScope == currentEditScope, "icon" : self.__editScopeSwatch( editScope ), @@ -351,7 +349,7 @@ def __buildMenu( self, result, path, currentEditScope ) : result.append( itemName, { - "subMenu" : functools.partial( Gaffer.WeakMethod( self.__buildMenu ), result, childPath, currentEditScope ), + "subMenu" : functools.partial( Gaffer.WeakMethod( self.__buildMenu ), childPath, currentEditScope ), "icon" : icon } ) @@ -360,24 +358,18 @@ def __buildMenu( self, result, path, currentEditScope ) : def __menuDefinition( self ) : - result = IECore.MenuDefinition() - currentEditScope = None if self.getPlug().getInput() is not None : currentEditScope = self.getPlug().getInput().parent() activeEditScopes = self.__activeEditScopes() - node = self.__inputNode() - downstream = Gaffer.NodeAlgo.findAllDownstream( node, self.__editScopePredicate ) if node is not None else [] - - # Each child of the root will get its own section in the menu - # if it has children. The section will be preceded by a divider - # with its name in the divider label. + # Build a menu hierarchy to match the node hierarchy. + # This will be simplified where possible in `__buildMenu()`. menuHierarchy = OrderedDict() + for editScope in reversed( activeEditScopes ) : - def addToMenuHierarchy( editScope, root ) : ancestorNodes = [] currentNode = editScope while currentNode.parent() != editScope.scriptNode() : @@ -386,31 +378,12 @@ def addToMenuHierarchy( editScope, root ) : ancestorNodes.reverse() - currentNode = menuHierarchy.setdefault( root, {} ) + currentMenu = menuHierarchy for n in ancestorNodes : - currentNode = currentNode.setdefault( n.getName(), {} ) - currentNode[editScope.getName()] = editScope - - for editScope in reversed( activeEditScopes ) : - addToMenuHierarchy( editScope, "Upstream" ) - - for editScope in sorted( downstream, key = lambda e : e.relativeName( e.scriptNode() ) ) : - addToMenuHierarchy( editScope, "Downstream" ) - - menuPath = Gaffer.DictPath( menuHierarchy, "/" ) - - for category in menuPath.children() : - - if len( category.children() ) == 0 : - continue - - result.append( - "/__{}Divider__".format( category[-1] ), - { "divider" : True, "label" : category[-1] } - ) - - result.update( self.__buildMenu( result, category, currentEditScope ) ) + currentMenu = currentMenu.setdefault( n.getName(), {} ) + currentMenu[editScope.getName()] = editScope + result = self.__buildMenu( Gaffer.DictPath( menuHierarchy, "/" ), currentEditScope ) if self.__contextTracker.updatePending() : result.append( "/__RefreshDivider__", { "divider" : True } )