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",
};