From fd2db6bbb36cd290ee73b649366265a313d9050b Mon Sep 17 00:00:00 2001 From: Jonathan Olson Date: Tue, 2 Apr 2024 14:06:36 -0600 Subject: [PATCH] Adding DisplayedTrailsProperty, DisplayedProperty => TypeScript, adding Node rootedDisplayChangedEmitter, pdomVisibleProperty, see https://github.com/phetsims/scenery/issues/1620https://github.com/phetsims/scenery/issues/1621 --- js/accessibility/pdom/PDOMTree.js | 2 + js/accessibility/pdom/ParallelDOM.ts | 70 ++++++-- js/imports.ts | 3 + js/nodes/Node.ts | 42 ++--- js/util/DisplayedProperty.js | 111 ------------ js/util/DisplayedProperty.ts | 35 ++++ js/util/DisplayedTrailsProperty.ts | 251 +++++++++++++++++++++++++++ 7 files changed, 369 insertions(+), 145 deletions(-) delete mode 100644 js/util/DisplayedProperty.js create mode 100644 js/util/DisplayedProperty.ts create mode 100644 js/util/DisplayedTrailsProperty.ts diff --git a/js/accessibility/pdom/PDOMTree.js b/js/accessibility/pdom/PDOMTree.js index e894a23ce..d597f9f7a 100644 --- a/js/accessibility/pdom/PDOMTree.js +++ b/js/accessibility/pdom/PDOMTree.js @@ -136,6 +136,7 @@ const PDOMTree = { if ( removedItemToRemove ) { PDOMTree.removeTree( node, removedItemToRemove, pdomTrails ); removedItemToRemove._pdomParent = null; + removedItemToRemove.pdomParentChangedEmitter.emit(); } } @@ -148,6 +149,7 @@ const PDOMTree = { PDOMTree.removeTree( removedParents[ j ], addedItemToRemove ); } addedItemToRemove._pdomParent = node; + addedItemToRemove.pdomParentChangedEmitter.emit(); } } diff --git a/js/accessibility/pdom/ParallelDOM.ts b/js/accessibility/pdom/ParallelDOM.ts index 425885e7b..62a724008 100644 --- a/js/accessibility/pdom/ParallelDOM.ts +++ b/js/accessibility/pdom/ParallelDOM.ts @@ -140,6 +140,8 @@ import TReadOnlyProperty, { isTReadOnlyProperty } from '../../../../axon/js/TRea import ReadOnlyProperty from '../../../../axon/js/ReadOnlyProperty.js'; import isSettingPhetioStateProperty from '../../../../tandem/js/isSettingPhetioStateProperty.js'; import Bounds2 from '../../../../dot/js/Bounds2.js'; +import TinyForwardingProperty from '../../../../axon/js/TinyForwardingProperty.js'; +import TProperty from '../../../../axon/js/TProperty.js'; const INPUT_TAG = PDOMUtils.TAGS.INPUT; const P_TAG = PDOMUtils.TAGS.P; @@ -221,6 +223,7 @@ const ACCESSIBILITY_OPTION_KEYS = [ 'focusHighlight', 'focusHighlightLayerable', 'groupFocusHighlight', + 'pdomVisibleProperty', 'pdomVisible', 'pdomOrder', @@ -278,6 +281,7 @@ export type ParallelDOMOptions = { focusHighlight?: Highlight; // Sets the focus highlight for the node focusHighlightLayerable?: boolean; //lag to determine if the focus highlight node can be layered in the scene graph groupFocusHighlight?: Node | boolean; // Sets the outer focus highlight for this node when a descendant has focus + pdomVisibleProperty?: TReadOnlyProperty | null; pdomVisible?: boolean; // Sets whether or not the node's DOM element is visible in the parallel DOM pdomOrder?: ( Node | null )[] | null; // Modifies the order of accessible navigation @@ -492,7 +496,7 @@ export default class ParallelDOM extends PhetioObject { // be found by the assistive technology virtual cursor. For more information on how assistive technologies // read with the virtual cursor see // http://www.ssbbartgroup.com/blog/how-windows-screen-readers-work-on-the-web/ - private _pdomVisible: boolean; + private readonly _pdomVisibleProperty: TinyForwardingProperty; // If provided, it will override the focus order between children // (and optionally arbitrary subtrees). If not provided, the focus order will default to the rendering order @@ -559,10 +563,13 @@ export default class ParallelDOM extends PhetioObject { private _pdomHeadingBehavior: PDOMBehaviorFunction; // Emits an event when the focus highlight is changed. - public readonly focusHighlightChangedEmitter: TEmitter; + public readonly focusHighlightChangedEmitter: TEmitter = new TinyEmitter(); + + // Emits an event when the pdom parent of this Node has changed + public readonly pdomParentChangedEmitter: TEmitter = new TinyEmitter(); // Fired when the PDOM Displays for this Node have changed (see PDOMInstance) - public readonly pdomDisplaysEmitter: TEmitter; + public readonly pdomDisplaysEmitter: TEmitter = new TinyEmitter(); // PDOM specific enabled listener protected pdomBoundInputEnabledListener: ( enabled: boolean ) => void; @@ -611,7 +618,6 @@ export default class ParallelDOM extends PhetioObject { this._focusHighlight = null; this._focusHighlightLayerable = false; this._groupFocusHighlight = false; - this._pdomVisible = true; this._pdomOrder = null; this._pdomParent = null; this._pdomTransformSourceNode = null; @@ -622,14 +628,14 @@ export default class ParallelDOM extends PhetioObject { this._positionInPDOM = false; this.excludeLabelSiblingFromInput = false; + this._pdomVisibleProperty = new TinyForwardingProperty( true, false, this.onPdomVisiblePropertyChange.bind( this ) ); + // HIGHER LEVEL API INITIALIZATION this._accessibleNameBehavior = ParallelDOM.BASIC_ACCESSIBLE_NAME_BEHAVIOR; this._helpTextBehavior = ParallelDOM.HELP_TEXT_AFTER_CONTENT; this._headingLevel = null; this._pdomHeadingBehavior = DEFAULT_PDOM_HEADING_BEHAVIOR; - this.focusHighlightChangedEmitter = new TinyEmitter(); - this.pdomDisplaysEmitter = new TinyEmitter(); this.pdomBoundInputEnabledListener = this.pdomInputEnabledListener.bind( this ); } @@ -700,6 +706,8 @@ export default class ParallelDOM extends PhetioObject { // PDOM attributes can potentially have listeners, so we will clear those out. this.removePDOMAttributes(); + + this._pdomVisibleProperty.dispose(); } private pdomInputEnabledListener( enabled: boolean ): void { @@ -745,7 +753,7 @@ export default class ParallelDOM extends PhetioObject { // when accessibility is widely used, this assertion can be added back in // assert && assert( this._pdomInstances.length > 0, 'there must be pdom content for the node to receive focus' ); assert && assert( this.focusable, 'trying to set focus on a node that is not focusable' ); - assert && assert( this._pdomVisible, 'trying to set focus on a node with invisible pdom content' ); + assert && assert( this.pdomVisible, 'trying to set focus on a node with invisible pdom content' ); assert && assert( this._pdomInstances.length === 1, 'focus() unsupported for Nodes using DAG, pdom content is not unique' ); const peer = this._pdomInstances[ 0 ].peer!; @@ -2316,6 +2324,46 @@ export default class ParallelDOM extends PhetioObject { } } + /** + * Called when our pdomVisible Property changes values. + */ + private onPdomVisiblePropertyChange( visible: boolean ): void { + this._pdomDisplaysInfo.onPDOMVisibilityChange( visible ); + } + + /** + * Sets what Property our pdomVisibleProperty is backed by, so that changes to this provided Property will change this + * Node's pdom visibility, and vice versa. This does not change this._pdomVisibleProperty. See TinyForwardingProperty.setTargetProperty() + * for more info. + */ + public setPdomVisibleProperty( newTarget: TReadOnlyProperty | null ): this { + this._pdomVisibleProperty.setTargetProperty( newTarget ); + + return this; + } + + /** + * See setPdomVisibleProperty() for more information + */ + public set pdomVisibleProperty( property: TReadOnlyProperty | null ) { + this.setPdomVisibleProperty( property ); + } + + /** + * See getPdomVisibleProperty() for more information + */ + public get pdomVisibleProperty(): TProperty { + return this.getPdomVisibleProperty(); + } + + + /** + * Get this Node's pdomVisibleProperty. See Node.getVisibleProperty for more information + */ + public getPdomVisibleProperty(): TProperty { + return this._pdomVisibleProperty; + } + /** * Hide completely from a screen reader and the browser by setting the hidden attribute on the node's * representative DOM element. If the sibling DOM Elements have a container parent, the container @@ -2323,11 +2371,7 @@ export default class ParallelDOM extends PhetioObject { * order. */ public setPDOMVisible( visible: boolean ): void { - if ( this._pdomVisible !== visible ) { - this._pdomVisible = visible; - - this._pdomDisplaysInfo.onPDOMVisibilityChange( visible ); - } + this.pdomVisibleProperty.value = visible; } public set pdomVisible( visible: boolean ) { this.setPDOMVisible( visible ); } @@ -2338,7 +2382,7 @@ export default class ParallelDOM extends PhetioObject { * Get whether or not this node's representative DOM element is visible. */ public isPDOMVisible(): boolean { - return this._pdomVisible; + return this.pdomVisibleProperty.value; } /** diff --git a/js/imports.ts b/js/imports.ts index 2cd18d234..27f190232 100644 --- a/js/imports.ts +++ b/js/imports.ts @@ -32,7 +32,10 @@ export { default as SceneryStyle } from './util/SceneryStyle.js'; export { default as CanvasContextWrapper } from './util/CanvasContextWrapper.js'; export { default as FullScreen } from './util/FullScreen.js'; export { default as CountMap } from './util/CountMap.js'; +export { default as DisplayedTrailsProperty } from './util/DisplayedTrailsProperty.js'; +export type { DisplayedTrailsPropertyOptions } from './util/DisplayedTrailsProperty.js'; export { default as DisplayedProperty } from './util/DisplayedProperty.js'; +export type { DisplayedPropertyOptions } from './util/DisplayedProperty.js'; export { default as SceneImage } from './util/SceneImage.js'; export { default as allowLinksProperty } from './util/allowLinksProperty.js'; export { default as openPopup } from './util/openPopup.js'; diff --git a/js/nodes/Node.ts b/js/nodes/Node.ts index 5ed12da26..dadd9c530 100644 --- a/js/nodes/Node.ts +++ b/js/nodes/Node.ts @@ -609,16 +609,16 @@ class Node extends ParallelDOM { // This is fired only once for any single operation that may change the children of a Node. // For example, if a Node's children are [ a, b ] and setChildren( [ a, x, y, z ] ) is called on it, the // childrenChanged event will only be fired once after the entire operation of changing the children is completed. - public readonly childrenChangedEmitter: TEmitter; + public readonly childrenChangedEmitter: TEmitter = new TinyEmitter(); // For every single added child Node, emits with {Node} Node, {number} indexOfChild - public readonly childInsertedEmitter: TEmitter<[ node: Node, indexOfChild: number ]>; + public readonly childInsertedEmitter: TEmitter<[ node: Node, indexOfChild: number ]> = new TinyEmitter(); // For every single removed child Node, emits with {Node} Node, {number} indexOfChild - public readonly childRemovedEmitter: TEmitter<[ node: Node, indexOfChild: number ]>; + public readonly childRemovedEmitter: TEmitter<[ node: Node, indexOfChild: number ]> = new TinyEmitter(); // Provides a given range that may be affected by the reordering - public readonly childrenReorderedEmitter: TEmitter<[ minChangedIndex: number, maxChangedIndex: number ]>; + public readonly childrenReorderedEmitter: TEmitter<[ minChangedIndex: number, maxChangedIndex: number ]> = new TinyEmitter(); // Fired whenever a parent is added public readonly parentAddedEmitter: TEmitter<[ node: Node ]> = new TinyEmitter(); @@ -628,18 +628,18 @@ class Node extends ParallelDOM { // Fired synchronously when the transform (transformation matrix) of a Node is changed. Any // change to a Node's translation/rotation/scale/etc. will trigger this event. - public readonly transformEmitter: TEmitter; + public readonly transformEmitter: TEmitter = new TinyEmitter(); // Should be emitted when we need to check full metadata updates directly on Instances, // to see if we need to change drawable types, etc. - public readonly instanceRefreshEmitter: TEmitter; + public readonly instanceRefreshEmitter: TEmitter = new TinyEmitter(); // Emitted to when we need to potentially recompute our renderer summary (bitmask flags, or // things that could affect descendants) - public readonly rendererSummaryRefreshEmitter: TEmitter; + public readonly rendererSummaryRefreshEmitter: TEmitter = new TinyEmitter(); // Emitted to when we change filters (either opacity or generalized filters) - public readonly filterChangeEmitter: TEmitter; + public readonly filterChangeEmitter: TEmitter = new TinyEmitter(); // Fired when an instance is changed (added/removed). CAREFUL!! This is potentially a very dangerous thing to listen // to. Instances are updated in an asynchronous batch during `updateDisplay()`, and it is very important that display @@ -647,10 +647,14 @@ class Node extends ParallelDOM { // Currently, all usages of this cause into updates to the audio view, or updates to a separate display (used as an // overlay). Please proceed with caution, and see https://github.com/phetsims/scenery/issues/1615 and // https://github.com/phetsims/scenery/issues/1620 for details. - public readonly changedInstanceEmitter: TEmitter<[ instance: Instance, added: boolean ]>; + public readonly changedInstanceEmitter: TEmitter<[ instance: Instance, added: boolean ]> = new TinyEmitter(); + + // Fired whenever this node is added as a root to a Display OR when it is removed as a root from a Display (i.e. + // the Display is disposed). + public readonly rootedDisplayChangedEmitter: TEmitter<[ display: Display ]> = new TinyEmitter(); // Fired when layoutOptions changes - public readonly layoutOptionsChangedEmitter: TEmitter; + public readonly layoutOptionsChangedEmitter: TEmitter = new TinyEmitter(); // A bitmask which specifies which renderers this Node (and only this Node, not its subtree) supports. // (scenery-internal) @@ -836,17 +840,6 @@ class Node extends ParallelDOM { this._filters = []; - this.childrenChangedEmitter = new TinyEmitter(); - this.childInsertedEmitter = new TinyEmitter(); - this.childRemovedEmitter = new TinyEmitter(); - this.childrenReorderedEmitter = new TinyEmitter(); - this.transformEmitter = new TinyEmitter(); - this.instanceRefreshEmitter = new TinyEmitter(); - this.rendererSummaryRefreshEmitter = new TinyEmitter(); - this.filterChangeEmitter = new TinyEmitter(); - this.changedInstanceEmitter = new TinyEmitter(); - this.layoutOptionsChangedEmitter = new TinyEmitter(); - this._rendererBitmask = Renderer.bitmaskNodeDefault; this._rendererSummary = new RendererSummary( this ); @@ -5960,6 +5953,8 @@ class Node extends ParallelDOM { // Defined in ParallelDOM.js this._pdomDisplaysInfo.onAddedRootedDisplay( display ); + + this.rootedDisplayChangedEmitter.emit( display ); } /** @@ -5972,6 +5967,8 @@ class Node extends ParallelDOM { // Defined in ParallelDOM.js this._pdomDisplaysInfo.onRemovedRootedDisplay( display ); + + this.rootedDisplayChangedEmitter.emit( display ); } private getRecursiveConnectedDisplays( displays: Display[] ): Display[] { @@ -6342,6 +6339,9 @@ class Node extends ParallelDOM { if ( assert && options.hasOwnProperty( 'visible' ) && options.hasOwnProperty( 'visibleProperty' ) ) { assert && assert( options.visibleProperty!.value === options.visible, 'If both visible and visibleProperty are provided, then values should match' ); } + if ( assert && options.hasOwnProperty( 'pdomVisible' ) && options.hasOwnProperty( 'pdomVisibleProperty' ) ) { + assert && assert( options.pdomVisibleProperty!.value === options.pdomVisible, 'If both pdomVisible and pdomVisibleProperty are provided, then values should match' ); + } if ( assert && options.hasOwnProperty( 'pickable' ) && options.hasOwnProperty( 'pickableProperty' ) ) { assert && assert( options.pickableProperty!.value === options.pickable, 'If both pickable and pickableProperty are provided, then values should match' ); } diff --git a/js/util/DisplayedProperty.js b/js/util/DisplayedProperty.js deleted file mode 100644 index 736c95846..000000000 --- a/js/util/DisplayedProperty.js +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright 2018-2024, University of Colorado Boulder - -/** - * A property that is true when the node appears on the given display. Please exercise extreme care when using this - * class, as it comes with some finicky drawbacks: - * - * 1. Note that a node can appear on a display even after it has been removed from the scene graph, if - * Display.updateDisplay() has not yet been called since it was removed. So generally this Property will only update - * as a result of Display.updateDisplay() being called. - * 2. Given (1), this means that any listeners to this Property will fire during updateDisplay(), and not during normal - * stepping/event handling of the simulation. updateDisplay should NEVER cause the scene graph state to change, so - * listeners to this property should not cause changes to Node state, see https://github.com/phetsims/scenery/issues/1615 - * for details. - * 3. Be careful to dispose of these, since it WILL result in a permanent memory leak otherwise. Instance objects are - * pooled, and if the listener is not removed, it will stay around forever. - * - * @author Jonathan Olson - */ - -import BooleanProperty from '../../../axon/js/BooleanProperty.js'; -import merge from '../../../phet-core/js/merge.js'; -import { scenery } from '../imports.js'; - -class DisplayedProperty extends BooleanProperty { - /** - * @public - * @extends {Property} - * - * @param {scenery.Node} node - * @param {Object} [options] - Passed through to the BooleanProperty - */ - constructor( node, options ) { - - options = merge( { - display: null // {Display|null} if null, this will check on any Display - }, options ); - - super( false, options ); - - // @private {Node} - this.node = node; - - // @private {Display|null} - this.display = options.display; - - // @private {function} - this.updateListener = this.updateValue.bind( this ); - this.changedInstanceListener = this.changedInstance.bind( this ); - - node.changedInstanceEmitter.addListener( this.changedInstanceListener ); - // node.pdomDisplaysEmitter.addListener( this.updateListener ); // TODO support pdom visibility, https://github.com/phetsims/scenery/issues/1167 - - // Add any instances the node may already have/ - const instances = node.instances; - for ( let i = 0; i < instances.length; i++ ) { - this.changedInstance( instances[ i ], true ); - } - } - - /** - * Checks whether the node was displayed and updates the value of this Property. - * @private - */ - updateValue() { - this.value = this.node.wasVisuallyDisplayed( this.display ); - - // TODO support pdom visibility, https://github.com/phetsims/scenery/issues/1167 - // this.value = this.node.wasVisuallyDisplayed( this.display ) || this.node.isPDOMDisplayed(); - } - - /** - * Called when an instance is changed or added (based on the boolean flag). - * @private - * - * @param {Instance} instance - * @param {boolean} added - */ - changedInstance( instance, added ) { - if ( added ) { - instance.visibleEmitter.addListener( this.updateListener ); - } - else { - instance.visibleEmitter.removeListener( this.updateListener ); - } - - this.updateValue(); - } - - /** - * Releases references to avoid memory leaks. - * @public - * @override - */ - dispose() { - // Remove any instances the node may still have - const instances = this.node.instances; - for ( let i = 0; i < instances.length; i++ ) { - this.changedInstance( instances[ i ], false ); - } - - this.node.changedInstanceEmitter.removeListener( this.changedInstanceListener ); - - // TODO support pdom visibility, https://github.com/phetsims/scenery/issues/1167 - // this.node.pdomDisplaysEmitter.removeListener( this.updateListener ); - - super.dispose(); - } -} - -scenery.register( 'DisplayedProperty', DisplayedProperty ); -export default DisplayedProperty; \ No newline at end of file diff --git a/js/util/DisplayedProperty.ts b/js/util/DisplayedProperty.ts new file mode 100644 index 000000000..a73ff20e5 --- /dev/null +++ b/js/util/DisplayedProperty.ts @@ -0,0 +1,35 @@ +// Copyright 2018-2024, University of Colorado Boulder + +/** + * A property that is true when the node appears on the given display. See DisplayedTrailsProperty for additional options + * + * @author Jonathan Olson + */ + +import { DisplayedTrailsProperty, DisplayedTrailsPropertyOptions, Node, scenery, Trail } from '../imports.js'; +import { DerivedProperty1, DerivedPropertyOptions } from '../../../axon/js/DerivedProperty.js'; + +export type DisplayedPropertyOptions = DisplayedTrailsPropertyOptions & DerivedPropertyOptions; + +class DisplayedProperty extends DerivedProperty1 { + + private readonly displayedTrailsProperty: DisplayedTrailsProperty; + + public constructor( node: Node, options?: DisplayedPropertyOptions ) { + + const displayedTrailsProperty = new DisplayedTrailsProperty( node, options ); + + super( [ displayedTrailsProperty ], trails => trails.length > 0, options ); + + this.displayedTrailsProperty = displayedTrailsProperty; + } + + public override dispose(): void { + this.displayedTrailsProperty.dispose(); + + super.dispose(); + } +} + +scenery.register( 'DisplayedProperty', DisplayedProperty ); +export default DisplayedProperty; \ No newline at end of file diff --git a/js/util/DisplayedTrailsProperty.ts b/js/util/DisplayedTrailsProperty.ts new file mode 100644 index 000000000..bf119dccb --- /dev/null +++ b/js/util/DisplayedTrailsProperty.ts @@ -0,0 +1,251 @@ +// Copyright 2022-2024, University of Colorado Boulder + +/** + * A Property that will contain a list of Trails where the root of the trail is a root Node of a Display, and the leaf + * node is the provided Node. + * + * NOTE: If a Node is disposed, it will be removed from the trails. + * + * @author Jonathan Olson + */ + +import TinyProperty from '../../../axon/js/TinyProperty.js'; +import { Display, Node, scenery, Trail } from '../imports.js'; +import optionize from '../../../phet-core/js/optionize.js'; + +type DisplayPredicate = Display | ( ( display: Display ) => boolean ) | null; + +export type DisplayedTrailsPropertyOptions = { + // If provided, we will only report trails that are rooted for the specific Display provided. + display?: DisplayPredicate; + + // If true, we will follow the pdomParent if it is available (if our child node is specified in a pdomOrder of another + // node, we will follow that order). + // This essentially tracks the following: + // + // - followPdomOrder: true = visual trails (just children) + // - followPdomORder: false = pdom trails (respecting pdomOrder) + followPdomOrder?: boolean; + + // If true, we will only report trails where every node is visible: true. + requireVisible?: boolean; + + // If true, we will only report trails where every node is pdomVisible: true. + requirePdomVisible?: boolean; + + // If true, we will only report trails where every node is enabled: true. + requireEnabled?: boolean; + + // If true, we will only report trails where every node is inputEnabled: true. + requireInputEnabled?: boolean; + + // NOTE: Could think about adding pickability here in the future. The complication is that it doesn't measure our hit + // testing precisely, because of pickable:null (default) and the potential existence of input listeners. +}; + +export default class DisplayedTrailsProperty extends TinyProperty { + + public readonly node: Node; + public readonly listenedNodeSet: Set = new Set(); + private readonly _trailUpdateListener: () => void; + + // Recorded options + private readonly display: DisplayPredicate; + private readonly followPdomOrder: boolean; + private readonly requireVisible: boolean; + private readonly requirePdomVisible: boolean; + private readonly requireEnabled: boolean; + private readonly requireInputEnabled: boolean; + + /** + * We will contain Trails whose leaf node (lastNode) is this provided Node. + */ + public constructor( node: Node, providedOptions?: DisplayedTrailsPropertyOptions ) { + + const options = optionize()( { + // Listen to all displays + display: null, + + // Default to visual trails (just children), with only pruning by normal visibility + followPdomOrder: false, + requireVisible: true, + requirePdomVisible: false, + requireEnabled: false, + requireInputEnabled: false + }, providedOptions ); + + super( [] ); + + // Save options for later updates + this.node = node; + this.display = options.display; + this.followPdomOrder = options.followPdomOrder; + this.requireVisible = options.requireVisible; + this.requirePdomVisible = options.requirePdomVisible; + this.requireEnabled = options.requireEnabled; + this.requireInputEnabled = options.requireInputEnabled; + + this._trailUpdateListener = this.update.bind( this ); + + this.update(); + } + + private update(): void { + + // Factored out because we're using a "function" below for recursion (NOT an arrow function) + const display = this.display; + const followPdomOrder = this.followPdomOrder; + const requireVisible = this.requireVisible; + const requirePdomVisible = this.requirePdomVisible; + const requireEnabled = this.requireEnabled; + const requireInputEnabled = this.requireInputEnabled; + + // Trails accumulated in our recursion that will be our Property's value + const trails: Trail[] = []; + + // Nodes that were touched in the scan (we should listen to changes to ANY of these to see if there is a connection + // or disconnection. This could potentially cause our Property to change + const nodeSet = new Set(); + + // Modified in-place during the search + const trail = new Trail( this.node ); + + // We will recursively add things to the "front" of the trail (ancestors) + ( function recurse() { + const root = trail.rootNode(); + + // If a Node is disposed, we won't add listeners to it, so we abort slightly earlier. + if ( root.isDisposed ) { + return; + } + + nodeSet.add( root ); + + // If we fail other conditions, we won't add a trail OR recurse, but we will STILL have listeners added to the Node. + if ( + ( requireVisible && !root.visible ) || + ( requirePdomVisible && !root.pdomVisible ) || + ( requireEnabled && !root.enabled ) || + ( requireInputEnabled && !root.inputEnabled ) + ) { + return; + } + + const displays = root.getRootedDisplays(); + + let displayMatches: boolean; + + if ( display === null ) { + displayMatches = displays.length > 0; + } + else if ( display instanceof Display ) { + displayMatches = displays.includes( display ); + } + else { + displayMatches = displays.some( display ); + } + + if ( displayMatches ) { + // Create a permanent copy that won't be mutated + trails.push( trail.copy() ); + } + + const parents = followPdomOrder && root.pdomParent ? [ root.pdomParent ] : root.parents; + + parents.forEach( parent => { + trail.addAncestor( parent ); + recurse(); + trail.removeAncestor(); + } ); + } )(); + + // Add in new needed listeners + nodeSet.forEach( node => { + if ( !this.listenedNodeSet.has( node ) ) { + this.addNodeListener( node ); + } + } ); + + // Remove listeners not needed anymore + this.listenedNodeSet.forEach( node => { + if ( !nodeSet.has( node ) ) { + this.removeNodeListener( node ); + } + } ); + + // Guard in a way that deepEquality on the Property wouldn't (because of the Array wrapper) + // NOTE: Duplicated with TrailsBetweenProperty, likely can be factored out. + const currentTrails = this.value; + let trailsEqual = currentTrails.length === trails.length; + if ( trailsEqual ) { + for ( let i = 0; i < trails.length; i++ ) { + if ( !currentTrails[ i ].equals( trails[ i ] ) ) { + trailsEqual = false; + break; + } + } + } + + if ( !trailsEqual ) { + this.value = trails; + } + } + + private addNodeListener( node: Node ): void { + this.listenedNodeSet.add( node ); + + // Unconditional listeners, which affect all nodes. + node.parentAddedEmitter.addListener( this._trailUpdateListener ); + node.parentRemovedEmitter.addListener( this._trailUpdateListener ); + node.rootedDisplayChangedEmitter.addListener( this._trailUpdateListener ); + node.disposeEmitter.addListener( this._trailUpdateListener ); + + if ( this.followPdomOrder ) { + node.pdomParentChangedEmitter.addListener( this._trailUpdateListener ); + } + if ( this.requireVisible ) { + node.visibleProperty.lazyLink( this._trailUpdateListener ); + } + if ( this.requirePdomVisible ) { + node.pdomVisibleProperty.lazyLink( this._trailUpdateListener ); + } + if ( this.requireEnabled ) { + node.enabledProperty.lazyLink( this._trailUpdateListener ); + } + if ( this.requireInputEnabled ) { + node.inputEnabledProperty.lazyLink( this._trailUpdateListener ); + } + } + + private removeNodeListener( node: Node ): void { + this.listenedNodeSet.delete( node ); + node.parentAddedEmitter.removeListener( this._trailUpdateListener ); + node.parentRemovedEmitter.removeListener( this._trailUpdateListener ); + node.rootedDisplayChangedEmitter.removeListener( this._trailUpdateListener ); + node.disposeEmitter.removeListener( this._trailUpdateListener ); + + if ( this.followPdomOrder ) { + node.pdomParentChangedEmitter.removeListener( this._trailUpdateListener ); + } + if ( this.requireVisible ) { + node.visibleProperty.unlink( this._trailUpdateListener ); + } + if ( this.requirePdomVisible ) { + node.pdomVisibleProperty.unlink( this._trailUpdateListener ); + } + if ( this.requireEnabled ) { + node.enabledProperty.unlink( this._trailUpdateListener ); + } + if ( this.requireInputEnabled ) { + node.inputEnabledProperty.unlink( this._trailUpdateListener ); + } + } + + public override dispose(): void { + this.listenedNodeSet.forEach( node => this.removeNodeListener( node ) ); + + super.dispose(); + } +} + +scenery.register( 'DisplayedTrailsProperty', DisplayedTrailsProperty ); \ No newline at end of file