Source code for psychopy.visual.form

#!/usr/bin/env python
# -*- coding: utf-8 -*-


# Part of the PsychoPy library
# Copyright (C) 2002-2018 Jonathan Peirce (C) 2019-2024 Open Science Tools Ltd.
# Distributed under the terms of the GNU General Public License (GPL).
import copy
import psychopy
from .text import TextStim
from .rect import Rect
from psychopy.data.utils import importConditions, listFromString
from psychopy.visual.basevisual import (BaseVisualStim,
                                        ContainerMixin,
                                        ColorMixin)
from psychopy.tools import stimulustools as stt
from psychopy import logging, layout
from random import shuffle
from pathlib import Path

__author__ = 'Jon Peirce, David Bridges, Anthony Haffey'

from ..colors import Color

_REQUIRED = -12349872349873  # an unlikely int

# a dict of known fields with their default vals
_knownFields = {
    'index': None,  # optional field to index into the rows
    'itemText': _REQUIRED,  # (question used until 2020.2)
    'itemColor': None,
    'itemWidth': 1,  # fraction of the form
    'type': _REQUIRED,  # type of response box (see below)
    'options': ('Yes', 'No'),  # for choice box
    'ticks': None,#(1, 2, 3, 4, 5, 6, 7),
    'tickLabels': None,
    'font': None,
    # for rating/slider
    'responseWidth': 1,  # fraction of the form
    'responseColor': None,
    'markerColor': None,
    'layout': 'horiz',  # can be vert or horiz
}
_doNotSave = [
    'itemCtrl', 'responseCtrl',  # these genuinely can't be save
    'itemColor', 'itemWidth', 'options', 'ticks', 'tickLabels',  # not useful?
    'responseWidth', 'responseColor', 'layout',
]
_knownRespTypes = {
    'heading', 'description',  # no responses
    'rating', 'slider',  # slider is continuous
    'free text',
    'choice', 'radio'  # synonyms (radio was used until v2020.2)
}
_synonyms = {
    'itemText': 'questionText',
    'choice': 'radio',
    'free text': 'textBox'
}

# Setting debug to True will set the sub-elements on Form to be outlined in red, making it easier to determine their position
debug = False


