/**
* @fileoverview Collection of controller classes used by the GUI.
* @see Controller
* @see ValuedController
* @see Button
* @see FileLoader
* @see TextFileLoader
* @see JSONFileLoader
* @see ImageLoader
* @see Toggle
* @see Select
* @see ResolutionSelect
* @see Slider
* @see RangeSlider
* @see XYSlider
* @see ColourTextArea
* @see ColourBoxes
* @see MultiColourBoxes
*/
/**
* Base class for all GUI controllers.
* @extends Field
* @example
* // For any Controller, the main callback function passed into it will trigger when the controller is triggered, like this:
* const triggerButton = new Button(
* gui,
* 'buttonTrigger',
* 'Do something!',
* controller => {
* doSomething();
* }
* );
*
* @example
* // You can add an optional setupCallback function which calls after the GUI item is created:
* const triggerButton = new Button(
* gui,
* 'buttonTrigger',
* 'Do something!',
* controller => {
* doSomething();
* },
* controller => {
* // simulate a click on loading the app
* controller.click();
* }
* );
*/
class Controller extends Field {
/**
* Static flag to control whether the change set should be updated.
* @type {boolean}
*/
static _doUpdateChangeSet = true;
/**
* Flag to control whether the change set should be updated.
* @type {boolean}
*/
_doUpdateChangeSet = true;
/**
* The HTML element representing the controller.
* @type {p5.Element|HTMLElement}
*/
controllerElement = null;
/**
* Flag to control whether the controller should randomize its value.
* @type {boolean}
*/
doRandomize = undefined;
/**
* The GUIForP5 instance this controller belongs to.
* @type {GUIForP5}
*/
gui;
/**
* The name of the controller.
* @type {string}
*/
name;
/**
* The label for the controller.
* @type {string}
*/
label;
/**
* The wrapper div for the controller.
* @type {p5.Element}
*/
controllerWrapper;
/**
* Optional setup callback.
* @type {function}
*/
setupCallback;
/**
* The console div element for displaying messages.
* @type {p5.Element}
*/
console;
/**
* The current console text.
* @type {string}
*/
consoleText;
/**
* The die icon for randomization.
* @type {DieIcon}
*/
die;
/**
* Constructor for the Controller class.
* @param {GUI} gui - The GUI instance this controller belongs to.
* @param {string} name - The name of the controller.
* @param {string} labelStr - The label text for the controller.
* @param {function} [setupCallback] - Optional callback function for setup.
*/
constructor(gui, name, labelStr, setupCallback = undefined) {
super(gui.div, name, 'gui-controller');
this.gui = gui;
this.name = name;
if (labelStr !== undefined) {
labelStr = lang.process(labelStr, true);
this.label = new Label(this, labelStr);
}
this.controllerWrapper = createDiv();
this.controllerWrapper.class('controller-wrapper');
this.controllerWrapper.parent(this.div);
this.setupCallback = setupCallback || (controller => {});
}
/**
* Setup for the controller.
*/
setup() {
this.createConsole();
this.setupCallback(this);
}
/**
* Disables the controller.
*/
disable() {
if (this.controllerElement instanceof p5.Element)
this.controllerElement.elt.disabled = true;
else this.controllerElement.disabled = true;
}
/**
* Enables the controller.
*/
enable() {
if (this.controllerElement instanceof p5.Element)
this.controllerElement.elt.disabled = false;
else this.controllerElement.disabled = false;
}
/**
* Checks if the controller is disabled.
* @returns {boolean} - True if the controller is disabled, false otherwise.
*/
isDisabled() {
if (this.controllerElement instanceof p5.Element)
return this.controllerElement.elt.disabled;
else return this.controllerElement.disabled;
}
/**
* Sets the disabled state of the controller.
* @param {boolean} doSetDisabled - True to disable the controller, false to enable it.
*/
setDisabled(doSetDisabled) {
doSetDisabled ? this.disable() : this.enable();
}
/**
* Creates the console element for the controller.
*/
createConsole() {
this.console = createDiv();
this.console.parent(this.div);
this.console.class('gui-console');
this.console.hide();
}
/**
* Sets the console text and type.
* @param {string} text - The text to display in the console.
* @param {string} [type] - The type of message ('error', 'warning', etc.).
*/
setConsole(text, type) {
if (text === undefined) {
this.consoleText = undefined;
this.console.hide();
this.console.html('');
this.console.class('gui-console');
return;
}
if (type === undefined) text = '🔺 ' + text;
this.consoleText = text;
this.console.class('gui-console');
this.console.addClass('gui-console-' + type);
this.console.html(text);
this.console.show();
}
/**
* Sets the error message in the console.
* @param {string} text - The error message to display.
*/
setError(text) {
this.setConsole('❌ ' + text, 'error');
}
/**
* Sets the warning message in the console.
* @param {string} text - The warning message to display.
*/
setWarning(text) {
this.setConsole('⚠️ ' + text, 'warning');
}
/**
* Adds this controller to a randomizer.
* @param {Randomizer} randomizer - The randomizer to add this controller to.
*/
addToRandomizer(randomizer) {
randomizer.addController(this);
}
/**
* Adds a die to the controller.
* @param {DieIcon} die - The die to add.
*/
addDie(die) {
die.img.parent(this.controllerWrapper);
this.die = die;
}
/**
* Checks if the change set should be updated.
* @returns {boolean} - True if the change set should be updated, false otherwise.
*/
doUpdateChangeSet() {
return (
changeSet !== undefined &&
this._doUpdateChangeSet &&
Controller._doUpdateChangeSet
);
}
}
/**
* Controller that holds a value which can be serialised.
* @extends Controller
* @example
* // ValuedController gives back its value through a callback, that's where you tie it to the system.
* // I usually link it to generator like so, also using data from generator to construct the controller:
* const fgColBoxes = new ColourBoxes(
* gui,
* 'colourBoxesFgCol',
* 'Foreground colour',
* generator.palette,
* 0,
* (controller, value) => {
* generator.fgCol = value;
* }
* );
*/
class ValuedController extends Controller {
/**
* The value of the controller.
* @type {*}
*/
value;
/**
* Constructor for ValuedController.
* @param {GUI} gui
* @param {string} name
* @param {string} labelStr
* @param {function} [setupCallback]
*/
constructor(gui, name, labelStr, setupCallback = undefined) {
super(gui, name, labelStr, setupCallback);
}
/**
* Sets the value of the controller.
* @param {*} value - The value to set.
*/
setValue(value) {
this.value = value;
if (this.doUpdateChangeSet()) changeSet.save();
}
/**
* Randomizes the value of the controller.
*/
randomize() {
console.error('No randomize() method.');
}
/**
* Gets the value for JSON serialization.
* @returns {*}
*/
getValueForJSON() {
return this.value;
}
}
/**
* Simple push button controller.
* @extends Controller
*/
class Button extends Controller {
/**
* Constructor for Button.
* @param {GUI} gui
* @param {string} name
* @param {string} labelStr
* @param {function} callback
* @param {function} [setupCallback]
* @example
* const button = new Button(
* gui,
* 'buttonName',
* 'Click me',
* controller => {
* print('Button clicked!');
* }
* );
*/
constructor(gui, name, labelStr, callback, setupCallback = undefined) {
super(gui, name, undefined, setupCallback);
labelStr = lang.process(labelStr, true);
this.controllerElement = createButton(labelStr);
this.controllerElement.parent(this.controllerWrapper);
this.controllerElement.elt.onclick = () => {
callback(this);
if (this.doUpdateChangeSet()) changeSet.save();
};
}
/**
* Simulates a button click.
*/
click() {
this.controllerElement.elt.onclick();
}
}
/**
* Base class for file input controllers.
* @extends Button
*/
class FileLoader extends Button {
/**
* The file type accepted.
* @type {string}
*/
fileType;
/**
* The file object.
* @type {*}
*/
file;
/**
* The file name.
* @type {string}
*/
fileName;
/**
* Constructor for FileLoader.
* @param {GUI} gui
* @param {string} name
* @param {string} fileType
* @param {string} labelStr
* @param {function} fileReadyCallback
* @param {function} valueCallback
* @param {function} [setupCallback]
*/
constructor(
gui,
name,
fileType,
labelStr,
fileReadyCallback,
valueCallback,
setupCallback = undefined
) {
super(
gui,
name,
labelStr,
() => {
this.controllerElement.elt.click();
},
setupCallback
);
this.fileType = fileType;
this.callback = value => {
valueCallback(this, value);
};
this.controllerElement = createFileInput(file => {
this.file = file;
this.fileName = file.file.name;
fileReadyCallback(file);
this.callback(this.file);
});
this.controllerElement.parent(this.controllerWrapper);
this.controllerElement.hide();
}
}
/**
* Loader for plain text files.
* @extends FileLoader
*/
class TextFileLoader extends FileLoader {
/**
* Constructor for TextFileLoader.
* @param {GUI} gui
* @param {string} name
* @param {string} labelStr
* @param {function} valueCallback
* @param {function} [setupCallback]
*/
constructor(gui, name, labelStr, valueCallback, setupCallback = undefined) {
super(
gui,
name,
'text',
labelStr,
file => {},
valueCallback,
setupCallback
);
this.controllerElement.elt.accept = '.txt';
}
}
/**
* Loader for JSON files.
* @extends FileLoader
*/
class JSONFileLoader extends FileLoader {
/**
* Constructor for JSONFileLoader.
* @param {GUI} gui
* @param {string} name
* @param {string} labelStr
* @param {function} valueCallback
* @param {function} [setupCallback]
*/
constructor(gui, name, labelStr, valueCallback, setupCallback = undefined) {
super(
gui,
name,
'json',
labelStr,
file => {},
valueCallback,
setupCallback
);
this.controllerElement.elt.accept = '.json';
}
}
/**
* Loader that converts files to p5.Image instances.
* @extends FileLoader
*/
class ImageLoader extends FileLoader {
/**
* The loaded image.
* @type {p5.Element}
*/
img;
/**
* Constructor for ImageLoader.
* @param {GUI} gui
* @param {string} name
* @param {string} labelStr
* @param {function} valueCallback
* @param {function} [setupCallback]
*/
constructor(gui, name, labelStr, valueCallback, setupCallback = undefined) {
super(
gui,
name,
'image',
labelStr,
file => {
this.img = createImg(file.data, '');
this.img.hide();
this.file = this.img;
},
valueCallback,
setupCallback
);
this.controllerElement.elt.accept = '.jpg,.png,.gif,.tif';
}
}
/**
* On/off toggle represented by a button.
* @extends ValuedController
*/
class Toggle extends ValuedController {
/**
* The callback function for toggle.
* @type {function}
*/
callback;
/**
* Constructor for Toggle.
* @param {GUI} gui
* @param {string} name
* @param {string} labelStr0
* @param {string} labelStr1
* @param {boolean} isToggled
* @param {function} callback
* @param {function} [setupCallback]
*/
constructor(
gui,
name,
labelStr0,
labelStr1,
isToggled,
callback,
setupCallback = undefined
) {
super(gui, name, undefined, setupCallback);
this.controllerElement = createButton('');
this.controllerElement.parent(this.controllerWrapper);
this.controllerElement.class('toggle');
this.controllerElement.elt.onmousedown = () => callback(this);
labelStr0 = lang.process(labelStr0, true);
labelStr1 = lang.process(labelStr1, true);
const span0 = createSpan(labelStr0);
const span1 = createSpan(labelStr1);
span0.parent(this.controllerElement);
span1.parent(this.controllerElement);
this.value = isToggled ? true : false;
this.controllerElement.elt.onmousedown = () => {
this.setValue(!this.value);
};
this.callback = callback;
}
/**
* Simulates a toggle click.
*/
click() {
this.controllerElement.elt.onmousedown();
}
/**
* Sets the toggle value.
* @param {boolean} value
*/
setValue(value) {
if (value != this.value)
this.controllerElement.elt.toggleAttribute('toggled');
this.value = value;
this.callback(this, this.value);
if (this.doUpdateChangeSet()) changeSet.save();
}
/**
* Randomizes the toggle value.
*/
randomize() {
this.setValue(random(1) < 0.5);
}
}
/**
* Drop-down select controller.
* @extends ValuedController
*/
class Select extends ValuedController {
/**
* The options for the select.
* @type {Array}
*/
options;
/**
* The string representations of the options.
* @type {Array<string>}
*/
optionStrs;
/**
* The value callback.
* @type {function}
*/
valueCallback;
/**
* Constructor for Select.
* @param {GUI} gui
* @param {string} name
* @param {string} labelStr
* @param {Array} options
* @param {number} defaultIndex
* @param {function} valueCallback
* @param {function} [setupCallback]
*/
constructor(
gui,
name,
labelStr,
options,
defaultIndex,
valueCallback,
setupCallback = undefined
) {
super(gui, name, labelStr, setupCallback);
this.controllerElement = createSelect();
this.setOptions(options);
const callback = event => {
const valueStr = event.srcElement.value;
const ind = this.optionStrs.indexOf(valueStr);
this.value = this.options[ind];
valueCallback(this, this.value);
};
this.controllerElement.elt.onchange = callback;
this.valueCallback = valueCallback;
this.setValue(options[defaultIndex]);
}
/**
* Sets the options for the select.
* @param {Array} options
*/
setOptions(options) {
this.controllerElement.elt.replaceChildren();
this.controllerElement.parent(this.controllerWrapper);
this.options = options;
this.optionStrs = options.map(option => this.optionToString(option));
for (const optionStr of this.optionStrs)
this.controllerElement.option(optionStr);
this.afterSetOptions();
}
/**
* Converts an option to a string.
* @param {*} option
* @returns {string}
*/
optionToString(option) {
return option.toString();
}
/**
* Called after setting options.
*/
afterSetOptions() {}
/**
* Checks if an option exists.
* @param {*} option
* @returns {boolean}
*/
hasOption(option) {
return this.options.some(o => o == option);
}
/**
* Checks if an option string exists.
* @param {string} optionStr
* @returns {boolean}
*/
hasOptionStr(optionStr) {
return this.optionStrs.some(os => os == optionStr);
}
/**
* Sets the value of the select.
* @param {*} option
*/
setValue(option) {
if (!this.hasOption(option)) {
throw new Error(option + ' was not found in options.');
}
this.value = option;
const optStr = this.optionStrs[this.options.indexOf(option)];
this.controllerElement.selected(optStr);
this.valueCallback(this, option);
if (this.doUpdateChangeSet()) changeSet.save();
}
/**
* Randomizes the select value.
*/
randomize() {
this.setValue(random(this.options));
}
}
/**
* Specialised select for common resolutions.
* @extends Select
*/
class ResolutionSelect extends Select {
constructor(
gui,
labelStr,
resOptions,
defaultIndex,
valueCallback,
setupCallback = undefined
) {
super(
gui,
'resolutionSelect',
labelStr,
resOptions.map(s => lang.process(s, true)),
defaultIndex,
(controller, value) => {
if (value.indexOf(' x ') >= 0) {
const resStr = value.split(': ')[1];
const wh = resStr.split(' x ');
const w = parseInt(wh[0]);
const h = parseInt(wh[1]);
resize(w, h);
}
valueCallback(controller, value);
},
setupCallback
);
}
}
/**
* One dimensional slider controller.
* @extends ValuedController
*/
class Slider extends ValuedController {
constructor(
gui,
name,
labelStr,
minVal,
maxVal,
defaultVal,
stepSize,
valueCallback,
setupCallback = undefined
) {
super(gui, name, labelStr, setupCallback);
this.controllerElement = createSlider(
minVal,
maxVal,
defaultVal,
stepSize
);
this.controllerElement.parent(this.controllerWrapper);
this.minVal = minVal;
this.maxVal = maxVal;
this.defaultVal = defaultVal;
this.stepSize = stepSize;
const callback = event => {
const value = parseFloat(event.srcElement.value);
valueCallback(this, value);
};
this.controllerElement.elt.onchange = callback;
this.controllerElement.elt.oninput = callback;
valueCallback(this, defaultVal);
this.valueCallback = valueCallback;
}
setValue(value) {
this.value = value;
this.valueCallback(this, value);
this.controllerElement.value(value);
if (this.doUpdateChangeSet()) changeSet.save();
}
randomize() {
this.setValue(random(this.minVal, this.maxVal));
}
}
/**
* Two handled slider returning a min/max range.
* @extends ValuedController
*/
class RangeSlider extends ValuedController {
constructor(
gui,
name,
labelStr,
minVal,
maxVal,
defaultValMin,
defaultValMax,
stepSize,
valueCallback,
setupCallback = undefined
) {
super(gui, name, labelStr, setupCallback);
this.controllerElement = createDiv()
.class('dual-range-input')
.parent(this.controllerWrapper);
this.minSlider = createSlider(
minVal,
maxVal,
defaultValMin,
stepSize
).parent(this.controllerElement);
this.maxSlider = createSlider(
minVal,
maxVal,
defaultValMax,
stepSize
).parent(this.controllerElement);
new DualRangeInput(this.minSlider.elt, this.maxSlider.elt);
this.minVal = minVal;
this.maxVal = maxVal;
this.defaultValMin = defaultValMin;
this.defaultValMax = defaultValMax;
this.stepSize = stepSize;
const callback = event => {
const minValue = parseFloat(this.minSlider.elt.value);
const maxValue = parseFloat(this.maxSlider.elt.value);
valueCallback(this, { min: minValue, max: maxValue });
};
this.minSlider.elt.onchange = callback;
this.minSlider.elt.oninput = callback;
this.maxSlider.elt.onchange = callback;
this.maxSlider.elt.oninput = callback;
this.valueCallback = valueCallback;
}
setValue(value) {
this.value = value;
this.valueCallback(this, value);
this.minSlider.value(value.min);
this.maxSlider.value(value.max);
if (this.doUpdateChangeSet()) changeSet.save();
}
randomize() {
const pivot = random(this.minVal, this.maxVal);
this.setValue({
min: random(this.minVal, pivot),
max: random(pivot, this.maxVal),
});
}
}
/**
* Two dimensional slider returning an {x,y} object.
* @extends ValuedController
*/
class XYSlider extends ValuedController {
constructor(
gui,
name,
labelStr,
minValX,
maxValX,
defaultValX,
stepSizeX,
minValY,
maxValY,
defaultValY,
stepSizeY,
valueCallback,
setupCallback = undefined
) {
super(gui, name, labelStr, setupCallback);
this.minValX = minValX;
this.minValY = minValY;
this.maxValX = maxValX;
this.maxValY = maxValY;
this.defaultValX = defaultValX;
this.defaultValY = defaultValY;
this.stepSizeX = stepSizeX;
this.stepSizeY = stepSizeY;
this.valueCallback = valueCallback;
this.controllerElement = createDiv();
this.controllerElement.class('xyslider');
this.controllerElement.parent(this.controllerWrapper);
const handle = createDiv();
handle.class('handle');
handle.parent(this.controllerElement);
this.handle = handle;
this.isDragging = false;
this.controllerElement.elt.addEventListener('mousedown', e => {
this.isDragging = true;
this._doUpdateChangeSet = false;
});
handle.elt.addEventListener('mousedown', e => {
this.isDragging = true;
this._doUpdateChangeSet = false;
});
handle.elt.addEventListener('mouseup', e => {
this.isDragging = false;
this._doUpdateChangeSet = true;
this.setValue(this.getValueFromHandlePosition(e));
});
document.addEventListener('mousemove', e => {
if (!this.isDragging) return;
this.setValue(this.getValueFromHandlePosition(e));
});
this.setValue({ x: this.defaultValX, y: this.defaultValY });
}
getValueFromHandlePosition(mouseEvent) {
const compStyle = window.getComputedStyle(this.controllerElement.elt);
const borderW = parseFloat(compStyle.borderWidth);
const rect = this.controllerElement.elt.getBoundingClientRect();
rect.width -= borderW * 2;
rect.height -= borderW * 2;
let x =
mouseEvent.clientX - rect.left - this.handle.elt.offsetWidth / 2;
let y =
mouseEvent.clientY - rect.top - this.handle.elt.offsetHeight / 2;
const handleW = this.handle.elt.offsetWidth;
const handleH = this.handle.elt.offsetHeight;
x = constrain(x, -handleW / 2, rect.width - handleW / 2);
y = constrain(y, -handleH / 2, rect.height - handleH / 2);
let normX = map(x, -handleW / 2, rect.width - handleW / 2, -1, 1);
let normY = map(y, -handleH / 2, rect.height - handleH / 2, -1, 1);
return this.mapSteppedFromNormedVec({ x: normX, y: normY });
}
mapSteppedFromNormedVec(normedVec) {
// snap to axes
if (abs(normedVec.x) < 0.033) normedVec.x = 0;
if (abs(normedVec.y) < 0.033) normedVec.y = 0;
const nStepsX = round((this.maxValX - this.minValX) / this.stepSizeX);
const nStepsY = round((this.maxValY - this.minValY) / this.stepSizeY);
return {
x:
this.minValX +
(round((normedVec.x * 0.5 + 0.5) * nStepsX) / nStepsX) *
(this.maxValX - this.minValX),
y:
this.minValY +
(round((normedVec.y * 0.5 + 0.5) * nStepsY) / nStepsY) *
(this.maxValY - this.minValY),
};
}
setValue(vec) {
if (vec.x === undefined || vec.y === undefined) {
console.error(
'Value must be a vector {x: X, y: Y}, not this: ',
vec
);
return;
}
this.value = vec;
this.setDisplay();
this.valueCallback(this, this.value);
if (this.doUpdateChangeSet()) changeSet.save();
}
setDisplay() {
const compStyle = window.getComputedStyle(this.controllerElement.elt);
const borderW = parseFloat(compStyle.borderWidth);
const rect = this.controllerElement.elt.getBoundingClientRect();
rect.width -= borderW * 2;
rect.height -= borderW * 2;
const handleW = this.handle.elt.offsetWidth;
const handleH = this.handle.elt.offsetHeight;
const feedbackX = map(
this.value.x,
this.minValX,
this.maxValX,
-handleW / 2,
rect.width - handleW / 2
);
const feedbackY = map(
this.value.y,
this.minValY,
this.maxValY,
-handleH / 2,
rect.height - handleH / 2
);
this.handle.elt.style.left = `${feedbackX}px`;
this.handle.elt.style.top = `${feedbackY}px`;
}
randomize() {
this.setValue(
this.mapSteppedFromNormedVec({
x: random(-1, 1),
y: random(-1, 1),
})
);
}
}
/**
* Radio buttons displaying coloured options.
* @extends ValuedController
*/
class ColourBoxes extends ValuedController {
constructor(
gui,
name,
labelStr,
colours,
defaultIndex,
valueCallback,
setupCallback = undefined
) {
super(gui, name, labelStr, setupCallback);
this.valueCallback = valueCallback;
this.createRadioFromColours(colours);
this.setValue(colours[defaultIndex]);
}
createRadioFromColours(colours) {
const isInit = this.controllerElement === undefined;
if (this.controllerElement) {
this.controllerElement.elt.remove();
}
const radio = createRadio(this.name);
radio.class('colour-boxes');
this.controllerWrapper.elt.prepend(radio.elt);
for (let i = 0; i < colours.length; i++) {
radio.option(i.toString());
}
// remove span labels from p5 structure
for (const elt of radio.elt.querySelectorAll('span')) elt.remove();
let i = 0;
for (const elt of radio.elt.querySelectorAll('input')) {
const hexCol = colorToHexString(colours[i++]).toUpperCase();
elt.style.backgroundColor = hexCol;
elt.title = hexCol;
elt.onclick = evt => {
this.setValue(this.colours[parseInt(elt.value)]);
};
}
this.colours = colours;
this.controllerElement = radio;
}
setValue(colObj) {
if (!(colObj instanceof p5.Color))
throw new Error(colObj + ' is not a p5.Color.');
const index = this.colours.findIndex(col =>
isArraysEqual(col.levels, colObj.levels)
);
this.value = this.colours[index];
this.controllerElement.selected('' + index);
this.valueCallback(this, this.value);
if (this.doUpdateChangeSet()) changeSet.save();
}
randomize() {
this.setValue(random(this.colours));
}
}
/**
* Multiple selectable colour checkboxes.
* @extends ValuedController
*/
class MultiColourBoxes extends ValuedController {
constructor(
gui,
name,
labelStr,
colours,
defaultIndices,
valueCallback,
setupCallback = undefined
) {
super(gui, name, labelStr, setupCallback);
this.colours = colours;
this.valueCallback = valueCallback;
this.setControllerColours();
const defaultCols = defaultIndices.map(i => this.colours[i]);
this.setValue(defaultCols);
}
setControllerColours() {
if (this.controllerElement) {
this.controllerElement.remove();
}
const div = createDiv();
div.class('colour-boxes');
div.parent(this.controllerWrapper);
this.checkboxes = [];
for (let i = 0; i < this.colours.length; i++) {
const cb = createCheckbox();
cb.parent(div);
cb.value('' + i);
cb.elt.addEventListener('click', () => {
const indices = [];
this.checkboxes.forEach((c, idx) => {
if (c.checked()) indices.push(idx);
});
this.setValueFromIndices(indices);
});
this.checkboxes.push(cb);
}
div.elt.querySelectorAll('span').forEach(elt => {
elt.remove();
});
div.elt.querySelectorAll('input').forEach((elt, i) => {
const hexCol = colorToHexString(this.colours[i]).toUpperCase();
elt.style.backgroundColor = hexCol;
elt.title = hexCol;
});
this.controllerElement = div;
}
setValueFromIndices(indices) {
this.valueIndices = indices;
this.value = indices.map(i => this.colours[i]);
this.checkboxes.forEach((cb, i) => {
cb.checked(indices.includes(i));
});
this.valueCallback(this, this.value);
if (this.doUpdateChangeSet()) changeSet.save();
}
setValue(colArray) {
const indices = colArray.map(colObj => {
if (!(colObj instanceof p5.Color))
throw new Error(colObj + ' is not a p5.Color.');
return this.colours.findIndex(col =>
isArraysEqual(col.levels, colObj.levels)
);
});
this.setValueFromIndices(indices);
}
randomize() {
const indices = [];
for (let i = 0; i < this.colours.length; i++) {
if (random(1) < 0.5) indices.push(i);
}
if (indices.length === 0)
indices.push(floor(random(this.colours.length)));
this.setValueFromIndices(indices);
}
}
/**
* Single line text input controller.
* @extends ValuedController
*/
class Textbox extends ValuedController {
constructor(
gui,
name,
labelStr,
defaultVal,
valueCallback,
setupCallback = undefined
) {
super(gui, name, labelStr, setupCallback);
this.controllerElement = createInput();
this.controllerElement.parent(this.controllerWrapper);
this.controllerElement.value(defaultVal);
this.controllerElement.elt.oninput = event => {
const value = event.srcElement.value;
valueCallback(this, value);
};
this.valueCallback = valueCallback;
this.controllerElement.elt.addEventListener(
'focusin',
event => (gui.isTypingText = true)
);
this.controllerElement.elt.addEventListener(
'focusout',
event => (gui.isTypingText = false)
);
}
setValue(value) {
this.value = value;
this.valueCallback(this, value);
this.controllerElement.value(value);
if (this.doUpdateChangeSet()) changeSet.save();
}
randomize() {}
}
/**
* Pair of textboxes for width and height values.
* @extends ValuedController
*/
class ResolutionTextboxes extends ValuedController {
constructor(gui, defW, defH, valueCallback, setupCallback = undefined) {
super(gui, 'resolutionTextboxes', undefined, setupCallback);
this.w = defW;
this.h = defH;
this.wBox = new Textbox(
gui,
'resolutionTextBoxes-Width',
lang.process('LANG_WIDTH:', true),
defW,
(controller, value) => {
const pxDim = parseInt(value);
if (isNaN(pxDim)) {
return;
}
this.w = pxDim;
resize(this.w, this.h);
valueCallback(this, { w: this.w, h: this.h });
}
);
this.hBox = new Textbox(
gui,
'resolutionTextBoxes-Height',
lang.process('LANG_HEIGHT:', true),
defH,
(controller, value) => {
const pxDim = parseInt(value);
if (isNaN(pxDim)) {
return;
}
this.h = pxDim;
resize(this.w, this.h);
valueCallback(this, { w: this.w, h: this.h });
}
);
for (const tb of [this.wBox, this.hBox]) {
tb.div.parent(this.controllerWrapper);
}
this.div.style('display', 'flex');
this.div.style('flex-direction', 'row');
this.div.style('gap', '1em');
}
setValue(vec) {
this.value = vec;
this.wBox.setValue(vec.w);
this.hBox.setValue(vec.h);
if (this.doUpdateChangeSet()) changeSet.save();
}
setValueOnlyDisplay(w, h) {
this.wBox.controllerElement.value(w);
this.hBox.controllerElement.value(h);
}
}
/**
* Multi line text area controller.
* @extends ValuedController
*/
class Textarea extends ValuedController {
constructor(
gui,
name,
labelStr,
defaultVal,
valueCallback,
setupCallback = undefined
) {
super(gui, name, labelStr, setupCallback);
this.controllerElement = createElement('textarea');
this.controllerElement.parent(this.controllerWrapper);
this.controllerElement.html(defaultVal);
this.controllerElement.elt.oninput = event => {
const value = event.srcElement.value;
valueCallback(this, value);
};
this.valueCallback = valueCallback;
this.controllerElement.elt.addEventListener(
'focusin',
event => (gui.isTypingText = true)
);
this.controllerElement.elt.addEventListener('focusout', event => {
gui.isTypingText = false;
const value = event.srcElement.value;
this.setValue(value);
});
}
setValue(value) {
this.value = value;
this.valueCallback(this, value);
this.controllerElement.value(value);
if (this.doUpdateChangeSet()) changeSet.save();
}
randomize() {}
}
/**
* Textarea that accepts and displays colour lists.
* A list of hex colours, like: "`#ff0000, #00ff00, #0000ff`",
* will output a `value` of an array of `p5.Color` objects.
* @extends Textarea
* @see ColourBoxes
* @see MultiColourBoxes
*/
class ColourTextArea extends Textarea {
/**
* ColourTextArea constructor.
* @param {GUI} gui - The GUI instance.
* @param {string} name - The name of the controller.
* @param {string} labelStr - The label for the controller.
* @param {Array<p5.Color>} colours - The initial list of colours.
* @param {function} valueCallback - Callback function for value changes.
* @param {function} [setupCallback] - Optional setup callback.
* @example
* const colourTextArea = new ColourTextArea(
* gui,
* 'colourTextArea',
* 'Enter colours:',
* generator.volours,
* (controller, value) => {
* console.log('Colours changed:', value);
* }
* );
*/
constructor(
gui,
name,
labelStr,
colours,
valueCallback,
setupCallback = undefined
) {
const colourList = ColourTextArea.colourListToString(colours);
super(gui, name, labelStr, colourList, valueCallback, setupCallback);
this.controllerElement.elt.oninput = event => {
const value = ColourTextArea.parseColourList(
event.srcElement.value
);
this.valueCallback(this, value);
this.displayColours(value);
};
this.displayColours(colours);
}
displayColours(colours) {
if (this.disp) this.disp.elt.remove();
this.disp = createDiv();
this.disp.class('colour-text-area-display');
this.disp.parent(this.div);
for (let col of colours) {
let colBlock = createDiv();
colBlock.class('colour-text-area-block');
colBlock.style('background-color', colorToHexString(col));
colBlock.parent(this.disp);
}
}
static colourListToString(colours) {
return colours.map(c => colorToHexString(c).toUpperCase()).join(',');
}
static parseColourList(str) {
return str
.split(',')
.map(cstr => cstr.trim())
.filter(cstr => cstr.length == 7 && cstr[0] == '#')
.map(cstr => color(cstr));
}
}