# visual/Form.js

    /**
     * Form Stimulus.
     *
     * @author Alain Pitiot
     * @version 2022.2.3
     * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org)
     * @license Distributed under the terms of the MIT License
     */

    import * as PIXI from "pixi.js-legacy";
    import { TrialHandler } from "../data/TrialHandler.js";
    import { Color } from "../util/Color.js";
    import { ColorMixin } from "../util/ColorMixin.js";
    import { to_pixiPoint } from "../util/Pixi.js";
    import * as util from "../util/Util.js";
    import { Slider } from "./Slider.js";
    import { TextBox } from "./TextBox.js";
    import { TextStim } from "./TextStim.js";
    import { VisualStim } from "./VisualStim.js";

    /**
     * Form stimulus.
     *
     * @extends module:visual.VisualStim
     * @mixes module:util.ColorMixin
     */
    export class Form extends util.mix(VisualStim).with(ColorMixin)
    {
    	/**
    	 * @memberOf module:visual
    	 * @param {Object} options
    	 * @param {String} options.name - the name used when logging messages from this stimulus
    	 * @param {module:core.Window} options.win - the associated Window
    	 * @param {number[]} [options.pos= [0, 0]] - the position of the center of the slider
    	 * @param {number[]} options.size - the size of the slider, e.g. [1, 0.1] for an horizontal slider
    	 * @param {string} [options.units= 'height'] - the units of the Slider position, and font size
    	 *
    	 * @param {Color} [options.color= Color('LightGray')] the color of the slider
    	 * @param {number} [options.contrast= 1.0] - the contrast of the slider
    	 * @param {number} [options.opacity= 1.0] - the opacity of the slider
    	 * @param {number} [options.depth= 0] - the depth (i.e. the z order), note that the text, radio buttons and slider elements are at depth - 1
    	 *
    	 * @param {number[]} [options.items= []] - the array of labels
    	 * @param {number} [options.itemPadding= 0.05] - the granularity
    	 *
    	 * @param {string} [options.font= 'Arial'] - the text font
    	 * @param {string} [options.fontFamily= 'Helvetica'] - the text font
    	 * @param {boolean} [options.bold= true] - whether or not the font of the labels is bold
    	 * @param {boolean} [options.italic= false] - whether or not the font of the labels is italic
    	 * @param {number} [options.fontSize] - the font size of the labels (in form units), the default fontSize
    	 * depends on the Form units: 14 for 'pix', 0.03 otherwise
    	 *
    	 * @param {PIXI.Graphics} [options.clipMask= null] - the clip mask
    	 * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every
    	 *   frame flip
    	 * @param {boolean} [options.autoLog= false] - whether or not to log
    	 */
    	constructor(
    		{
    			name,
    			win,
    			pos,
    			size,
    			units,
    			borderColor,
    			fillColor,
    			itemColor,
    			markerColor,
    			responseColor,
    			color,
    			contrast,
    			opacity,
    			depth,
    			items,
    			randomize,
    			itemPadding,
    			font,
    			fontFamily,
    			bold,
    			italic,
    			fontSize,
    			clipMask,
    			autoDraw,
    			autoLog,
    		} = {},
    	)
    	{
    		super({ name, win, units, opacity, depth, pos, size, clipMask, autoDraw, autoLog });

    		this._addAttribute(
    			"itemPadding",
    			itemPadding,
    			util.to_unit([20, 0], "pix", win, this._units)[0],
    			this._onChange(true, false),
    		);

    		// colors:
    		this._addAttribute(
    			"color",
    			// Same as itemColor
    			color,
    			undefined,
    			this._onChange(true, false),
    		);

    		this._addAttribute(
    			"borderColor",
    			borderColor,
    			fillColor,
    			this._onChange(true, false),
    		);

    		this._addAttribute(
    			"fillColor",
    			fillColor,
    			undefined,
    			this._onChange(true, false),
    		);

    		this._addAttribute(
    			"itemColor",
    			itemColor,
    			undefined,
    			this._onChange(true, false),
    		);

    		this._addAttribute(
    			"markerColor",
    			markerColor,
    			undefined,
    			this._onChange(true, false),
    		);

    		this._addAttribute(
    			"responseColor",
    			responseColor,
    			undefined,
    			this._onChange(true, false),
    		);

    		this._addAttribute(
    			"contrast",
    			contrast,
    			1.0,
    			this._onChange(true, false),
    		);

    		// fonts:
    		this._addAttribute(
    			"font",
    			font,
    			"Arial",
    			this._onChange(true, true),
    		);
    		// Not in use at present
    		this._addAttribute(
    			"fontFamily",
    			fontFamily,
    			"Helvetica",
    			this._onChange(true, true),
    		);
    		this._addAttribute(
    			"fontSize",
    			fontSize,
    			(this._units === "pix") ? 14 : 0.03,
    			this._onChange(true, true),
    		);
    		this._addAttribute(
    			"bold",
    			bold,
    			false,
    			this._onChange(true, true),
    		);
    		this._addAttribute(
    			"italic",
    			italic,
    			false,
    			this._onChange(true, true),
    		);

    		// callback to deal with changes to items:
    		const onItemChange = () =>
    		{
    			// reprocess the items:
    			this._processItems();

    			// setup the stimuli:
    			this._setupStimuli();

    			this._onChange(true, true)();
    		};

    		this._addAttribute(
    			"items",
    			items,
    			[],
    			onItemChange,
    		);
    		this._addAttribute(
    			"randomize",
    			randomize,
    			false,
    			onItemChange,
    		);

    		this._scrollbarWidth = 0.02;
    		this._responseTextHeightRatio = 0.8;

    		// process the items:
    		this._processItems();

    		// setup the stimuli:
    		this._setupStimuli();

    		if (this._autoLog)
    		{
    			this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`);
    		}
    	}

    	/**
    	 * Force a refresh of the stimulus.
    	 */
    	refresh()
    	{
    		super.refresh();

    		for (let i = 0; i < this._items.length; ++i)
    		{
    			const textStim = this._visual.textStims[i];
    			textStim.refresh();

    			const responseStim = this._visual.responseStims[i];
    			if (responseStim)
    			{
    				responseStim.refresh();
    			}
    		}
    	}

    	/**
    	 * Overridden draw that also calls the draw method of all form elements.
    	 *
    	 * @override
    	 */
    	draw()
    	{
    		// if the scrollbar's marker position has changed then the layout must be updated:
    		if (this._scrollbar.markerPos !== this._prevScrollbarMarkerPos)
    		{
    			this._prevScrollbarMarkerPos = this._scrollbar.markerPos;
    			this._needUpdate = true;
    		}

    		// draw the decorations:
    		super.draw();

    		// draw the stimuli:
    		for (let i = 0; i < this._items.length; ++i)
    		{
    			if (this._visual.visibles[i])
    			{
    				const textStim = this._visual.textStims[i];
    				textStim.draw();

    				const responseStim = this._visual.responseStims[i];
    				if (responseStim)
    				{
    					responseStim.draw();
    				}
    			}
    		}

    		// draw the scrollbar:
    		this._scrollbar.draw();
    	}

    	/**
    	 * Overridden hide that also calls the hide method of all form elements.
    	 *
    	 * @override
    	 */
    	hide()
    	{
    		// hide the decorations:
    		super.hide();

    		// hide the stimuli:
    		if (typeof this._items !== "undefined")
    		{
    			for (let i = 0; i < this._items.length; ++i)
    			{
    				if (this._visual.visibles[i])
    				{
    					const textStim = this._visual.textStims[i];
    					textStim.hide();

    					const responseStim = this._visual.responseStims[i];
    					if (responseStim)
    					{
    						responseStim.hide();
    					}
    				}
    			}

    			// hide the scrollbar:
    			this._scrollbar.hide();
    		}
    	}

    	/**
    	 * Reset the form.
    	 */
    	reset()
    	{
    		this.psychoJS.logger.debug("reset Form: ", this._name);

    		// reset the stimuli:
    		for (let i = 0; i < this._items.length; ++i)
    		{
    			const textStim = this._visual.textStims[i];
    			textStim.reset();

    			const responseStim = this._visual.responseStims[i];
    			if (responseStim)
    			{
    				responseStim.reset();
    			}
    		}

    		this._needUpdate = true;
    	}

    	/**
    	 * Collate the questions and responses into a single dataset.
    	 *
    	 * @return {object} - the dataset with all questions and responses.
    	 */
    	getData()
    	{
    		let nbIncompleteResponse = 0;

    		for (let i = 0; i < this._items.length; ++i)
    		{
    			const item = this._items[i];
    			const responseStim = this._visual.responseStims[i];
    			if (responseStim)
    			{
    				if (item.type === Form.Types.CHOICE || item.type === Form.Types.RATING || item.type === Form.Types.SLIDER)
    				{
    					item.response = responseStim.getRating();
    					item.rt = responseStim.getRT();

    					if (typeof item.response === "undefined")
    					{
    						++nbIncompleteResponse;
    					}
    				}
    				else if (item.type === Form.Types.FREE_TEXT)
    				{
    					item.response = responseStim.text;
    					item.rt = undefined;

    					if (item.response.length === 0)
    					{
    						++nbIncompleteResponse;
    					}
    				}
    			}
    		}

    		this._items._complete = (nbIncompleteResponse === 0);

    		// return a copy of this._items:
    		return this._items.map((item) => Object.assign({}, item));
    	}
    	/**
    	 * Check if the form is complete.
    	 *
    	 * @return {boolean} - whether there are any remaining incomplete responses.
    	 */
    	formComplete()
    	{
    		// same as complete but might be used by some experiments before 2020.2
    		this.getData();
    		return this._items._complete;
    	}
    	/**
    	 * Add the form data to the given experiment.
    	 *
    	 * @param {module:data.ExperimentHandler} experiment - the experiment into which to insert the form data
    	 * @param {string} [format= 'rows'] - whether to insert the data as rows or as columns
    	 */
    	addDataToExp(experiment, format = "rows")
    	{
    		const addAsColumns = ["cols", "columns"].includes(format.toLowerCase());
    		const data = this.getData();

    		const _doNotSave = [
    			"itemCtrl",
    			"responseCtrl",
    			"itemColor",
    			"options",
    			"ticks",
    			"tickLabels",
    			"responseWidth",
    			"responseColor",
    			"layout",
    		];

    		for (const item of this.getData())
    		{
    			let index = 0;
    			for (const field in item)
    			{
    				if (!_doNotSave.includes(field))
    				{
    					const columnName = (addAsColumns) ? `${this._name}[${index}]${field}` : `${this._name}${field}`;
    					experiment.addData(columnName, item[field]);
    				}
    				++index;
    			}

    			if (!addAsColumns)
    			{
    				experiment.nextEntry();
    			}
    		}

    		if (addAsColumns)
    		{
    			experiment.nextEntry();
    		}
    	}

    	/**
    	 * Import and process the form items from either a spreadsheet resource files (.csv, .xlsx, etc.) or from an array.
    	 *
    	 * @protected
    	 */
    	_processItems()
    	{
    		const response = {
    			origin: "Form._processItems",
    			context: "when processing the form items",
    		};

    		try
    		{
    			if (this._autoLog)
    			{
    				// note: we use the same log message as PsychoPy even though we called this method differently
    				this._psychoJS.experimentLogger.exp("Importing items...");
    			}

    			// import the items:
    			this._importItems();

    			// sanitize the items (check that keys are valid, fill in default values):
    			this._sanitizeItems();

    			// randomise the items if need be:
    			if (this._randomize)
    			{
    				util.shuffle(this._items);
    			}
    		}
    		catch (error)
    		{
    			// throw { ...response, error };
    			throw Object.assign(response, { error });
    		}
    	}

    	/**
    	 * Import the form items from either a spreadsheet resource files (.csv, .xlsx, etc.) or from an array.
    	 *
    	 * @protected
    	 */
    	_importItems()
    	{
    		const response = {
    			origin: "Form._importItems",
    			context: "when importing the form items",
    		};

    		try
    		{
    			const itemsType = typeof this._items;

    			// we treat undefined items as a list with a single default entry:
    			if (itemsType === "undefined")
    			{
    				this._items = [Form._defaultItems];
    			}
    			// if items is a string, we treat it as the name of a resource file and import it:
    			else if (itemsType === "string")
    			{
    				this._items = TrialHandler.importConditions(this._psychoJS.serverManager, this._items);
    			}
    			// unknown items type:
    			else
    			{
    				throw `unable to import items of unknown type: ${itemsType}`;
    			}

    			// if items is an empty array, we replace with a single default entry:
    			if (Array.isArray(this._items) && this._items.length === 0)
    			{
    				this._items = [Form._defaultItems];
    			}
    		}
    		catch (error)
    		{
    			// throw { ...response, error };
    			throw Object.assign(response, { error });
    		}
    	}

    	/**
    	 * Sanitize the form items: check that the keys are valid, and fill in default values.
    	 *
    	 * @protected
    	 */
    	_sanitizeItems()
    	{
    		const response = {
    			origin: "Form._sanitizeItems",
    			context: "when sanitizing the form items",
    		};

    		try
    		{
    			// convert old style questionnaire to new style:
    			for (const item of this._items)
    			{
    				// old style forms have questionText instead of itemText:
    				if (typeof item.questionText !== "undefined")
    				{
    					item.itemText = item.questionText;
    					delete item.questionText;

    					item.itemWidth = item.questionWidth;
    					delete item.questionWidth;

    					// for items of type 'rating, the ticks are in 'options' instead of in 'ticks':
    					if (item.type === "rating" || item.type === "slider")
    					{
    						item.ticks = item.options;
    						item.options = undefined;
    					}
    				}
    			}

    			// fill in missing keys and undefined values:
    			const defaultKeys = Object.keys(Form._defaultItems);
    			const missingKeys = new Set();
    			for (const item of this._items)
    			{
    				const itemKeys = Object.keys(item);
    				for (const key of defaultKeys)
    				{
    					// missing key:
    					if (!itemKeys.includes(key))
    					{
    						missingKeys.add(key);
    						item[key] = Form._defaultItems[key];
    					}
    					// undefined value:
    					else if (typeof item[key] === "undefined")
    					{
    						// TODO: options = '' for FREE_TEXT
    						item[key] = Form._defaultItems[key];
    					}
    				}
    			}

    			if (missingKeys.size > 0)
    			{
    				this._psychoJS.logger.warn(
    					`Missing headers: ${Array.from(missingKeys).join(", ")}\nNote, headers are case sensitive and must match: ${Array.from(defaultKeys).join(", ")}`,
    				);
    			}

    			// check the types and options:
    			const formTypes = Object.getOwnPropertyNames(Form.Types);
    			for (const item of this._items)
    			{
    				// convert type to upper case, replace spaces by underscores
    				item.type = item.type.toUpperCase().replace(" ", "_");

    				// check that the type is valid:
    				if (!formTypes.includes(item.type))
    				{
    					throw `${item.type} is not a valid type for item: ${item.itemText}`;
    				}

    				// Support the 'radio' type found on older versions of PsychoPy
    				if (item.type === "RADIO")
    				{
    					item.type = "CHOICE";
    				}

    				// convert item type to symbol:
    				item.type = Symbol.for(item.type);

    				// turn the option into an array and check length, where applicable:
    				if (item.type === Form.Types.CHOICE)
    				{
    					item.options = item.options.split(",");
    					if (item.options.length < 2)
    					{
    						throw `at least two choices should be provided for choice item: ${item.itemText}`;
    					}
    				}
    				// turn the ticks and tickLabels into arrays, where applicable:
    				else if (item.type === Form.Types.RATING || item.type === Form.Types.SLIDER)
    				{
    					item.ticks = item.ticks.split(",").map((_, t) => parseInt(t));
    					item.tickLabels = (item.tickLabels.length > 0) ? item.tickLabels.split(",") : [];
    				}

    				// TODO
    				// estimate potentially missing itemWidth or responseWidth
    				// solve conflicts when itemWidth + responseWidth != 1
    			}

    			// check the layout:
    			const formLayouts = ["HORIZ", "VERT"];
    			for (const item of this._items)
    			{
    				// convert layout to upper case:
    				item.layout = item.layout.toUpperCase();

    				// check that the layout is valid:
    				if (!formLayouts.includes(item.layout))
    				{
    					throw `${item.layout} is not a valid layout for item: ${item.itemText}`;
    				}

    				// convert item layout to symbol:
    				item.layout = (item.layout === "HORIZ") ? Form.Layout.HORIZONTAL : Form.Layout.VERTICAL;
    			}
    		}
    		catch (error)
    		{
    			// throw { ...response, error };
    			throw Object.assign(response, { error });
    		}
    	}

    	/**
    	 * Estimate the bounding box.
    	 *
    	 * @override
    	 * @protected
    	 */
    	_estimateBoundingBox()
    	{
    		// take the alignment into account:
    		this._boundingBox = new PIXI.Rectangle(
    			this._pos[0] - this._size[0] / 2.0,
    			this._pos[1] - this._size[1] / 2.0,
    			this._size[0],
    			this._size[1],
    		);
    	}

    	/**
    	 * Setup the stimuli, and the scrollbar.
    	 *
    	 * @protected
    	 */
    	_setupStimuli()
    	{
    		if (this._autoLog)
    		{
    			this._psychoJS.experimentLogger.exp(`Setting layout of Form: ${this.name}`);
    		}

    		// clean up the previously setup stimuli:
    		if (typeof this._visual !== "undefined")
    		{
    			for (const textStim of this._visual.textStims)
    			{
    				textStim.release();
    			}
    			for (const responseStim of this._visual.responseStims)
    			{
    				responseStim.release();
    			}
    		}

    		// visual representations of the items:
    		this._visual = {
    			rowHeights: [],
    			textStims: [],
    			responseStims: [],
    			visibles: [],
    			stimuliTotalHeight: 0,
    		};

    		// instantiate the clip mask that will be used by all stimuli:
    		this._stimuliClipMask = new PIXI.Graphics();

    		// default stimulus options:
    		const textStimOption = {
    			win: this._win,
    			name: "item text",
    			font: this.font,
    			units: this._units,
    			alignHoriz: "left",
    			alignVert: "top",
    			height: this._fontSize,
    			color: this.itemColor,
    			ori: 0,
    			opacity: 1,
    			depth: this._depth - 1,
    			clipMask: this._stimuliClipMask,
    		};
    		const sliderOption = {
    			win: this._win,
    			name: "choice response",
    			units: this._units,
    			flip: false,
    			// Not part of Slider options as things stand
    			fontFamily: this.fontFamily,
    			// As found in Slider options
    			font: this.font,
    			bold: false,
    			italic: false,
    			fontSize: this._fontSize * this._responseTextHeightRatio,
    			color: this.responseColor,
    			markerColor: this.markerColor,
    			opacity: 1,
    			depth: this._depth - 1,
    			clipMask: this._stimuliClipMask,
    			granularity: 1,
    		};
    		const textBoxOption = {
    			win: this._win,
    			name: "free text response",
    			units: this._units,
    			anchor: "left-top",
    			flip: false,
    			opacity: 1,
    			depth: this._depth - 1,
    			font: this.font,
    			letterHeight: this._fontSize * this._responseTextHeightRatio,
    			bold: false,
    			italic: false,
    			alignment: "left",
    			color: this.responseColor,
    			fillColor: this.fillColor,
    			contrast: 1.0,
    			borderColor: this.responseColor,
    			borderWidth: 0.002,
    			padding: 0.01,
    			editable: true,
    			clipMask: this._stimuliClipMask,
    		};

    		// we use for the slider's tick size the height of a word:
    		const textStim = new TextStim(Object.assign(textStimOption, { text: "Ag", pos: [0, 0] }));
    		const textMetrics_px = textStim.getTextMetrics();
    		const sliderTickSize = this._getLengthUnits(textMetrics_px.height) / 2;
    		textStim.release(false);

    		let stimulusOffset = -this._itemPadding;
    		for (const item of this._items)
    		{
    			// initially, all items are invisible:
    			this._visual.visibles.push(false);

    			// estimate row width:
    			// - heading: <padding> + <item> + <padding> + <scrollbar> = this._size[0]
    			// - description: <padding> + <item> + <padding> + <scrollbar> = this._size[0]
    			// - choice with vert layout: <padding> + <item> + <padding> + <scrollbar> = this._size[0]
    			let rowWidth;
    			if (
    				item.type === Form.Types.HEADING || item.type === Form.Types.DESCRIPTION
    				|| (item.type === Form.Types.CHOICE && item.layout === Form.Layout.VERTICAL)
    			)
    			{
    				rowWidth = (this._size[0] - this._itemPadding * 2 - this._scrollbarWidth);
    			}
    			// - anything else: <padding> + <item> + <padding> + <response> + <padding> + <scrollbar> = this._size[0]
    			else
    			{
    				rowWidth = (this._size[0] - this._itemPadding * 3 - this._scrollbarWidth);
    			}

    			// item text
    			const itemWidth = rowWidth * item.itemWidth;
    			const textStim = new TextStim(
    				Object.assign(textStimOption, {
    					text: item.itemText,
    					wrapWidth: itemWidth,
    				}),
    			);
    			textStim._relativePos = [this._itemPadding, stimulusOffset];
    			const textHeight = textStim.boundingBox.height;
    			this._visual.textStims.push(textStim);

    			// item response:
    			let responseStim = null;
    			let responseHeight = 0;
    			let compact;
    			let flip;
    			const responseWidth = rowWidth * item.responseWidth;

    			// CHOICE and RATING
    			if (item.type === Form.Types.CHOICE || item.type === Form.Types.RATING || item.type === Form.Types.SLIDER)
    			{
    				let sliderSize;
    				if (item.layout === Form.Layout.HORIZONTAL)
    				{
    					sliderSize = [responseWidth, sliderTickSize];
    					compact = true;
    					flip = false;
    				}
    				else
    				{
    					sliderSize = [sliderTickSize, (sliderTickSize * 1.5) * item.options.length];
    					compact = false;
    					flip = true;
    				}

    				let style, labels, ticks, granularity = 1;
    				if (item.type === Form.Types.CHOICE)
    				{
    					style = [Slider.Style.RATING, Slider.Style.RADIO];
    					labels = item.options;
    					ticks = []; // categorical
    				}
    				else if (item.type === Form.Types.SLIDER)
    				{
    					style = [Slider.Style.SLIDER];
    					labels = item.tickLabels;
    					ticks = item.ticks;
    					granularity = 0;
    				}
    				else
    				{
    					style = [Slider.Style.RATING];
    					labels = item.tickLabels;
    					ticks = item.ticks;
    					granularity = 1;
    				}

    				responseStim = new Slider(
    					Object.assign({}, sliderOption, {
    						granularity,
    						size: sliderSize,
    						style,
    						labels,
    						ticks,
    						compact,
    						flip,
    					}),
    				);
    				responseHeight = responseStim.boundingBox.height;
    				if (item.layout === Form.Layout.HORIZONTAL)
    				{
    					responseStim._relativePos = [
    						this._itemPadding * 2 + itemWidth + responseWidth / 2,
    						stimulusOffset,
    						// - Math.max(0, (textHeight - responseHeight) / 2) // (vertical centering)
    					];
    				}
    				else
    				{
    					responseStim._relativePos = [
    						this._itemPadding * 2 + itemWidth, // this._itemPadding + sliderTickSize,
    						stimulusOffset - responseHeight / 2 - textHeight - this._itemPadding,
    					];

    					// since rowHeight will be the max of itemHeight and responseHeight, we need to alter responseHeight
    					// to account for the fact that the response is below the item text:
    					responseHeight += textHeight + this._itemPadding;
    				}
    			}
    			// FREE TEXT
    			else if (item.type === Form.Types.FREE_TEXT)
    			{
    				responseStim = new TextBox(
    					Object.assign(textBoxOption, {
    						text: item.options,
    						size: [responseWidth, -1],
    					}),
    				);
    				responseHeight = responseStim.boundingBox.height;
    				responseStim._relativePos = [
    					this._itemPadding * 2 + itemWidth,
    					stimulusOffset,
    				];
    			}

    			this._visual.responseStims.push(responseStim);

    			const rowHeight = Math.max(textHeight, responseHeight);
    			this._visual.rowHeights.push(rowHeight);

    			stimulusOffset -= rowHeight + this._itemPadding;
    		}
    		this._visual.stimuliTotalHeight = stimulusOffset;

    		// scrollbar
    		// note: we add this Form as a dependent stimulus such that the Form is redrawn whenever
    		// the slider is updated
    		this._scrollbar = new Slider({
    			win: this._win,
    			name: "scrollbar",
    			units: this._units,
    			color: this.itemColor,
    			depth: this._depth - 1,
    			pos: [0, 0],
    			size: [this._scrollbarWidth, this._size[1]],
    			style: [Slider.Style.SLIDER],
    			ticks: [0, -this._visual.stimuliTotalHeight / this._size[1]],
    			dependentStims: [this],
    		});
    		this._prevScrollbarMarkerPos = 0;
    		this._scrollbar.setMarkerPos(this._prevScrollbarMarkerPos);

    		// estimate the bounding box:
    		this._estimateBoundingBox();

    		if (this._autoLog)
    		{
    			this._psychoJS.experimentLogger.exp(`Layout set for: ${this.name}`);
    		}
    	}

    	/**
    	 * Update the form visual representation, if necessary.
    	 *
    	 * <p>This estimate which stimuli are visible, and updates the decorations.</p>
    	 *
    	 * @protected
    	 */
    	_updateIfNeeded()
    	{
    		if (!this._needUpdate)
    		{
    			return;
    		}
    		this._needUpdate = false;

    		// calculate the edges of the form and various other sizes, in various units:
    		this._leftEdge = this._pos[0] - this._size[0] / 2.0;
    		this._rightEdge = this._pos[0] + this._size[0] / 2.0;
    		this._topEdge = this._pos[1] + this._size[1] / 2.0;
    		this._bottomEdge = this._pos[1] - this._size[1] / 2.0;

    		[this._leftEdge_px, this._topEdge_px] = util.to_px(
    			[this._leftEdge, this._topEdge],
    			this.units,
    			this.win,
    			true,
    		);
    		[this._rightEdge_px, this._bottomEdge_px] = util.to_px(
    			[this._rightEdge, this._bottomEdge],
    			this.units,
    			this.win,
    			true,
    		);
    		this._itemPadding_px = this._getLengthPix(this._itemPadding);
    		this._scrollbarWidth_px = this._getLengthPix(this._scrollbarWidth, true);
    		this._size_px = util.to_px(this._size, this.units, this.win, true);

    		// update the stimuli clip mask
    		// note: the clip mask is in screen coordinates
    		this._stimuliClipMask.clear();
    		this._stimuliClipMask.beginFill(0xFFFFFF);
    		this._stimuliClipMask.drawRect(
    			this._win._stimsContainer.position.x + this._leftEdge_px + 2,
    			this._win._stimsContainer.position.y + this._bottomEdge_px + 2,
    			this._size_px[0] - 4,
    			this._size_px[1] - 6,
    		);
    		this._stimuliClipMask.endFill();

    		// position the scrollbar and get the scrollbar offset, in form units:
    		this._scrollbar.setPos([this._rightEdge - this._scrollbarWidth / 2, this._pos[1]], false);
    		this._scrollbar.setOpacity(0.5);
    		this._scrollbarOffset = this._prevScrollbarMarkerPos * (this._visual.stimuliTotalHeight + this._size[1]) / (-this._visual.stimuliTotalHeight / this._size[1]);

    		// update decorations and stimuli:
    		this._updateVisibleStimuli();
    		this._updateDecorations();
    	}

    	/**
    	 * Update the visible stimuli.
    	 *
    	 * @protected
    	 */
    	_updateVisibleStimuli()
    	{
    		for (let i = 0; i < this._items.length; ++i)
    		{
    			// a. item text
    			const textStim = this._visual.textStims[i];
    			const textStimPos = [
    				this._leftEdge + textStim._relativePos[0],
    				this._topEdge + textStim._relativePos[1] - this._scrollbarOffset,
    			];
    			textStim.setPos(textStimPos);

    			// b. response:
    			const responseStim = this._visual.responseStims[i];
    			if (responseStim)
    			{
    				const responseStimPos = [
    					this._leftEdge + responseStim._relativePos[0],
    					this._topEdge + responseStim._relativePos[1] - this._scrollbarOffset,
    				];
    				responseStim.setPos(responseStimPos);
    			}

    			// if the stimuli fall within the form area, we make them visible:
    			if (textStimPos[1] > this._bottomEdge && textStimPos[1] - this._visual.rowHeights[i] <= this._topEdge)
    			{
    				this._visual.visibles[i] = true;
    			}
    			// otherwise, we make them invisible:
    			else
    			{
    				// if the stimulus was previously visible, we need to hide it:
    				if (this._visual.visibles[i])
    				{
    					textStim.hide();
    					if (responseStim)
    					{
    						responseStim.hide();
    					}
    				}

    				this._visual.visibles[i] = false;
    			}
    		}
    	}

    	/**
    	 * Update the form decorations (bounding box, lines between items, etc.)
    	 *
    	 * @protected
    	 */
    	_updateDecorations()
    	{
    		if (typeof this._pixi !== "undefined")
    		{
    			this._pixi.destroy(true);
    		}

    		this._pixi = new PIXI.Graphics();
    		this._pixi.scale.x = 1;
    		this._pixi.scale.y = 1;
    		this._pixi.rotation = 0;
    		this._pixi.position = to_pixiPoint(this.pos, this.units, this.win);

    		this._pixi.alpha = this._opacity;
    		this._pixi.zIndex = -this._depth;

    		// apply the form clip mask (n.b., that is not the stimuli clip mask):
    		this._pixi.mask = this._clipMask;

    		// form background:
    		this._pixi.lineStyle(1, new Color(this.borderColor).int, this._opacity, 0.5);
    		// this._decorations.beginFill(this._barFillColor.int, this._opacity);
    		this._pixi.beginFill(new Color(this.fillColor).int);
    		this._pixi.drawRect(this._leftEdge_px, this._bottomEdge_px, this._size_px[0], this._size_px[1]);
    		// this._decorations.endFill();
    		this._pixi.endFill();

    		// item decorators:
    		this._decorations = new PIXI.Graphics();
    		this._pixi.addChild(this._decorations);
    		this._decorations.mask = this._stimuliClipMask;
    		this._decorations.lineStyle(1, new Color("gray").int, this._opacity, 0.5);
    		this._decorations.alpha = 0.5;

    		for (let i = 0; i < this._items.length; ++i)
    		{
    			if (this._visual.visibles[i])
    			{
    				const item = this._items[i];
    				// background for headings and descriptions:
    				if (item.type === Form.Types.HEADING || item.type === Form.Types.DESCRIPTION)
    				{
    					const textStim = this._visual.textStims[i];
    					const textStimPos = [
    						this._leftEdge + textStim._relativePos[0],
    						this._topEdge + textStim._relativePos[1] - this._scrollbarOffset,
    					];
    					const textStimPos_px = util.to_px(textStimPos, this._units, this._win);
    					this._decorations.beginFill(new Color("darkgray").int);
    					this._decorations.drawRect(
    						textStimPos_px[0] - this._itemPadding_px / 2,
    						textStimPos_px[1] + this._itemPadding_px / 2,
    						this._size_px[0] - this._itemPadding_px - this._scrollbarWidth_px,
    						-this._getLengthPix(this._visual.rowHeights[i]) - this._itemPadding_px,
    					);
    					this._decorations.endFill();
    				}
    			}
    		}
    	}
    }

    /**
     * Form item types.
     *
     * @enum {Symbol}
     * @readonly
     */
    Form.Types = {
    	HEADING: Symbol.for("HEADING"),
    	DESCRIPTION: Symbol.for("DESCRIPTION"),
    	RATING: Symbol.for("RATING"),
    	SLIDER: Symbol.for("SLIDER"),
    	FREE_TEXT: Symbol.for("FREE_TEXT"),
    	CHOICE: Symbol.for("CHOICE"),
    	RADIO: Symbol.for("RADIO"),
    };

    /**
     * Form item layout.
     *
     * @enum {Symbol}
     * @readonly
     */
    Form.Layout = {
    	HORIZONTAL: Symbol.for("HORIZONTAL"),
    	VERTICAL: Symbol.for("VERTICAL"),
    };

    /**
     * Default form item.
     *
     * @readonly
     * @protected
     *
     */
    Form._defaultItems = {
    	"itemText": "Default question",
    	"type": "rating",
    	"options": "Yes, No",
    	"tickLabels": "",
    	"itemWidth": 0.7,
    	"itemColor": "white",

    	"responseWidth": 0.3,
    	"responseColor": "white",

    	"index": 0,
    	"layout": "horiz",
    };