[docs]class Form(BaseVisualStim, ContainerMixin, ColorMixin): """A class to add Forms to a `psychopy.visual.Window` The Form allows Psychopy to be used as a questionnaire tool, where participants can be presented with a series of questions requiring responses. Form items, defined as questions and response pairs, are presented simultaneously onscreen with a scrollable viewing window. Example ------- survey = Form(win, items=[{}], size=(1.0, 0.7), pos=(0.0, 0.0)) Parameters ---------- win : psychopy.visual.Window The window object to present the form. items : List of dicts or csv or xlsx file a list of dicts or csv file should have the following key, value pairs / column headers: "index": The item index as a number "itemText": item question string, "itemWidth": fraction of the form width 0:1 "type": type of rating e.g., 'radio', 'rating', 'slider' "responseWidth": fraction of the form width 0:1, "options": list of tick labels for options, "layout": Response object layout e.g., 'horiz' or 'vert' textHeight : float Text height. size : tuple, list Size of form on screen. pos : tuple, list Position of form on screen. itemPadding : float Space or padding between form items. units : str units for stimuli - Currently, Form class only operates with 'height' units. randomize : bool Randomize order of Form elements """ knownStyles = stt.formStyles def __init__(self, win, name='default', colorSpace='rgb', fillColor=None, borderColor=None, itemColor='white', responseColor='white', markerColor='red', items=None, font=None, textHeight=.02, size=(.5, .5), pos=(0, 0), style=None, itemPadding=0.05, units='height', randomize=False, autoLog=True, depth=0, # legacy color=None, foreColor=None ): super(Form, self).__init__(win, units, autoLog=False) self.win = win self.autoLog = autoLog self.name = name self.randomize = randomize self.items = self.importItems(items) self.size = size self._pos = pos self.itemPadding = itemPadding self.scrollSpeed = self.setScrollSpeed(self.items, 4) self.units = units self.depth = depth # Appearance self.colorSpace = colorSpace self.fillColor = fillColor self.borderColor = borderColor self.itemColor = itemColor self.responseColor = responseColor self.markerColor = markerColor if color: self.foreColor = color if foreColor: self.foreColor = color self.font = font or "Open Sans" self.textHeight = textHeight self._baseYpositions = [] self.leftEdge = None self.rightEdge = None self.topEdge = None self._currentVirtualY = 0 # Y position in the virtual sheet self._vheight = 0 # Height of the virtual sheet self._decorations = [] self._externalDecorations = [] # Check units - only works with height units for now if self.win.units != 'height': logging.warning( "Form currently only formats correctly using height units. " "Please change the units in Experiment Settings to 'height'") self._complete = False # Create layout of form self._createItemCtrls() self.style = style if self.autoLog: logging.exp("Created {} = {}".format(self.name, repr(self))) def __repr__(self, complete=False): return self.__str__(complete=complete) # from MinimalStim
[docs] def importItems(self, items): """Import items from csv or excel sheet and convert to list of dicts. Will also accept a list of dicts. Note, for csv and excel files, 'options' must contain comma separated values, e.g., one, two, three. No parenthesis, or quotation marks required. Parameters ---------- items : Excel or CSV file, list of dicts Items used to populate the Form Returns ------- List of dicts A list of dicts, where each list entry is a dict containing all fields for a single Form item """ def _checkSynonyms(items, fieldNames): """Checks for updated names for fields (i.e. synonyms)""" replacedFields = set() for field in _synonyms: synonym = _synonyms[field] for item in items: if synonym in item: # convert to new name item[field] = item[synonym] del item[synonym] replacedFields.add(field) for field in replacedFields: fieldNames.append(field) fieldNames.remove(_synonyms[field]) logging.warning("Form {} included field no longer used {}. " "Replacing with new name '{}'" .format(self.name, _synonyms[field], field)) def _checkRequiredFields(fieldNames): """Checks for required headings (do this after checking synonyms)""" for hdr in _knownFields: # is it required and/or present? if _knownFields[hdr] == _REQUIRED and hdr not in fieldNames: raise ValueError("Missing header ({}) in Form ({}). " "Headers found were: {}" .format(hdr, self.name, fieldNames)) def _checkTypes(types, itemText): """A nested function for testing the number of options given Raises ValueError if n Options not > 1 """ itemDiff = set([types]) - set(_knownRespTypes) for incorrItemType in itemDiff: if incorrItemType == _REQUIRED: if self._itemsFile: itemsFileStr = ("in items file '{}'" .format(self._itemsFile)) else: itemsFileStr = "" msg = ("Item {}{} is missing a required " "value for its response type. Permitted types are " "{}.".format(itemText, itemsFileStr, _knownRespTypes)) if self.autoLog: logging.error(msg) raise ValueError(msg) def _addDefaultItems(items): """ Adds default items when missing. Works in-place. Parameters ---------- items : List of dicts headers : List of column headers for each item """ def isPresent(d, field): # check if the field is there and not empty on this row return (field in d and d[field] not in [None, '']) missingHeaders = [] defaultValues = _knownFields for index, item in enumerate(items): defaultValues['index'] = index for header in defaultValues: # if header is missing of val is None or '' if not isPresent(item, header): oldHeader = header.replace('item', 'question') if isPresent(item, oldHeader): item[header] = item[oldHeader] logging.warning( "{} is a deprecated heading for Forms. " "Use {} instead" .format(oldHeader, header) ) continue # Default to colour scheme if specified if defaultValues[header] in ['fg', 'bg', 'em']: item[header] = self.color else: item[header] = defaultValues[header] missingHeaders.append(header) msg = "Using default values for the following headers: {}".format( missingHeaders) if self.autoLog: logging.info(msg) if self.autoLog: logging.info("Importing items...") if not isinstance(items, list): # items is a conditions file self._itemsFile = Path(items) items, fieldNames = importConditions(items, returnFieldNames=True) else: # we already have a list so lets find the fieldnames fieldNames = set() for item in items: fieldNames = fieldNames.union(item) fieldNames = list(fieldNames) # convert to list at the end self._itemsFile = None _checkSynonyms(items, fieldNames) _checkRequiredFields(fieldNames) # Add default values if entries missing _addDefaultItems(items) # Convert options to list of strings for idx, item in enumerate(items): if item['ticks']: item['ticks'] = listFromString(item['ticks']) if 'tickLabels' in item and item['tickLabels']: item['tickLabels'] = listFromString(item['tickLabels']) if 'options' in item and item['options']: item['options'] = listFromString(item['options']) # Check types [_checkTypes(item['type'], item['itemText']) for item in items] # Check N options > 1 # Randomise items if requested if self.randomize: shuffle(items) return items
[docs] def setScrollSpeed(self, items, multiplier=2): """Set scroll speed of Form. Higher multiplier gives smoother, but slower scroll. Parameters ---------- items : list of dicts Items used to populate the form multiplier : int (default=2) Number used to calculate scroll speed Returns ------- int Scroll speed, calculated using N items by multiplier """ return len(items) * multiplier
[docs] def _getItemRenderedWidth(self, size): """Returns text width for item text based on itemWidth and Form width. Parameters ---------- size : float, int The question width Returns ------- float Wrap width for question text """ return size * self.size[0] - (self.itemPadding * 2)
[docs] def _setQuestion(self, item): """Creates TextStim object containing question Parameters ---------- item : dict The dict entry for a single item Returns ------- psychopy.visual.text.TextStim The textstim object with the question string questionHeight The height of the question bounding box as type float questionWidth The width of the question bounding box as type float """ if self.autoLog: logging.exp( u"Question text: {}".format(item['itemText'])) if item['type'] == 'heading': letterScale = 1.5 bold = True else: letterScale = 1.0 bold = False w = self._getItemRenderedWidth(item['itemWidth']) question = psychopy.visual.TextBox2( self.win, text=item['itemText'], units=self.units, letterHeight=self.textHeight * letterScale, anchor='top-left', alignment='center-left', pos=(self.leftEdge+self.itemPadding, 0), # y pos irrelevant size=[w, 0.1], # expand height with text autoLog=False, colorSpace=self.colorSpace, color=item['itemColor'] or self.itemColor, fillColor=None, padding=0, # handle this by padding between items borderWidth=1, borderColor='red' if debug else None, # add borderColor to help debug editable=False, bold=bold, font=item['font'] or self.font) # Resize textbox to be at least as tall as the text question._updateVertices() textHeight = getattr(question.boundingBox._size, question.units)[1] if textHeight > question.size[1]: question.size[1] = textHeight + question.padding[1] * 2 question._layout() questionHeight = question.size[1] questionWidth = question.size[0] # store virtual pos to combine with scroll bar for actual pos question._baseY = self._currentVirtualY # Add question objects to Form element dict item['itemCtrl'] = question return question, questionHeight, questionWidth
[docs] def _setResponse(self, item): """Makes calls to methods which make Slider or TextBox response objects for Form Parameters ---------- item : dict The dict entry for a single item question : TextStim The question text object Returns ------- psychopy.visual.slider.Slider The Slider object for response psychopy.visual.TextBox The TextBox object for response respHeight The height of the response object as type float """ if self.autoLog: logging.info( "Adding response to Form type: {}, layout: {}, options: {}" .format(item['type'], item['layout'], item['options'])) if item['type'].lower() == 'free text': respCtrl, respHeight = self._makeTextBox(item) elif item['type'].lower() in ['heading', 'description']: respCtrl, respHeight = None, 0 elif item['type'].lower() in ['rating', 'slider', 'choice', 'radio']: respCtrl, respHeight = self._makeSlider(item) item['responseCtrl'] = respCtrl return respCtrl, float(respHeight)
[docs] def _makeSlider(self, item): """Creates Slider object for Form class Parameters ---------- item : dict The dict entry for a single item pos : tuple position of response object Returns ------- psychopy.visual.slider.Slider The Slider object for response respHeight The height of the response object as type float """ # Slider dict kind = item['type'].lower() # what are the ticks for the scale/slider? if item['type'].lower() in ['radio', 'choice']: if item['ticks']: ticks = item['ticks'] else: ticks = None tickLabels = item['tickLabels'] or item['options'] or item['ticks'] granularity = 1 style = 'radio' else: if item['ticks']: ticks = item['ticks'] elif item['options']: ticks = range(0, len(item['options'])) else: raise ValueError("We don't appear to have either options or " "ticks for item '{}' of {}." .format(item['itemText'], self.name)) # how to label those ticks if item['tickLabels']: tickLabels = [str(i).strip() for i in item['tickLabels']] elif 'options' in item and item['options']: tickLabels = [str(i).strip() for i in item['options']] else: tickLabels = None # style/granularity if kind == 'slider' and 'granularity' in item: if item['granularity']: granularity = item['granularity'] else: granularity = 0 elif kind == 'slider' and 'granularity' not in item: granularity = 0 else: granularity = 1 style = kind # Make invisible guide rect to help with laying out slider w = (item['responseWidth'] - self.itemPadding * 2) * (self.size[0] - self.scrollbarWidth) * 0.8 if item['layout'] == 'horiz': h = self.textHeight * 2 + 0.03 elif item['layout'] == 'vert': h = self.textHeight * 1.1 * len(item['options']) x = self.rightEdge - self.itemPadding - self.scrollbarWidth - w * 0.1 guide = Rect( self.win, size=(w, h), pos=(x, 0), anchor="top-right", lineColor="red", fillColor=None, units=self.units, autoLog=False ) # Get slider pos and size if item['layout'] == 'horiz': x = guide.pos[0] - guide.size[0] / 2 w = guide.size[0] h = 0.03 wrap = None # Slider defaults are fine for horizontal elif item['layout'] == 'vert': # for vertical take into account the nOptions x = guide.pos[0] - guide.size[0] w = 0.03 h = guide.size[1] wrap = guide.size[0] / 2 - 0.03 item['options'].reverse() # Create Slider resp = psychopy.visual.Slider( self.win, pos=(x, 0), # NB y pos is irrelevant here - handled later size=(w, h), ticks=ticks, labels=tickLabels, units=self.units, labelHeight=self.textHeight, labelWrapWidth=wrap, granularity=granularity, flip=True, style=style, autoLog=False, font=item['font'] or self.font, color=item['responseColor'] or self.responseColor, fillColor=item['markerColor'] or self.markerColor, borderColor=item['responseColor'] or self.responseColor, colorSpace=self.colorSpace) resp.guide = guide # store virtual pos to combine with scroll bar for actual pos resp._baseY = self._currentVirtualY - guide.size[1] / 2 - self.itemPadding return resp, guide.size[1]
[docs] def _getItemHeight(self, item, ctrl=None): """Returns the full height of the item to be inserted in the form""" if type(ctrl) == psychopy.visual.TextBox2: return ctrl.size[1] if type(ctrl) == psychopy.visual.Slider: # Set radio button layout if item['layout'] == 'horiz': return 0.03 + ctrl.labelHeight*3 elif item['layout'] == 'vert': # for vertical take into account the nOptions return ctrl.labelHeight*len(item['options'])
[docs] def _makeTextBox(self, item): """Creates TextBox object for Form class NOTE: The TextBox 2 in work in progress, and has not been added to Form class yet. Parameters ---------- item : dict The dict entry for a single item pos : tuple position of response object Returns ------- psychopy.visual.TextBox The TextBox object for response respHeight The height of the response object as type float """ w = (item['responseWidth'] - self.itemPadding * 2) * (self.size[0] - self.scrollbarWidth) x = self.rightEdge - self.itemPadding - self.scrollbarWidth resp = psychopy.visual.TextBox2( self.win, text='', pos=(x, 0), # y pos irrelevant now (handled by scrollbar) size=(w, 0.1), letterHeight=self.textHeight, units=self.units, anchor='top-right', color=item['responseColor'] or self.responseColor, colorSpace=self.colorSpace, font=item['font'] or self.font, editable=True, borderColor=item['responseColor'] or self.responseColor, borderWidth=2, fillColor=None, onTextCallback=self._layoutY, ) if debug: resp.borderColor = "red" # Resize textbox to be at least as tall as the text resp._updateVertices() textHeight = getattr(resp.boundingBox._size, resp.units)[1] if textHeight > resp.size[1]: resp.size[1] = textHeight + resp.padding[1] * 2 resp._layout() respHeight = resp.size[1] # store virtual pos to combine with scroll bar for actual pos resp._baseY = self._currentVirtualY return resp, respHeight
[docs] def _setScrollBar(self): """Creates Slider object for scrollbar Returns ------- psychopy.visual.slider.Slider The Slider object for scroll bar """ scroll = psychopy.visual.Slider(win=self.win, size=(self.scrollbarWidth, self.size[1] / 1.2), # Adjust size to account for scrollbar overflow ticks=[0, 1], style='scrollbar', borderColor=self.responseColor, fillColor=self.markerColor, pos=(self.rightEdge - self.scrollbarWidth / 2, self.pos[1]), autoLog=False) return scroll
[docs] def _setBorder(self): """Creates border using Rect Returns ------- psychopy.visual.Rect The border for the survey """ return psychopy.visual.Rect(win=self.win, units=self.units, pos=self.pos, width=self.size[0], height=self.size[1], colorSpace=self.colorSpace, fillColor=self.fillColor, lineColor=self.borderColor, opacity=None, autoLog=False)
[docs] def _setAperture(self): """Blocks text beyond border using Aperture Returns ------- psychopy.visual.Aperture The aperture setting viewable area for forms """ aperture = psychopy.visual.Aperture(win=self.win, name=f"{self.name}_aperture", units=self.units, shape='square', size=self.size, pos=self.pos, autoLog=False) aperture.disable() # Disable on creation. Only enable on draw. return aperture
[docs] def _getScrollOffset(self): """Calculate offset position of items in relation to markerPos. Offset is a proportion of `vheight - height`, meaning the max offset (when scrollbar.markerPos is 1) is enough to take the bottom element to the bottom of the border. Returns ------- float Offset position of items proportionate to scroll bar """ offset = max(self._vheight - self.size[1], 0) * (1 - self.scrollbar.markerPos) * -1 return offset
[docs] def _createItemCtrls(self): """Define layout of form""" # Define boundaries of form if self.autoLog: logging.info("Setting layout of Form: {}.".format(self.name)) self.leftEdge = self.pos[0] - self.size[0] / 2.0 self.rightEdge = self.pos[0] + self.size[0] / 2.0 # For each question, create textstim and rating scale for item in self.items: # set up the question object self._setQuestion(item) # set up the response object self._setResponse(item) # position a slider on right-hand edge self.scrollbar = self._setScrollBar() self.scrollbar.markerPos = 1 # Set scrollbar to start position self.border = self._setBorder() self.aperture = self._setAperture() # then layout the Y positions self._layoutY() if self.autoLog: logging.info("Layout set for Form: {}.".format(self.name))
[docs] def _layoutY(self): """This needs to be done when editable textboxes change their size because everything below them needs to move too""" self.topEdge = self.pos[1] + self.size[1] / 2.0 self._currentVirtualY = self.topEdge - self.itemPadding # For each question, create textstim and rating scale for item in self.items: question = item['itemCtrl'] response = item['responseCtrl'] # update item baseY question._baseY = self._currentVirtualY # and get height to update current Y questionHeight = self._getItemHeight(item=item, ctrl=question) # go on to next line if together they're too wide oneLine = (item['itemWidth']+item['responseWidth'] <= 1 or not response) if not oneLine: # response on next line self._currentVirtualY -= questionHeight + self.itemPadding / 4 # update response baseY if not response: self._currentVirtualY -= questionHeight + self.itemPadding continue # get height to update current Y respHeight = self._getItemHeight(item=item, ctrl=response) # update item baseY # slider needs to align by middle if type(response) == psychopy.visual.Slider: response._baseY = self._currentVirtualY - max(questionHeight, respHeight)/2 else: # hopefully we have an object that can anchor at top? response._baseY = self._currentVirtualY # go on to next line if together they're too wide if oneLine: # response on same line - work out which is bigger self._currentVirtualY -= ( max(questionHeight, respHeight) + self.itemPadding ) else: # response on next line self._currentVirtualY -= respHeight + self.itemPadding * 5/4 # Calculate virtual height as distance from top edge to bottom of last element self._vheight = abs(self.topEdge - self._currentVirtualY) self._setDecorations() # choose whether show/hide scroolbar
[docs] def _setDecorations(self): """Sets Form decorations i.e., Border and scrollbar""" # add scrollbar if it's needed self._decorations = [self.border] if self._vheight > self.size[1]: self._decorations.append(self.scrollbar)
[docs] def _inRange(self, item): """Check whether item position falls within border area Parameters ---------- item : TextStim, Slider object TextStim or Slider item from survey Returns ------- bool Returns True if item position falls within border area """ upperRange = self.size[1] lowerRange = -self.size[1] return (item.pos[1] < upperRange and item.pos[1] > lowerRange)
[docs] def _drawDecorations(self): """Draw decorations on form.""" [decoration.draw() for decoration in self._decorations]
[docs] def _drawExternalDecorations(self): """Draw decorations outside the aperture""" [decoration.draw() for decoration in self._externalDecorations]
[docs] def _drawCtrls(self): """Draw elements on form within border range. Parameters ---------- items : List List of TextStim or Slider item from survey """ for idx, item in enumerate(self.items): for element in [item['itemCtrl'], item['responseCtrl']]: if element is None: # e.g. because this has no resp obj continue element.pos = (element.pos[0], element._baseY - self._getScrollOffset()) if self._inRange(element): element.draw() if debug and hasattr(element, "guide"): # If debugging, draw position guide too element.guide.pos = (element.guide.pos[0], element._baseY - self._getScrollOffset() + element.guide.size[1] / 2) element.guide.draw()
[docs] def setAutoDraw(self, value, log=None): """Sets autoDraw for Form and any responseCtrl contained within """ for i in self.items: if i['responseCtrl']: i['responseCtrl'].__dict__['autoDraw'] = value self.win.addEditable(i['responseCtrl']) BaseVisualStim.setAutoDraw(self, value, log)
[docs] def draw(self): """Draw all form elements""" # Check mouse wheel self.scrollbar.markerPos += self.scrollbar.mouse.getWheelRel()[ 1] / self.scrollSpeed # draw the box and scrollbar self._drawExternalDecorations() # enable aperture self.aperture._reset() # draw the box and scrollbar self._drawDecorations() # Draw question and response objects self._drawCtrls() # disable aperture self.aperture.disable()
[docs] def getData(self): """Extracts form questions, response ratings and response times from Form items Returns ------- list A copy of the data as a list of dicts """ nIncomplete = 0 nIncompleteRequired = 0 for thisItem in self.items: if 'responseCtrl' not in thisItem or not thisItem['responseCtrl']: continue # maybe a heading or similar responseCtrl = thisItem['responseCtrl'] # get response if available if hasattr(responseCtrl, 'getRating'): thisItem['response'] = responseCtrl.getRating() else: thisItem['response'] = responseCtrl.text if thisItem['response'] in [None, '']: # todo : handle required items here (e.g. ending with * ?) nIncomplete += 1 # get RT if available if hasattr(responseCtrl, 'getRT'): thisItem['rt'] = responseCtrl.getRT() else: thisItem['rt'] = None self._complete = (nIncomplete == 0) return copy.copy(self.items) # don't want users changing orig
[docs] def reset(self): """ Clear all responses and set all items to their initial values. """ # Iterate through all items for item in self.items: # If item doesn't have a response ctrl, skip it if "responseCtrl" not in item: continue # If response ctrl is a slider, set its rating to None if isinstance(item['responseCtrl'], psychopy.visual.Slider): item['responseCtrl'].rating = None # If response ctrl is a textbox, set its text to blank elif isinstance(item['responseCtrl'], psychopy.visual.TextBox2): item['responseCtrl'].text = "" # Set scrollbar to top self.scrollbar.rating = 1
[docs] def addDataToExp(self, exp, itemsAs='rows'): """Gets the current Form data and inserts into an :class:`~psychopy.experiment.ExperimentHandler` object either as rows or as columns Parameters ---------- exp : :class:`~psychopy.experiment.ExperimentHandler` itemsAs: 'rows','cols' (or 'columns') Returns ------- """ data = self.getData() # will be a copy of data (we can trash it) asCols = itemsAs.lower() in ['cols', 'columns'] # iterate over items and fields within each item # iterate all items and all fields before calling nextEntry for ii, thisItem in enumerate(data): # data is a list of dicts for fieldName in thisItem: if fieldName in _doNotSave: continue if asCols: # for columns format, we need index for item columnName = "{}[{}].{}".format(self.name, ii, fieldName) else: columnName = "{}.{}".format(self.name, fieldName) exp.addData(columnName, thisItem[fieldName]) # finished field if not asCols: # for rows format we add a newline each item exp.nextEntry() # finished item # finished form if asCols: # for cols format we add a newline each item exp.nextEntry()
[docs] def formComplete(self): """Deprecated in version 2020.2. Please use the Form.complete property """ return self.complete
@property def pos(self): if hasattr(self, '_pos'): return self._pos @pos.setter def pos(self, value): self._pos = value if hasattr(self, 'aperture'): self.aperture.pos = value if hasattr(self, 'border'): self.border.pos = value self.leftEdge = self.pos[0] - self.size[0] / 2.0 self.rightEdge = self.pos[0] + self.size[0] / 2.0 # Set horizontal position of elements for item in self.items: for element in [item['itemCtrl'], item['responseCtrl']]: if element is None: # e.g. because this has no resp obj continue element.pos = [value[0], element.pos[1]] element._baseY = value[1] if hasattr(element, 'anchor'): element.anchor = 'top-center' # Calculate new position for everything on the y axis self.scrollbar.pos = (self.rightEdge - .008, self.pos[1]) self._layoutY() @property def scrollbarWidth(self): """ Width of the scrollbar for this Form, in the spatial units of this Form. Can also be set as a `layout.Vector` object. """ if not hasattr(self, "_scrollbarWidth"): # Default to 15px self._scrollbarWidth = layout.Vector(15, 'pix', self.win) return getattr(self._scrollbarWidth, self.units)[0] @scrollbarWidth.setter def scrollbarWidth(self, value): self._scrollbarWidth = layout.Vector(value, self.units, self.win) self.scrollbar.width[0] = self.scrollbarWidth @property def opacity(self): return BaseVisualStim.opacity.fget(self) @opacity.setter def opacity(self, value): BaseVisualStim.opacity.fset(self, value) self.fillColor = self._fillColor self.borderColor = self._borderColor if hasattr(self, "_foreColor"): self._foreColor.alpha = value if hasattr(self, "_itemColor"): self._itemColor.alpha = value if hasattr(self, "_responseColor"): self._responseColor.alpha = value if hasattr(self, "_markerColor"): self._markerColor.alpha = value @property def complete(self): """A read-only property to determine if the current form is complete""" self.getData() return self._complete @property def foreColor(self): """ Sets both `itemColor` and `responseColor` to the same value """ return ColorMixin.foreColor.fget(self) @foreColor.setter def foreColor(self, value): ColorMixin.foreColor.fset(self, value) self.itemColor = value self.responseColor = value @property def fillColor(self): """ Color of the form's background """ return ColorMixin.fillColor.fget(self) @fillColor.setter def fillColor(self, value): ColorMixin.fillColor.fset(self, value) if hasattr(self, "border"): self.border.fillColor = value @property def borderColor(self): """ Color of the line around the form """ return ColorMixin.borderColor.fget(self) @borderColor.setter def borderColor(self, value): ColorMixin.borderColor.fset(self, value) if hasattr(self, "border"): self.border.borderColor = value @property def itemColor(self): """ Color of the text on form items """ return getattr(self._itemColor, self.colorSpace) @itemColor.setter def itemColor(self, value): self._itemColor = Color(value, self.colorSpace) # Set text color on each item for item in self.items: if 'itemCtrl' in item: if isinstance(item['itemCtrl'], psychopy.visual.TextBox2): item['itemCtrl'].foreColor = self._itemColor @property def responseColor(self): """ Color of the lines and text on form responses """ if hasattr(self, "_responseColor"): return getattr(self._responseColor, self.colorSpace) @responseColor.setter def responseColor(self, value): self._responseColor = Color(value, self.colorSpace) # Set line color on scrollbar if hasattr(self, "scrollbar"): self.scrollbar.borderColor = self._responseColor # Set line and label color on each item for item in self.items: if 'responseCtrl' in item: if isinstance(item['responseCtrl'], psychopy.visual.Slider) or isinstance(item['responseCtrl'], psychopy.visual.TextBox2): item['responseCtrl'].borderColor = self._responseColor item['responseCtrl'].foreColor = self._responseColor @property def markerColor(self): """ Color of the marker on any sliders in this form """ if hasattr(self, "_markerColor"): return getattr(self._markerColor, self.colorSpace) @markerColor.setter def markerColor(self, value): self._markerColor = Color(value, self.colorSpace) # Set marker color on scrollbar if hasattr(self, "scrollbar"): self.scrollbar.fillColor = self._markerColor # Set marker color on each item for item in self.items: if 'responseCtrl' in item: if isinstance(item['responseCtrl'], psychopy.visual.Slider): item['responseCtrl'].fillColor = self._markerColor @property def style(self): if hasattr(self, "_style"): return self._style @style.setter def style(self, style): """Sets some predefined styles or use these to create your own. If you fancy creating and including your own styles that would be great! Parameters ---------- style: string Known styles currently include: 'light': black text on a light background 'dark': white text on a dark background """ self._style = style # If style is custom, skip the rest if style in ['custom...', 'None', None]: return # If style is a string of a known style, use that if style in self.knownStyles: style = self.knownStyles[style] # By here, style should be a dict if not isinstance(style, dict): return # Apply each key in the style dict as an attr for key, val in style.items(): if hasattr(self, key): setattr(self, key, val) @property def values(self): # Iterate through each control and append its value to a dict out = {} for item in self.getData(): out.update( {item['index']: item['response']} ) return out @values.setter def values(self, values): for item in self.items: if item['index'] in values: ctrl = item['responseCtrl'] # set response if available if hasattr(ctrl, "rating"): ctrl.rating = values[item['index']] elif hasattr(ctrl, "value"): ctrl.value = values[item['index']] else: ctrl.text = values[item['index']]

Back to top