-
Notifications
You must be signed in to change notification settings - Fork 12
/
Copy pathButtonNode.ts
371 lines (296 loc) · 15 KB
/
ButtonNode.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
// Copyright 2020-2024, University of Colorado Boulder
/**
* ButtonNode is the base class for the sun button hierarchy.
*
* @author John Blanco (PhET Interactive Simulations)
* @author Sam Reid (PhET Interactive Simulations)
* @author Michael Kauzmann (PhET Interactive Simulations)
*/
import DerivedProperty from '../../../axon/js/DerivedProperty.js';
import Multilink, { UnknownMultilink } from '../../../axon/js/Multilink.js';
import TinyProperty from '../../../axon/js/TinyProperty.js';
import TReadOnlyProperty from '../../../axon/js/TReadOnlyProperty.js';
import Bounds2 from '../../../dot/js/Bounds2.js';
import Dimension2 from '../../../dot/js/Dimension2.js';
import optionize, { combineOptions } from '../../../phet-core/js/optionize.js';
import StrictOmit from '../../../phet-core/js/types/StrictOmit.js';
import { AlignBox, AlignBoxXAlign, AlignBoxYAlign, assertNoAdditionalChildren, Brightness, Color, Contrast, Grayscale, Node, NodeOptions, PaintableNode, PaintColorProperty, Path, PressListener, PressListenerOptions, SceneryConstants, Sizable, SizableOptions, TColor, TPaint, Voicing, VoicingOptions } from '../../../scenery/js/imports.js';
import ColorConstants from '../ColorConstants.js';
import sun from '../sun.js';
import ButtonInteractionState from './ButtonInteractionState.js';
import ButtonModel from './ButtonModel.js';
import TButtonAppearanceStrategy, { TButtonAppearanceStrategyOptions } from './TButtonAppearanceStrategy.js';
import TContentAppearanceStrategy, { TContentAppearanceStrategyOptions } from './TContentAppearanceStrategy.js';
// constants
const CONTRAST_FILTER = new Contrast( 0.7 );
const BRIGHTNESS_FILTER = new Brightness( 1.2 );
// if there is content, style can be applied to a containing Node around it.
type EnabledAppearanceStrategy = ( enabled: boolean, button: Node, background: Node, content: Node | null ) => void;
type SelfOptions = {
// what appears on the button (icon, label, etc.)
content?: Node | null;
// margin in x direction, i.e. on left and right
xMargin?: number;
// margin in y direction, i.e. on top and bottom
yMargin?: number;
// Alignment, relevant only when options minWidth or minHeight are greater than the size of options.content
xAlign?: AlignBoxXAlign;
yAlign?: AlignBoxYAlign;
// By default, icons are centered in the button, but icons with odd
// shapes that are not wrapped in a normalizing parent node may need to
// specify offsets to line things up properly
xContentOffset?: number;
yContentOffset?: number;
// Options that will be passed through to the main input listener (PressListener)
listenerOptions?: PressListenerOptions;
// initial color of the button's background
baseColor?: TPaint;
// Color when disabled
disabledColor?: TPaint;
// Class and associated options that determine the button's appearance and the changes that occur when the button is
// pressed, hovered over, disabled, and so forth.
buttonAppearanceStrategy?: TButtonAppearanceStrategy;
buttonAppearanceStrategyOptions?: TButtonAppearanceStrategyOptions;
// Class and associated options that determine how the content node looks and the changes that occur when the button
// is pressed, hovered over, disabled, and so forth.
contentAppearanceStrategy?: TContentAppearanceStrategy | null;
contentAppearanceStrategyOptions?: TContentAppearanceStrategyOptions;
// Alter the appearance when changing the enabled of the button.
enabledAppearanceStrategy?: EnabledAppearanceStrategy;
};
type ParentOptions = SizableOptions & VoicingOptions & NodeOptions;
// Normal options, for use in optionize
export type ButtonNodeOptions = SelfOptions & ParentOptions;
export default class ButtonNode extends Sizable( Voicing( Node ) ) {
protected buttonModel: ButtonModel;
private readonly _settableBaseColorProperty: PaintColorProperty;
private readonly _disabledColorProperty: PaintColorProperty;
private readonly baseColorProperty: TReadOnlyProperty<Color>;
private readonly _pressListener: PressListener;
private readonly disposeButtonNode: () => void;
protected readonly content: Node | null;
public readonly contentContainer: Node | null = null; // (sun-only)
protected readonly layoutSizeProperty: TinyProperty<Dimension2> = new TinyProperty<Dimension2>( new Dimension2( 0, 0 ) );
// The maximum lineWidth our buttonBackground can have. We'll lay things out so that if we adjust our lineWidth below
// this, the layout won't change
protected readonly maxLineWidth: number;
public static FlatAppearanceStrategy: TButtonAppearanceStrategy;
/**
* @param buttonModel
* @param buttonBackground - the background of the button (like a circle or rectangle).
* @param interactionStateProperty - a Property that is used to drive the visual appearance of the button
* @param providedOptions - this type does not mutate its options, but relies on the subtype to
*/
protected constructor( buttonModel: ButtonModel,
buttonBackground: Path,
interactionStateProperty: TReadOnlyProperty<ButtonInteractionState>,
providedOptions?: ButtonNodeOptions ) {
const options = optionize<ButtonNodeOptions, StrictOmit<SelfOptions, 'listenerOptions'>, ParentOptions>()( {
content: null,
xMargin: 10,
yMargin: 5,
xAlign: 'center',
yAlign: 'center',
xContentOffset: 0,
yContentOffset: 0,
baseColor: ColorConstants.LIGHT_BLUE,
cursor: 'pointer',
buttonAppearanceStrategy: ButtonNode.FlatAppearanceStrategy,
buttonAppearanceStrategyOptions: {},
contentAppearanceStrategy: null,
contentAppearanceStrategyOptions: {},
enabledAppearanceStrategy: ( enabled, button, background, content ) => {
background.filters = enabled ? [] : [ CONTRAST_FILTER, BRIGHTNESS_FILTER ];
if ( content ) {
content.filters = enabled ? [] : [ Grayscale.FULL ];
content.opacity = enabled ? 1 : SceneryConstants.DISABLED_OPACITY;
}
},
disabledColor: ColorConstants.LIGHT_GRAY,
// pdom
tagName: 'button',
// phet-io
tandemNameSuffix: 'Button',
visiblePropertyOptions: { phetioFeatured: true },
phetioEnabledPropertyInstrumented: true // opt into default PhET-iO instrumented enabledProperty
}, providedOptions );
options.listenerOptions = combineOptions<PressListenerOptions>( {
tandem: options.tandem?.createTandem( 'pressListener' )
}, options.listenerOptions );
assert && options.enabledProperty && assert( options.enabledProperty === buttonModel.enabledProperty,
'if options.enabledProperty is provided, it must === buttonModel.enabledProperty' );
options.enabledProperty = buttonModel.enabledProperty;
super();
this.content = options.content;
this.buttonModel = buttonModel;
this._settableBaseColorProperty = new PaintColorProperty( options.baseColor );
this._disabledColorProperty = new PaintColorProperty( options.disabledColor );
this.baseColorProperty = new DerivedProperty( [
this._settableBaseColorProperty,
this.enabledProperty,
this._disabledColorProperty
], ( color, enabled, disabledColor ) => {
return enabled ? color : disabledColor;
} );
this._pressListener = buttonModel.createPressListener( options.listenerOptions );
this.addInputListener( this._pressListener );
assert && assert( buttonBackground.fill === null, 'ButtonNode controls the fill for the buttonBackground' );
buttonBackground.fill = this.baseColorProperty;
this.addChild( buttonBackground );
// Hook up the strategy that will control the button's appearance.
const buttonAppearanceStrategy = new options.buttonAppearanceStrategy(
buttonBackground,
interactionStateProperty,
this.baseColorProperty,
options.buttonAppearanceStrategyOptions
);
// Optionally hook up the strategy that will control the content's appearance.
let contentAppearanceStrategy: InstanceType<TContentAppearanceStrategy>;
if ( options.contentAppearanceStrategy && options.content ) {
contentAppearanceStrategy = new options.contentAppearanceStrategy(
options.content,
interactionStateProperty, options.contentAppearanceStrategyOptions
);
}
// Get our maxLineWidth from the appearance strategy, as it's needed for layout (and in subtypes)
this.maxLineWidth = buttonAppearanceStrategy.maxLineWidth;
let alignBox: AlignBox | null = null;
let updateAlignBounds: UnknownMultilink | null = null;
if ( options.content ) {
// Container here that can get scaled/positioned/pickable-modified, without affecting the provided content.
this.contentContainer = new Node( {
children: [
options.content
],
// For performance, in case content is a complicated icon or shape.
// See https://github.com/phetsims/sun/issues/654#issuecomment-718944669
pickable: false
} );
// Align content in the button rectangle. Must be disposed since it adds listener to content bounds.
alignBox = new AlignBox( this.contentContainer, {
xAlign: options.xAlign,
yAlign: options.yAlign,
// Apply offsets via margins, so that bounds of the AlignBox doesn't unnecessarily extend past the
// buttonBackground. See https://github.com/phetsims/sun/issues/649
leftMargin: options.xMargin + options.xContentOffset,
rightMargin: options.xMargin - options.xContentOffset,
topMargin: options.yMargin + options.yContentOffset,
bottomMargin: options.yMargin - options.yContentOffset
} );
// Dynamically adjust alignBounds.
updateAlignBounds = Multilink.multilink(
[ buttonBackground.boundsProperty, this.layoutSizeProperty ],
( backgroundBounds, size ) => {
if ( size.width > 0 && size.height > 0 ) {
alignBox!.alignBounds = Bounds2.point( backgroundBounds.center ).dilatedXY( size.width / 2, size.height / 2 );
}
}
);
this.addChild( alignBox );
}
this.mutate( options );
// No need to dispose because enabledProperty is disposed in Node
this.enabledProperty.link( enabled => options.enabledAppearanceStrategy( enabled, this, buttonBackground, alignBox ) );
// Decorating with additional content is an anti-pattern, see https://github.com/phetsims/sun/issues/860
assert && assertNoAdditionalChildren( this );
this.disposeButtonNode = () => {
alignBox && alignBox.dispose();
updateAlignBounds && updateAlignBounds.dispose();
buttonAppearanceStrategy.dispose && buttonAppearanceStrategy.dispose();
contentAppearanceStrategy && contentAppearanceStrategy.dispose && contentAppearanceStrategy.dispose();
this._pressListener.dispose();
this.baseColorProperty.dispose();
this._settableBaseColorProperty.dispose();
this._disabledColorProperty.dispose();
};
}
public override dispose(): void {
this.disposeButtonNode();
super.dispose();
}
/**
* Sets the base color, which is the main background fill color used for the button.
*/
public setBaseColor( baseColor: TColor ): void { this._settableBaseColorProperty.paint = baseColor; }
public set baseColor( baseColor: TColor ) { this.setBaseColor( baseColor ); }
public get baseColor(): TColor { return this.getBaseColor(); }
/**
* Gets the base color for this button.
*/
public getBaseColor(): TColor { return this._settableBaseColorProperty.paint as TColor; }
/**
* Manually click the button, as it would be clicked in response to alternative input. Recommended only for
* accessibility usages. For the most part, PDOM button functionality should be managed by PressListener, this should
* rarely be used.
*/
public pdomClick(): void {
this._pressListener.click( null );
}
}
/**
* FlatAppearanceStrategy is a value for ButtonNode options.buttonAppearanceStrategy. It makes a
* button look flat, i.e. no shading or highlighting, with color changes on mouseover, press, etc.
*/
export class FlatAppearanceStrategy {
public readonly maxLineWidth: number;
private readonly disposeFlatAppearanceStrategy: () => void;
/**
* @param buttonBackground - the Node for the button's background, sans content
* @param interactionStateProperty - interaction state, used to trigger updates
* @param baseColorProperty - base color from which other colors are derived
* @param [providedOptions]
*/
public constructor( buttonBackground: PaintableNode,
interactionStateProperty: TReadOnlyProperty<ButtonInteractionState>,
baseColorProperty: TReadOnlyProperty<Color>,
providedOptions?: TButtonAppearanceStrategyOptions ) {
// dynamic colors
const baseBrighter4Property = new PaintColorProperty( baseColorProperty, { luminanceFactor: 0.4 } );
const baseDarker4Property = new PaintColorProperty( baseColorProperty, { luminanceFactor: -0.4 } );
// various fills that are used to alter the button's appearance
const upFillProperty = baseColorProperty;
const overFillProperty = baseBrighter4Property;
const downFillProperty = baseDarker4Property;
const options = combineOptions<TButtonAppearanceStrategyOptions>( {
stroke: baseDarker4Property
}, providedOptions );
const lineWidth = typeof options.lineWidth === 'number' ? options.lineWidth : 1;
// If the stroke wasn't provided, set a default.
buttonBackground.stroke = options.stroke || baseDarker4Property;
buttonBackground.lineWidth = lineWidth;
this.maxLineWidth = buttonBackground.hasStroke() ? lineWidth : 0;
// Cache colors
buttonBackground.cachedPaints = [ upFillProperty, overFillProperty, downFillProperty ];
// Change colors to match interactionState
function interactionStateListener( interactionState: ButtonInteractionState ): void {
switch( interactionState ) {
case ButtonInteractionState.IDLE:
buttonBackground.fill = upFillProperty;
break;
case ButtonInteractionState.OVER:
buttonBackground.fill = overFillProperty;
break;
case ButtonInteractionState.PRESSED:
buttonBackground.fill = downFillProperty;
break;
default:
throw new Error( `unsupported interactionState: ${interactionState}` );
}
}
// Do the initial update explicitly, then lazy link to the properties. This keeps the number of initial updates to
// a minimum and allows us to update some optimization flags the first time the base color is actually changed.
interactionStateProperty.link( interactionStateListener );
this.disposeFlatAppearanceStrategy = () => {
if ( interactionStateProperty.hasListener( interactionStateListener ) ) {
interactionStateProperty.unlink( interactionStateListener );
}
baseBrighter4Property.dispose();
baseDarker4Property.dispose();
};
}
public dispose(): void {
this.disposeFlatAppearanceStrategy();
}
}
ButtonNode.FlatAppearanceStrategy = FlatAppearanceStrategy;
sun.register( 'ButtonNode', ButtonNode );