lil-gui
Version:
Makes a floating panel for controllers on the web.
1,949 lines (1,550 loc) • 61.2 kB
JavaScript
/**
* lil-gui
* https://lil-gui.georgealways.com
* @version 0.20.0
* @author George Michael Brower
* @license MIT
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.lil = {}));
})(this, (function (exports) { 'use strict';
/**
* Base class for all controllers.
*/
class Controller {
constructor( parent, object, property, className, elementType = 'div' ) {
/**
* The GUI that contains this controller.
* @type {GUI}
*/
this.parent = parent;
/**
* The object this controller will modify.
* @type {object}
*/
this.object = object;
/**
* The name of the property to control.
* @type {string}
*/
this.property = property;
/**
* Used to determine if the controller is disabled.
* Use `controller.disable( true|false )` to modify this value.
* @type {boolean}
*/
this._disabled = false;
/**
* Used to determine if the Controller is hidden.
* Use `controller.show()` or `controller.hide()` to change this.
* @type {boolean}
*/
this._hidden = false;
/**
* The value of `object[ property ]` when the controller was created.
* @type {any}
*/
this.initialValue = this.getValue();
/**
* The outermost container DOM element for this controller.
* @type {HTMLElement}
*/
this.domElement = document.createElement( elementType );
this.domElement.classList.add( 'controller' );
this.domElement.classList.add( className );
/**
* The DOM element that contains the controller's name.
* @type {HTMLElement}
*/
this.$name = document.createElement( 'div' );
this.$name.classList.add( 'name' );
Controller.nextNameID = Controller.nextNameID || 0;
this.$name.id = `lil-gui-name-${++Controller.nextNameID}`;
/**
* The DOM element that contains the controller's "widget" (which differs by controller type).
* @type {HTMLElement}
*/
this.$widget = document.createElement( 'div' );
this.$widget.classList.add( 'widget' );
/**
* The DOM element that receives the disabled attribute when using disable().
* @type {HTMLElement}
*/
this.$disable = this.$widget;
this.domElement.appendChild( this.$name );
this.domElement.appendChild( this.$widget );
// Don't fire global key events while typing in a controller
this.domElement.addEventListener( 'keydown', e => e.stopPropagation() );
this.domElement.addEventListener( 'keyup', e => e.stopPropagation() );
this.parent.children.push( this );
this.parent.controllers.push( this );
this.parent.$children.appendChild( this.domElement );
this._listenCallback = this._listenCallback.bind( this );
this.name( property );
}
/**
* Sets the name of the controller and its label in the GUI.
* @param {string} name
* @returns {this}
*/
name( name ) {
/**
* The controller's name. Use `controller.name( 'Name' )` to modify this value.
* @type {string}
*/
this._name = name;
this.$name.textContent = name;
return this;
}
/**
* Pass a function to be called whenever the value is modified by this controller.
* The function receives the new value as its first parameter. The value of `this` will be the
* controller.
*
* For function controllers, the `onChange` callback will be fired on click, after the function
* executes.
* @param {Function} callback
* @returns {this}
* @example
* const controller = gui.add( object, 'property' );
*
* controller.onChange( function( v ) {
* console.log( 'The value is now ' + v );
* console.assert( this === controller );
* } );
*/
onChange( callback ) {
/**
* Used to access the function bound to `onChange` events. Don't modify this value directly.
* Use the `controller.onChange( callback )` method instead.
* @type {Function}
*/
this._onChange = callback;
return this;
}
/**
* Calls the onChange methods of this controller and its parent GUI.
* @protected
*/
_callOnChange() {
this.parent._callOnChange( this );
if ( this._onChange !== undefined ) {
this._onChange.call( this, this.getValue() );
}
this._changed = true;
}
/**
* Pass a function to be called after this controller has been modified and loses focus.
* @param {Function} callback
* @returns {this}
* @example
* const controller = gui.add( object, 'property' );
*
* controller.onFinishChange( function( v ) {
* console.log( 'Changes complete: ' + v );
* console.assert( this === controller );
* } );
*/
onFinishChange( callback ) {
/**
* Used to access the function bound to `onFinishChange` events. Don't modify this value
* directly. Use the `controller.onFinishChange( callback )` method instead.
* @type {Function}
*/
this._onFinishChange = callback;
return this;
}
/**
* Should be called by Controller when its widgets lose focus.
* @protected
*/
_callOnFinishChange() {
if ( this._changed ) {
this.parent._callOnFinishChange( this );
if ( this._onFinishChange !== undefined ) {
this._onFinishChange.call( this, this.getValue() );
}
}
this._changed = false;
}
/**
* Sets the controller back to its initial value.
* @returns {this}
*/
reset() {
this.setValue( this.initialValue );
this._callOnFinishChange();
return this;
}
/**
* Enables this controller.
* @param {boolean} enabled
* @returns {this}
* @example
* controller.enable();
* controller.enable( false ); // disable
* controller.enable( controller._disabled ); // toggle
*/
enable( enabled = true ) {
return this.disable( !enabled );
}
/**
* Disables this controller.
* @param {boolean} disabled
* @returns {this}
* @example
* controller.disable();
* controller.disable( false ); // enable
* controller.disable( !controller._disabled ); // toggle
*/
disable( disabled = true ) {
if ( disabled === this._disabled ) return this;
this._disabled = disabled;
this.domElement.classList.toggle( 'disabled', disabled );
this.$disable.toggleAttribute( 'disabled', disabled );
return this;
}
/**
* Shows the Controller after it's been hidden.
* @param {boolean} show
* @returns {this}
* @example
* controller.show();
* controller.show( false ); // hide
* controller.show( controller._hidden ); // toggle
*/
show( show = true ) {
this._hidden = !show;
this.domElement.style.display = this._hidden ? 'none' : '';
return this;
}
/**
* Hides the Controller.
* @returns {this}
*/
hide() {
return this.show( false );
}
/**
* Changes this controller into a dropdown of options.
*
* Calling this method on an option controller will simply update the options. However, if this
* controller was not already an option controller, old references to this controller are
* destroyed, and a new controller is added to the end of the GUI.
* @example
* // safe usage
*
* gui.add( obj, 'prop1' ).options( [ 'a', 'b', 'c' ] );
* gui.add( obj, 'prop2' ).options( { Big: 10, Small: 1 } );
* gui.add( obj, 'prop3' );
*
* // danger
*
* const ctrl1 = gui.add( obj, 'prop1' );
* gui.add( obj, 'prop2' );
*
* // calling options out of order adds a new controller to the end...
* const ctrl2 = ctrl1.options( [ 'a', 'b', 'c' ] );
*
* // ...and ctrl1 now references a controller that doesn't exist
* assert( ctrl2 !== ctrl1 )
* @param {object|Array} options
* @returns {Controller}
*/
options( options ) {
const controller = this.parent.add( this.object, this.property, options );
controller.name( this._name );
this.destroy();
return controller;
}
/**
* Sets the minimum value. Only works on number controllers.
* @param {number} min
* @returns {this}
*/
min( min ) {
return this;
}
/**
* Sets the maximum value. Only works on number controllers.
* @param {number} max
* @returns {this}
*/
max( max ) {
return this;
}
/**
* Values set by this controller will be rounded to multiples of `step`. Only works on number
* controllers.
* @param {number} step
* @returns {this}
*/
step( step ) {
return this;
}
/**
* Rounds the displayed value to a fixed number of decimals, without affecting the actual value
* like `step()`. Only works on number controllers.
* @example
* gui.add( object, 'property' ).listen().decimals( 4 );
* @param {number} decimals
* @returns {this}
*/
decimals( decimals ) {
return this;
}
/**
* Calls `updateDisplay()` every animation frame. Pass `false` to stop listening.
* @param {boolean} listen
* @returns {this}
*/
listen( listen = true ) {
/**
* Used to determine if the controller is currently listening. Don't modify this value
* directly. Use the `controller.listen( true|false )` method instead.
* @type {boolean}
*/
this._listening = listen;
if ( this._listenCallbackID !== undefined ) {
cancelAnimationFrame( this._listenCallbackID );
this._listenCallbackID = undefined;
}
if ( this._listening ) {
this._listenCallback();
}
return this;
}
_listenCallback() {
this._listenCallbackID = requestAnimationFrame( this._listenCallback );
// To prevent framerate loss, make sure the value has changed before updating the display.
// Note: save() is used here instead of getValue() only because of ColorController. The !== operator
// won't work for color objects or arrays, but ColorController.save() always returns a string.
const curValue = this.save();
if ( curValue !== this._listenPrevValue ) {
this.updateDisplay();
}
this._listenPrevValue = curValue;
}
/**
* Returns `object[ property ]`.
* @returns {any}
*/
getValue() {
return this.object[ this.property ];
}
/**
* Sets the value of `object[ property ]`, invokes any `onChange` handlers and updates the display.
* @param {any} value
* @returns {this}
*/
setValue( value ) {
if ( this.getValue() !== value ) {
this.object[ this.property ] = value;
this._callOnChange();
this.updateDisplay();
}
return this;
}
/**
* Updates the display to keep it in sync with the current value. Useful for updating your
* controllers when their values have been modified outside of the GUI.
* @returns {this}
*/
updateDisplay() {
return this;
}
load( value ) {
this.setValue( value );
this._callOnFinishChange();
return this;
}
save() {
return this.getValue();
}
/**
* Destroys this controller and removes it from the parent GUI.
*/
destroy() {
this.listen( false );
this.parent.children.splice( this.parent.children.indexOf( this ), 1 );
this.parent.controllers.splice( this.parent.controllers.indexOf( this ), 1 );
this.parent.$children.removeChild( this.domElement );
}
}
class BooleanController extends Controller {
constructor( parent, object, property ) {
super( parent, object, property, 'boolean', 'label' );
this.$input = document.createElement( 'input' );
this.$input.setAttribute( 'type', 'checkbox' );
this.$input.setAttribute( 'aria-labelledby', this.$name.id );
this.$widget.appendChild( this.$input );
this.$input.addEventListener( 'change', () => {
this.setValue( this.$input.checked );
this._callOnFinishChange();
} );
this.$disable = this.$input;
this.updateDisplay();
}
updateDisplay() {
this.$input.checked = this.getValue();
return this;
}
}
function normalizeColorString( string ) {
let match, result;
if ( match = string.match( /(#|0x)?([a-f0-9]{6})/i ) ) {
result = match[ 2 ];
} else if ( match = string.match( /rgb\(\s*(\d*)\s*,\s*(\d*)\s*,\s*(\d*)\s*\)/ ) ) {
result = parseInt( match[ 1 ] ).toString( 16 ).padStart( 2, 0 )
+ parseInt( match[ 2 ] ).toString( 16 ).padStart( 2, 0 )
+ parseInt( match[ 3 ] ).toString( 16 ).padStart( 2, 0 );
} else if ( match = string.match( /^#?([a-f0-9])([a-f0-9])([a-f0-9])$/i ) ) {
result = match[ 1 ] + match[ 1 ] + match[ 2 ] + match[ 2 ] + match[ 3 ] + match[ 3 ];
}
if ( result ) {
return '#' + result;
}
return false;
}
const STRING = {
isPrimitive: true,
match: v => typeof v === 'string',
fromHexString: normalizeColorString,
toHexString: normalizeColorString
};
const INT = {
isPrimitive: true,
match: v => typeof v === 'number',
fromHexString: string => parseInt( string.substring( 1 ), 16 ),
toHexString: value => '#' + value.toString( 16 ).padStart( 6, 0 )
};
const ARRAY = {
isPrimitive: false,
// The arrow function is here to appease tree shakers like esbuild or webpack.
// See https://esbuild.github.io/api/#tree-shaking
match: v => Array.isArray( v ),
fromHexString( string, target, rgbScale = 1 ) {
const int = INT.fromHexString( string );
target[ 0 ] = ( int >> 16 & 255 ) / 255 * rgbScale;
target[ 1 ] = ( int >> 8 & 255 ) / 255 * rgbScale;
target[ 2 ] = ( int & 255 ) / 255 * rgbScale;
},
toHexString( [ r, g, b ], rgbScale = 1 ) {
rgbScale = 255 / rgbScale;
const int = ( r * rgbScale ) << 16 ^
( g * rgbScale ) << 8 ^
( b * rgbScale ) << 0;
return INT.toHexString( int );
}
};
const OBJECT = {
isPrimitive: false,
match: v => Object( v ) === v,
fromHexString( string, target, rgbScale = 1 ) {
const int = INT.fromHexString( string );
target.r = ( int >> 16 & 255 ) / 255 * rgbScale;
target.g = ( int >> 8 & 255 ) / 255 * rgbScale;
target.b = ( int & 255 ) / 255 * rgbScale;
},
toHexString( { r, g, b }, rgbScale = 1 ) {
rgbScale = 255 / rgbScale;
const int = ( r * rgbScale ) << 16 ^
( g * rgbScale ) << 8 ^
( b * rgbScale ) << 0;
return INT.toHexString( int );
}
};
const FORMATS = [ STRING, INT, ARRAY, OBJECT ];
function getColorFormat( value ) {
return FORMATS.find( format => format.match( value ) );
}
class ColorController extends Controller {
constructor( parent, object, property, rgbScale ) {
super( parent, object, property, 'color' );
this.$input = document.createElement( 'input' );
this.$input.setAttribute( 'type', 'color' );
this.$input.setAttribute( 'tabindex', -1 );
this.$input.setAttribute( 'aria-labelledby', this.$name.id );
this.$text = document.createElement( 'input' );
this.$text.setAttribute( 'type', 'text' );
this.$text.setAttribute( 'spellcheck', 'false' );
this.$text.setAttribute( 'aria-labelledby', this.$name.id );
this.$display = document.createElement( 'div' );
this.$display.classList.add( 'display' );
this.$display.appendChild( this.$input );
this.$widget.appendChild( this.$display );
this.$widget.appendChild( this.$text );
this._format = getColorFormat( this.initialValue );
this._rgbScale = rgbScale;
this._initialValueHexString = this.save();
this._textFocused = false;
this.$input.addEventListener( 'input', () => {
this._setValueFromHexString( this.$input.value );
} );
this.$input.addEventListener( 'blur', () => {
this._callOnFinishChange();
} );
this.$text.addEventListener( 'input', () => {
const tryParse = normalizeColorString( this.$text.value );
if ( tryParse ) {
this._setValueFromHexString( tryParse );
}
} );
this.$text.addEventListener( 'focus', () => {
this._textFocused = true;
this.$text.select();
} );
this.$text.addEventListener( 'blur', () => {
this._textFocused = false;
this.updateDisplay();
this._callOnFinishChange();
} );
this.$disable = this.$text;
this.updateDisplay();
}
reset() {
this._setValueFromHexString( this._initialValueHexString );
return this;
}
_setValueFromHexString( value ) {
if ( this._format.isPrimitive ) {
const newValue = this._format.fromHexString( value );
this.setValue( newValue );
} else {
this._format.fromHexString( value, this.getValue(), this._rgbScale );
this._callOnChange();
this.updateDisplay();
}
}
save() {
return this._format.toHexString( this.getValue(), this._rgbScale );
}
load( value ) {
this._setValueFromHexString( value );
this._callOnFinishChange();
return this;
}
updateDisplay() {
this.$input.value = this._format.toHexString( this.getValue(), this._rgbScale );
if ( !this._textFocused ) {
this.$text.value = this.$input.value.substring( 1 );
}
this.$display.style.backgroundColor = this.$input.value;
return this;
}
}
class FunctionController extends Controller {
constructor( parent, object, property ) {
super( parent, object, property, 'function' );
// Buttons are the only case where widget contains name
this.$button = document.createElement( 'button' );
this.$button.appendChild( this.$name );
this.$widget.appendChild( this.$button );
this.$button.addEventListener( 'click', e => {
e.preventDefault();
this.getValue().call( this.object );
this._callOnChange();
} );
// enables :active pseudo class on mobile
this.$button.addEventListener( 'touchstart', () => {}, { passive: true } );
this.$disable = this.$button;
}
}
class NumberController extends Controller {
constructor( parent, object, property, min, max, step ) {
super( parent, object, property, 'number' );
this._initInput();
this.min( min );
this.max( max );
const stepExplicit = step !== undefined;
this.step( stepExplicit ? step : this._getImplicitStep(), stepExplicit );
this.updateDisplay();
}
decimals( decimals ) {
this._decimals = decimals;
this.updateDisplay();
return this;
}
min( min ) {
this._min = min;
this._onUpdateMinMax();
return this;
}
max( max ) {
this._max = max;
this._onUpdateMinMax();
return this;
}
step( step, explicit = true ) {
this._step = step;
this._stepExplicit = explicit;
return this;
}
updateDisplay() {
const value = this.getValue();
if ( this._hasSlider ) {
let percent = ( value - this._min ) / ( this._max - this._min );
percent = Math.max( 0, Math.min( percent, 1 ) );
this.$fill.style.width = percent * 100 + '%';
}
if ( !this._inputFocused ) {
this.$input.value = this._decimals === undefined ? value : value.toFixed( this._decimals );
}
return this;
}
_initInput() {
this.$input = document.createElement( 'input' );
this.$input.setAttribute( 'type', 'text' );
this.$input.setAttribute( 'aria-labelledby', this.$name.id );
// On touch devices only, use input[type=number] to force a numeric keyboard.
// Ideally we could use one input type everywhere, but [type=number] has quirks
// on desktop, and [inputmode=decimal] has quirks on iOS.
// See https://github.com/georgealways/lil-gui/pull/16
const isTouch = window.matchMedia( '(pointer: coarse)' ).matches;
if ( isTouch ) {
this.$input.setAttribute( 'type', 'number' );
this.$input.setAttribute( 'step', 'any' );
}
this.$widget.appendChild( this.$input );
this.$disable = this.$input;
const onInput = () => {
let value = parseFloat( this.$input.value );
if ( isNaN( value ) ) return;
if ( this._stepExplicit ) {
value = this._snap( value );
}
this.setValue( this._clamp( value ) );
};
// Keys & mouse wheel
// ---------------------------------------------------------------------
const increment = delta => {
const value = parseFloat( this.$input.value );
if ( isNaN( value ) ) return;
this._snapClampSetValue( value + delta );
// Force the input to updateDisplay when it's focused
this.$input.value = this.getValue();
};
const onKeyDown = e => {
// Using `e.key` instead of `e.code` also catches NumpadEnter
if ( e.key === 'Enter' ) {
this.$input.blur();
}
if ( e.code === 'ArrowUp' ) {
e.preventDefault();
increment( this._step * this._arrowKeyMultiplier( e ) );
}
if ( e.code === 'ArrowDown' ) {
e.preventDefault();
increment( this._step * this._arrowKeyMultiplier( e ) * -1 );
}
};
const onWheel = e => {
if ( this._inputFocused ) {
e.preventDefault();
increment( this._step * this._normalizeMouseWheel( e ) );
}
};
// Vertical drag
// ---------------------------------------------------------------------
let testingForVerticalDrag = false,
initClientX,
initClientY,
prevClientY,
initValue,
dragDelta;
// Once the mouse is dragged more than DRAG_THRESH px on any axis, we decide
// on the user's intent: horizontal means highlight, vertical means drag.
const DRAG_THRESH = 5;
const onMouseDown = e => {
initClientX = e.clientX;
initClientY = prevClientY = e.clientY;
testingForVerticalDrag = true;
initValue = this.getValue();
dragDelta = 0;
window.addEventListener( 'mousemove', onMouseMove );
window.addEventListener( 'mouseup', onMouseUp );
};
const onMouseMove = e => {
if ( testingForVerticalDrag ) {
const dx = e.clientX - initClientX;
const dy = e.clientY - initClientY;
if ( Math.abs( dy ) > DRAG_THRESH ) {
e.preventDefault();
this.$input.blur();
testingForVerticalDrag = false;
this._setDraggingStyle( true, 'vertical' );
} else if ( Math.abs( dx ) > DRAG_THRESH ) {
onMouseUp();
}
}
// This isn't an else so that the first move counts towards dragDelta
if ( !testingForVerticalDrag ) {
const dy = e.clientY - prevClientY;
dragDelta -= dy * this._step * this._arrowKeyMultiplier( e );
// Clamp dragDelta so we don't have 'dead space' after dragging past bounds.
// We're okay with the fact that bounds can be undefined here.
if ( initValue + dragDelta > this._max ) {
dragDelta = this._max - initValue;
} else if ( initValue + dragDelta < this._min ) {
dragDelta = this._min - initValue;
}
this._snapClampSetValue( initValue + dragDelta );
}
prevClientY = e.clientY;
};
const onMouseUp = () => {
this._setDraggingStyle( false, 'vertical' );
this._callOnFinishChange();
window.removeEventListener( 'mousemove', onMouseMove );
window.removeEventListener( 'mouseup', onMouseUp );
};
// Focus state & onFinishChange
// ---------------------------------------------------------------------
const onFocus = () => {
this._inputFocused = true;
};
const onBlur = () => {
this._inputFocused = false;
this.updateDisplay();
this._callOnFinishChange();
};
this.$input.addEventListener( 'input', onInput );
this.$input.addEventListener( 'keydown', onKeyDown );
this.$input.addEventListener( 'wheel', onWheel, { passive: false } );
this.$input.addEventListener( 'mousedown', onMouseDown );
this.$input.addEventListener( 'focus', onFocus );
this.$input.addEventListener( 'blur', onBlur );
}
_initSlider() {
this._hasSlider = true;
// Build DOM
// ---------------------------------------------------------------------
this.$slider = document.createElement( 'div' );
this.$slider.classList.add( 'slider' );
this.$fill = document.createElement( 'div' );
this.$fill.classList.add( 'fill' );
this.$slider.appendChild( this.$fill );
this.$widget.insertBefore( this.$slider, this.$input );
this.domElement.classList.add( 'hasSlider' );
// Map clientX to value
// ---------------------------------------------------------------------
const map = ( v, a, b, c, d ) => {
return ( v - a ) / ( b - a ) * ( d - c ) + c;
};
const setValueFromX = clientX => {
const rect = this.$slider.getBoundingClientRect();
let value = map( clientX, rect.left, rect.right, this._min, this._max );
this._snapClampSetValue( value );
};
// Mouse drag
// ---------------------------------------------------------------------
const mouseDown = e => {
this._setDraggingStyle( true );
setValueFromX( e.clientX );
window.addEventListener( 'mousemove', mouseMove );
window.addEventListener( 'mouseup', mouseUp );
};
const mouseMove = e => {
setValueFromX( e.clientX );
};
const mouseUp = () => {
this._callOnFinishChange();
this._setDraggingStyle( false );
window.removeEventListener( 'mousemove', mouseMove );
window.removeEventListener( 'mouseup', mouseUp );
};
// Touch drag
// ---------------------------------------------------------------------
let testingForScroll = false, prevClientX, prevClientY;
const beginTouchDrag = e => {
e.preventDefault();
this._setDraggingStyle( true );
setValueFromX( e.touches[ 0 ].clientX );
testingForScroll = false;
};
const onTouchStart = e => {
if ( e.touches.length > 1 ) return;
// If we're in a scrollable container, we should wait for the first
// touchmove to see if the user is trying to slide or scroll.
if ( this._hasScrollBar ) {
prevClientX = e.touches[ 0 ].clientX;
prevClientY = e.touches[ 0 ].clientY;
testingForScroll = true;
} else {
// Otherwise, we can set the value straight away on touchstart.
beginTouchDrag( e );
}
window.addEventListener( 'touchmove', onTouchMove, { passive: false } );
window.addEventListener( 'touchend', onTouchEnd );
};
const onTouchMove = e => {
if ( testingForScroll ) {
const dx = e.touches[ 0 ].clientX - prevClientX;
const dy = e.touches[ 0 ].clientY - prevClientY;
if ( Math.abs( dx ) > Math.abs( dy ) ) {
// We moved horizontally, set the value and stop checking.
beginTouchDrag( e );
} else {
// This was, in fact, an attempt to scroll. Abort.
window.removeEventListener( 'touchmove', onTouchMove );
window.removeEventListener( 'touchend', onTouchEnd );
}
} else {
e.preventDefault();
setValueFromX( e.touches[ 0 ].clientX );
}
};
const onTouchEnd = () => {
this._callOnFinishChange();
this._setDraggingStyle( false );
window.removeEventListener( 'touchmove', onTouchMove );
window.removeEventListener( 'touchend', onTouchEnd );
};
// Mouse wheel
// ---------------------------------------------------------------------
// We have to use a debounced function to call onFinishChange because
// there's no way to tell when the user is "done" mouse-wheeling.
const callOnFinishChange = this._callOnFinishChange.bind( this );
const WHEEL_DEBOUNCE_TIME = 400;
let wheelFinishChangeTimeout;
const onWheel = e => {
// ignore vertical wheels if there's a scrollbar
const isVertical = Math.abs( e.deltaX ) < Math.abs( e.deltaY );
if ( isVertical && this._hasScrollBar ) return;
e.preventDefault();
// set value
const delta = this._normalizeMouseWheel( e ) * this._step;
this._snapClampSetValue( this.getValue() + delta );
// force the input to updateDisplay when it's focused
this.$input.value = this.getValue();
// debounce onFinishChange
clearTimeout( wheelFinishChangeTimeout );
wheelFinishChangeTimeout = setTimeout( callOnFinishChange, WHEEL_DEBOUNCE_TIME );
};
this.$slider.addEventListener( 'mousedown', mouseDown );
this.$slider.addEventListener( 'touchstart', onTouchStart, { passive: false } );
this.$slider.addEventListener( 'wheel', onWheel, { passive: false } );
}
_setDraggingStyle( active, axis = 'horizontal' ) {
if ( this.$slider ) {
this.$slider.classList.toggle( 'active', active );
}
document.body.classList.toggle( 'lil-gui-dragging', active );
document.body.classList.toggle( `lil-gui-${axis}`, active );
}
_getImplicitStep() {
if ( this._hasMin && this._hasMax ) {
return ( this._max - this._min ) / 1000;
}
return 0.1;
}
_onUpdateMinMax() {
if ( !this._hasSlider && this._hasMin && this._hasMax ) {
// If this is the first time we're hearing about min and max
// and we haven't explicitly stated what our step is, let's
// update that too.
if ( !this._stepExplicit ) {
this.step( this._getImplicitStep(), false );
}
this._initSlider();
this.updateDisplay();
}
}
_normalizeMouseWheel( e ) {
let { deltaX, deltaY } = e;
// Safari and Chrome report weird non-integral values for a notched wheel,
// but still expose actual lines scrolled via wheelDelta. Notched wheels
// should behave the same way as arrow keys.
if ( Math.floor( e.deltaY ) !== e.deltaY && e.wheelDelta ) {
deltaX = 0;
deltaY = -e.wheelDelta / 120;
deltaY *= this._stepExplicit ? 1 : 10;
}
const wheel = deltaX + -deltaY;
return wheel;
}
_arrowKeyMultiplier( e ) {
let mult = this._stepExplicit ? 1 : 10;
if ( e.shiftKey ) {
mult *= 10;
} else if ( e.altKey ) {
mult /= 10;
}
return mult;
}
_snap( value ) {
// Make the steps "start" at min or max.
let offset = 0;
if ( this._hasMin ) {
offset = this._min;
} else if ( this._hasMax ) {
offset = this._max;
}
value -= offset;
value = Math.round( value / this._step ) * this._step;
value += offset;
// Used to prevent "flyaway" decimals like 1.00000000000001
value = parseFloat( value.toPrecision( 15 ) );
return value;
}
_clamp( value ) {
// either condition is false if min or max is undefined
if ( value < this._min ) value = this._min;
if ( value > this._max ) value = this._max;
return value;
}
_snapClampSetValue( value ) {
this.setValue( this._clamp( this._snap( value ) ) );
}
get _hasScrollBar() {
const root = this.parent.root.$children;
return root.scrollHeight > root.clientHeight;
}
get _hasMin() {
return this._min !== undefined;
}
get _hasMax() {
return this._max !== undefined;
}
}
class OptionController extends Controller {
constructor( parent, object, property, options ) {
super( parent, object, property, 'option' );
this.$select = document.createElement( 'select' );
this.$select.setAttribute( 'aria-labelledby', this.$name.id );
this.$display = document.createElement( 'div' );
this.$display.classList.add( 'display' );
this.$select.addEventListener( 'change', () => {
this.setValue( this._values[ this.$select.selectedIndex ] );
this._callOnFinishChange();
} );
this.$select.addEventListener( 'focus', () => {
this.$display.classList.add( 'focus' );
} );
this.$select.addEventListener( 'blur', () => {
this.$display.classList.remove( 'focus' );
} );
this.$widget.appendChild( this.$select );
this.$widget.appendChild( this.$display );
this.$disable = this.$select;
this.options( options );
}
options( options ) {
this._values = Array.isArray( options ) ? options : Object.values( options );
this._names = Array.isArray( options ) ? options : Object.keys( options );
this.$select.replaceChildren();
this._names.forEach( name => {
const $option = document.createElement( 'option' );
$option.textContent = name;
this.$select.appendChild( $option );
} );
this.updateDisplay();
return this;
}
updateDisplay() {
const value = this.getValue();
const index = this._values.indexOf( value );
this.$select.selectedIndex = index;
this.$display.textContent = index === -1 ? value : this._names[ index ];
return this;
}
}
class StringController extends Controller {
constructor( parent, object, property ) {
super( parent, object, property, 'string' );
this.$input = document.createElement( 'input' );
this.$input.setAttribute( 'type', 'text' );
this.$input.setAttribute( 'spellcheck', 'false' );
this.$input.setAttribute( 'aria-labelledby', this.$name.id );
this.$input.addEventListener( 'input', () => {
this.setValue( this.$input.value );
} );
this.$input.addEventListener( 'keydown', e => {
if ( e.code === 'Enter' ) {
this.$input.blur();
}
} );
this.$input.addEventListener( 'blur', () => {
this._callOnFinishChange();
} );
this.$widget.appendChild( this.$input );
this.$disable = this.$input;
this.updateDisplay();
}
updateDisplay() {
this.$input.value = this.getValue();
return this;
}
}
var stylesheet = `.lil-gui {
font-family: var(--font-family);
font-size: var(--font-size);
line-height: 1;
font-weight: normal;
font-style: normal;
text-align: left;
color: var(--text-color);
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
--background-color: #1f1f1f;
--text-color: #ebebeb;
--title-background-color: #111111;
--title-text-color: #ebebeb;
--widget-color: #424242;
--hover-color: #4f4f4f;
--focus-color: #595959;
--number-color: #2cc9ff;
--string-color: #a2db3c;
--font-size: 11px;
--input-font-size: 11px;
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
--font-family-mono: Menlo, Monaco, Consolas, "Droid Sans Mono", monospace;
--padding: 4px;
--spacing: 4px;
--widget-height: 20px;
--title-height: calc(var(--widget-height) + var(--spacing) * 1.25);
--name-width: 45%;
--slider-knob-width: 2px;
--slider-input-width: 27%;
--color-input-width: 27%;
--slider-input-min-width: 45px;
--color-input-min-width: 45px;
--folder-indent: 7px;
--widget-padding: 0 0 0 3px;
--widget-border-radius: 2px;
--checkbox-size: calc(0.75 * var(--widget-height));
--scrollbar-width: 5px;
}
.lil-gui, .lil-gui * {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.lil-gui.root {
width: var(--width, 245px);
display: flex;
flex-direction: column;
background: var(--background-color);
}
.lil-gui.root > .title {
background: var(--title-background-color);
color: var(--title-text-color);
}
.lil-gui.root > .children {
overflow-x: hidden;
overflow-y: auto;
}
.lil-gui.root > .children::-webkit-scrollbar {
width: var(--scrollbar-width);
height: var(--scrollbar-width);
background: var(--background-color);
}
.lil-gui.root > .children::-webkit-scrollbar-thumb {
border-radius: var(--scrollbar-width);
background: var(--focus-color);
}
@media (pointer: coarse) {
.lil-gui.allow-touch-styles, .lil-gui.allow-touch-styles .lil-gui {
--widget-height: 28px;
--padding: 6px;
--spacing: 6px;
--font-size: 13px;
--input-font-size: 16px;
--folder-indent: 10px;
--scrollbar-width: 7px;
--slider-input-min-width: 50px;
--color-input-min-width: 65px;
}
}
.lil-gui.force-touch-styles, .lil-gui.force-touch-styles .lil-gui {
--widget-height: 28px;
--padding: 6px;
--spacing: 6px;
--font-size: 13px;
--input-font-size: 16px;
--folder-indent: 10px;
--scrollbar-width: 7px;
--slider-input-min-width: 50px;
--color-input-min-width: 65px;
}
.lil-gui.autoPlace {
max-height: 100%;
position: fixed;
top: 0;
right: 15px;
z-index: 1001;
}
.lil-gui .controller {
display: flex;
align-items: center;
padding: 0 var(--padding);
margin: var(--spacing) 0;
}
.lil-gui .controller.disabled {
opacity: 0.5;
}
.lil-gui .controller.disabled, .lil-gui .controller.disabled * {
pointer-events: none !important;
}
.lil-gui .controller > .name {
min-width: var(--name-width);
flex-shrink: 0;
white-space: pre;
padding-right: var(--spacing);
line-height: var(--widget-height);
}
.lil-gui .controller .widget {
position: relative;
display: flex;
align-items: center;
width: 100%;
min-height: var(--widget-height);
}
.lil-gui .controller.string input {
color: var(--string-color);
}
.lil-gui .controller.boolean {
cursor: pointer;
}
.lil-gui .controller.color .display {
width: 100%;
height: var(--widget-height);
border-radius: var(--widget-border-radius);
position: relative;
}
@media (hover: hover) {
.lil-gui .controller.color .display:hover:before {
content: " ";
display: block;
position: absolute;
border-radius: var(--widget-border-radius);
border: 1px solid #fff9;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
}
.lil-gui .controller.color input[type=color] {
opacity: 0;
width: 100%;
height: 100%;
cursor: pointer;
}
.lil-gui .controller.color input[type=text] {
margin-left: var(--spacing);
font-family: var(--font-family-mono);
min-width: var(--color-input-min-width);
width: var(--color-input-width);
flex-shrink: 0;
}
.lil-gui .controller.option select {
opacity: 0;
position: absolute;
width: 100%;
max-width: 100%;
}
.lil-gui .controller.option .display {
position: relative;
pointer-events: none;
border-radius: var(--widget-border-radius);
height: var(--widget-height);
line-height: var(--widget-height);
max-width: 100%;
overflow: hidden;
word-break: break-all;
padding-left: 0.55em;
padding-right: 1.75em;
background: var(--widget-color);
}
@media (hover: hover) {
.lil-gui .controller.option .display.focus {
background: var(--focus-color);
}
}
.lil-gui .controller.option .display.active {
background: var(--focus-color);
}
.lil-gui .controller.option .display:after {
font-family: "lil-gui";
content: "↕";
position: absolute;
top: 0;
right: 0;
bottom: 0;
padding-right: 0.375em;
}
.lil-gui .controller.option .widget,
.lil-gui .controller.option select {
cursor: pointer;
}
@media (hover: hover) {
.lil-gui .controller.option .widget:hover .display {
background: var(--hover-color);
}
}
.lil-gui .controller.number input {
color: var(--number-color);
}
.lil-gui .controller.number.hasSlider input {
margin-left: var(--spacing);
width: var(--slider-input-width);
min-width: var(--slider-input-min-width);
flex-shrink: 0;
}
.lil-gui .controller.number .slider {
width: 100%;
height: var(--widget-height);
background: var(--widget-color);
border-radius: var(--widget-border-radius);
padding-right: var(--slider-knob-width);
overflow: hidden;
cursor: ew-resize;
touch-action: pan-y;
}
@media (hover: hover) {
.lil-gui .controller.number .slider:hover {
background: var(--hover-color);
}
}
.lil-gui .controller.number .slider.active {
background: var(--focus-color);
}
.lil-gui .controller.number .slider.active .fill {
opacity: 0.95;
}
.lil-gui .controller.number .fill {
height: 100%;
border-right: var(--slider-knob-width) solid var(--number-color);
box-sizing: content-box;
}
.lil-gui-dragging .lil-gui {
--hover-color: var(--widget-color);
}
.lil-gui-dragging * {
cursor: ew-resize !important;
}
.lil-gui-dragging.lil-gui-vertical * {
cursor: ns-resize !important;
}
.lil-gui .title {
height: var(--title-height);
font-weight: 600;
padding: 0 var(--padding);
width: 100%;
text-align: left;
background: none;
text-decoration-skip: objects;
}
.lil-gui .title:before {
font-family: "lil-gui";
content: "▾";
padding-right: 2px;
display: inline-block;
}
.lil-gui .title:active {
background: var(--title-background-color);
opacity: 0.75;
}
@media (hover: hover) {
body:not(.lil-gui-dragging) .lil-gui .title:hover {
background: var(--title-background-color);
opacity: 0.85;
}
.lil-gui .title:focus {
text-decoration: underline var(--focus-color);
}
}
.lil-gui.root > .title:focus {
text-decoration: none !important;
}
.lil-gui.closed > .title:before {
content: "▸";
}
.lil-gui.closed > .children {
transform: translateY(-7px);
opacity: 0;
}
.lil-gui.closed:not(.transition) > .children {
display: none;
}
.lil-gui.transition > .children {
transition-duration: 300ms;
transition-property: height, opacity, transform;
transition-timing-function: cubic-bezier(0.2, 0.6, 0.35, 1);
overflow: hidden;
pointer-events: none;
}
.lil-gui .children:empty:before {
content: "Empty";
padding: 0 var(--padding);
margin: var(--spacing) 0;
display: block;
height: var(--widget-height);
font-style: italic;
line-height: var(--widget-height);
opacity: 0.5;
}
.lil-gui.root > .children > .lil-gui > .title {
border: 0 solid var(--widget-color);
border-width: 1px 0;
transition: border-color 300ms;
}
.lil-gui.root > .children > .lil-gui.closed > .title {
border-bottom-color: transparent;
}
.lil-gui + .controller {
border-top: 1px solid var(--widget-color);
margin-top: 0;
padding-top: var(--spacing);
}
.lil-gui .lil-gui .lil-gui > .title {
border: none;
}
.lil-gui .lil-gui .lil-gui > .children {
border: none;
margin-left: var(--folder-indent);
border-left: 2px solid var(--widget-color);
}
.lil-gui .lil-gui .controller {
border: none;
}
.lil-gui label, .lil-gui input, .lil-gui button {
-webkit-tap-highlight-color: transparent;
}
.lil-gui input {
border: 0;
outline: none;
font-family: var(--font-family);
font-size: var(--input-font-size);
border-radius: var(--widget-border-radius);
height: var(--widget-height);
background: var(--widget-color);
color: var(--text-color);
width: 100%;
}
@media (hover: hover) {
.lil-gui input:hover {
background: var(--hover-color);
}
.lil-gui input:active {
background: var(--focus-color);
}
}
.lil-gui input:disabled {
opacity: 1;
}
.lil-gui input[type=text],
.lil-gui input[type=number] {
padding: var(--widget-padding);
-moz-appearance: textfield;
}
.lil-gui input[type=text]:focus,
.lil-gui input[type=number]:focus {
background: var(--focus-color);
}
.lil-gui input[type=checkbox] {
appearance: none;
width: var(--checkbox-size);
height: var(--checkbox-size);
border-radius: var(--widget-border-radius);
text-align: center;
cursor: pointer;
}
.lil-gui input[type=checkbox]:checked:before {
font-family: "lil-gui";
content: "✓";
font-size: var(--checkbox-size);
line-height: var(--checkbox-size);
}
@media (hover: hover) {
.lil-gui input[type=checkbox]:focus {
box-shadow: inset 0 0 0 1px var(--focus-color);
}
}
.lil-gui button {
outline: none;
cursor: pointer;
font-family: var(--font-family);
font-size: var(--font-size);
color: var(--text-color);
width: 100%;
border: none;
}
.lil-gui .controller button {
height: var(--widget-height);
text-transform: none;
background: var(--widget-color);
border-radius: var(--widget-border-radius);
}
@media (hover: hover) {
.lil-gui .controller button:hover {
background: var(--hover-color);
}
.lil-gui .controller button:focus {
box-shadow: inset 0 0 0 1px var(--focus-color);
}
}
.lil-gui .controller button:active {
background: var(--focus-color);
}
@font-face {
font-family: "lil-gui";
src: url("data:application/font-woff;charset=utf-8;base64,d09GRgABAAAAAAUsAAsAAAAACJwAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABHU1VCAAABCAAAAH4AAADAImwmYE9TLzIAAAGIAAAAPwAAAGBKqH5SY21hcAAAAcgAAAD0AAACrukyyJBnbHlmAAACvAAAAF8AAACEIZpWH2hlYWQAAAMcAAAAJwAAADZfcj2zaGhlYQAAA0QAAAAYAAAAJAC5AHhobXR4AAADXAAAABAAAABMAZAAAGxvY2EAAANsAAAAFAAAACgCEgIybWF4cAAAA4AAAAAeAAAAIAEfABJuYW1lAAADoAAAASIAAAIK9SUU/XBvc3QAAATEAAAAZgAAAJCTcMc2eJxVjbEOgjAURU+hFRBK1dGRL+ALnAiToyMLEzFpnPz/eAshwSa97517c/MwwJmeB9kwPl+0cf5+uGPZXsqPu4nvZabcSZldZ6kfyWnomFY/eScKqZNWupKJO6kXN3K9uCVoL7iInPr1X5baXs3tjuMqCtzEuagm/AAlzQgPAAB4nGNgYRBlnMDAysDAYM/gBiT5oLQBAwuDJAMDEwMrMwNWEJDmmsJwgCFeXZghBcjlZMgFCzOiKOIFAB71Bb8AeJy1kjFuwkAQRZ+DwRAwBtNQRUGKQ8OdKCAWUhAgKLhIuAsVSpWz5Bbkj3dEgYiUIszqWdpZe+Z7/wB1oCYmIoboiwiLT2WjKl/jscrHfGg/pKdMkyklC5Zs2LEfHYpjcRoPzme9MWWmk3dWbK9ObkWkikOetJ554fWyoEsmdSlt+uR0pCJR34b6t/TVg1SY3sYvdf8vuiKrpyaDXDISiegp17p7579Gp3p++y7HPAiY9pmTibljrr85qSidtlg4+l25GLCaS8e6rRxNBmsnERunKbaOObRz7N72ju5vdAjYpBXHgJylOAVsMseDAPEP8LYoUHicY2BiAAEfhiAGJgZWBgZ7RnFRdnVJELCQlBSRlATJMoLV2DK4glSYs6ubq5vbKrJLSbGrgEmovDuDJVhe3VzcXFwNLCOILB/C4IuQ1xTn5FPilBTj5FPmBAB4WwoqAHicY2BkYGAA4sk1sR/j+W2+MnAzpDBgAyEMQUCSg4EJxAEAwUgFHgB4nGNgZGBgSGFggJMhDIwMqEAYAByHATJ4nGNgAIIUNEwmAABl3AGReJxjYAACIQYlBiMGJ3wQAEcQBEV4nGNgZGBgEGZgY2BiAAEQyQWEDAz/wXwGAAsPATIAAHicXdBNSsNAHAXwl35iA0UQXYnMShfS9GPZA7T7LgIu03SSpkwzYTIt1BN4Ak/gKTyAeCxfw39jZkjymzcvAwmAW/wgwHUEGDb36+jQQ3GXGot79L24jxCP4gHzF/EIr4jEIe7wxhOC3g2TMYy4Q7+Lu/SHuEd/ivt4wJd4wPxbPEKMX3GI5+DJFGaSn4qNzk8mcbKSR6xdXdhSzaOZJGtdapd4vVPbi6rP+cL7TGXOHtXKll4bY1Xl7EGnPtp7Xy2n00zyKLVHfkHBa4IcJ2oD3cgggWvt/V/FbDrUlEUJhTn/0azVWbNTNr0Ens8de1tceK9xZmfB1CPjOmPH4kitmvOubcNpmVTN3oFJyjzCvnmrwhJTzqzVj9jiSX911FjeAAB4nG3HMRKCMBBA0f0giiKi4DU8k0V2GWbIZDOh4PoWWvq6J5V8If9NVNQcaDhyouXMhY4rPTcG7jwYmXhKq8Wz+p762aNaeYXom2n3m2dLTVgsrCgFJ7OTmIkYbwIbC6vIB7WmFfAAAA==") format("woff");
}`;
function _injectStyles( cssContent ) {
const injected = document.createElement( 'style' );
injected.innerHTML = cssContent;
const before = document.querySelector( 'head link[rel=stylesheet], head style' );
if ( before ) {
document.head.insertBefore( injected, before );
} else {
document.head.appendChild( injected );
}
}
let stylesInjected = false;
class GUI {
/**
* Creates a panel that holds controllers.
* @example
* new GUI();
* new GUI( { container: document.getElementById( 'custom' ) } );
*
* @param {object} [options]
* @param {boolean} [options.autoPlace=true]
* Adds the GUI to `document.body` and fixes it to the top right of the page.
*
* @param {HTMLElement} [options.container]
* Adds the GUI to this DOM element. Overrides `autoPlace`.
*
* @param {number} [options.width=245]
* Width of the GUI in pixels, usually set when name labels become too long. Note that you can make
* name labels wider in CSS with `.lil‑gui { ‑‑name‑width: 55% }`.
*
* @param {string} [options.title=Controls]
* Name to display in the title bar.
*
* @param {boolean} [options.closeFolders=false]
* Pass `true` to close all folders in this GUI by default.
*
* @param {boolean} [options.injectStyles=true]
* Injects the default stylesheet into the page if this is the first GUI.
* Pass `false` to use your own stylesheet.
*
* @param {number} [options.touchStyles=true]
* Makes controllers larger on touch devices. Pass `false` to disable touch styles.
*
* @param {GUI} [options.parent]
* Adds this GUI as a child in another GUI. Usually this is done for you by `addFolder()`.
*/
constructor( {
parent,
autoPlace = parent === undefined,
container,
width,
title = 'Controls',
closeFolders = false,
injectStyles = true,
touchStyles = true
} = {} ) {
/**
* The GUI containing this folder, or `undefined` if this is the root GUI.
* @type {GUI}
*/
this.parent = parent;
/**
* The top level GUI containing this folder, or `this` if this is the root GUI.
* @type {GUI}
*/
this.root = parent ? parent.root : this;
/**
* The list of controllers and folders contained by this GUI.
* @type {Array<GUI|Controller>}
*/
this.children = [];
/**
* The list of controllers contained by this GUI.
* @type {Array<Controller>}
*/
this.controllers = [];
/**
* The list of folders contained by this GUI.
* @type {Array<GUI>}
*/
this.folders = [];
/**
* Used to determine if the GUI is closed. Use `gui.open()` or `gui.close()` to change this.
* @type {boolean}
*/
this._closed = false;
/**
* Used to determine if the GUI is hidden. Use `gui.show()` or `gui.hide()` to change this.
* @type {boolean}
*/
this._hidden = false;
/**
* The outermost container element.
* @type {HTMLElement}
*/
this.domElement = document.createElement( 'div' );
this.domElement.classList.add( 'lil-gui' );
/**
* The DOM element that contains the title.
* @type {HTMLElement}
*/
this.$title = document.createElement( 'button' );
this.$title.classList.add( 'title' );
this.$title.setAttribute( 'aria-expanded', true );
this.$title.addEventListener( 'click', () => this.openAnimated( this._closed ) );
// enables :active pseudo class on mobile
this.$title.addEventListener( 'touchstart', () => {}, { passive: true } );
/**
* The DOM element that contains children.
* @type {HTMLElement}
*/
this.$children = document.createElement( 'div' );
this.$children.classList.add( 'children' );
this.domElement.appendChild( this.$title );
this.domElement.appendChild( this.$children );
this.title( title );
if ( this.parent ) {
this.parent.children.push( this );
this.parent.folders.push( this );
this.parent.$children.appendChild( this.domElement );
// Stop the constructor early, everything onward only applies to root GUI's
return;
}
this.domElement.classList.add( 'root' );
if ( touchStyles ) {
this.domElement.classList.add( 'allow-touch-styles' );
}
// Inject stylesheet if we haven't done that yet
if ( !stylesInjected && injectStyles ) {
_injectStyles( stylesheet );
stylesInjected = true;
}
if ( container ) {
container.appendChild( this.domElement );
} else if ( autoPlace ) {
this.domElement.classList.add( 'autoPlace' );
document.body.appendChild( this.domElement );
}
if ( width ) {
this.domElement.style.setProperty( '--width', width + 'px' );
}
this._closeFolders = closeFolders;
}
/**
* Adds a controller to the GUI, inferring controller type using the `typeof` operator.
* @example
* gui.add( object, 'property' );
* gui.add( object, 'number', 0, 100, 1 );
* gui.add( object, 'options', [ 1, 2, 3 ] );
*
* @param {object} object The object the controller will modify.
* @param {string} property Name of the property to control.
* @param {number|object|Array} [$1] Minimum value for number controllers, or the set of
* selectable value