diff --git a/source/developers/buildercomponent.rst b/source/developers/buildercomponent.rst index 160837b..a89d96a 100644 --- a/source/developers/buildercomponent.rst +++ b/source/developers/buildercomponent.rst @@ -3,113 +3,281 @@ Adding a new Builder Component ===================================== -Builder Components are auto-detected and displayed to the experimenter as icons (in the right-most panel of the Builder interface panel). This makes it straightforward to add new ones. +Builder Components are auto-detected and displayed to the experimenter as icons (in the right-most panel of the Builder interface panel), so adding new ones is fairly straightforward. You can add Components directly to PsychoPy (see :ref:`addFeature`, if this doesn't mean anything to you then see :ref:`usingRepos`) or create a plugin which adds them via an entry point. -All you need to do is create a list of parameters that the Component needs to know about (that will automatically appear in the Component's dialog) and a few pieces of code specifying what code should be called at different points in the script (e.g. beginning of the Routine, every frame, end of the study etc...). Many of these will come simply from subclassing the _base or _visual Components. - -To get started, :ref:`addFeature` for the development of this component. (If this doesn't mean anything to you then see :ref:`usingRepos` ) - -You'll mainly be working in the directory `.../psychopy/experiment/components/`. -Take a look at several existing Components (such as `image.py`), and key files including `_base.py` and `_visual.py`. - -There are three main steps, the first being by far the most involved. - -1. Create the file defining the component: newcomp.py +Creating a Component class --------------------------------------------------------- -It's most straightforward to model a new Component on one of the existing ones. Be prepared to specify what your Component needs to do at several different points in time: the first trial, every frame, at the end of each routine, and at the end of the experiment. In addition, you may need to sacrifice some complexity in order to keep things streamlined enough for a Builder (see e.g., `ratingscale.py`). - -Your new Component class (in your file `newcomp.py`) should inherit from `BaseComponent` (in `_base.py`), `VisualComponent` (in `_visual.py`), or `KeyboardComponent` (in `keyboard.py`). You may need to rewrite some or all some of these methods, to override default behavior:: +Components in PsychoPy are Python classes, so creating a Component is just a case of creating the right kind of class. In order to be recognised as a Component, your class needs to be a subclass of BaseComponent. You can do this via BaseComponent directly:: - class NewcompComponent(BaseComponent): # or (VisualComponent) - def __init__(...): - super(NewcompComponent, self).__init__(...) - ... - def writeInitCode(self, buff): - def writeRoutineStartCode(self, buff): - def writeFrameCode(self, buff): - def writeRoutineEndCode(self, buff): + from psychopy.experiment.components import BaseComponent -Calling `super()` will create the basic default set of `params` that almost every component will need: `name`, `startVal`, `startType`, etc. Some of these fields may need to be overridden (e.g., `durationEstim` in `sound.py`). Inheriting from `VisualComponent` (which in turn inherits from `BaseComponent`) adds default visual params, plus arranges for Builder scripts to import `psychopy.visual`. If your component will need other libs, call `self.exp.requirePsychopyLib(['neededLib'])` (see e.g., `parallelPort.py`). + class MyNewComponent(BaseComponent): + pass -At the top of a component file is a dict named `_localized`. It contains mappings that allow a strict separation of internal string values (= used in logic, never displayed) from values used for display in the Builder interface (= for display only, possibly translated, never used in logic). The `.hint` and `.label` fields of `params['someParam']` should always be set to a localized value, either by using a dict entry such as `_localized['message']`, or via the globally available translation function, `_('message')`. Localized values must **not** be used elsewhere in a component definition. +Or via another class which itself is a subclass of BaseComponent (such as BaseVisualComponent or BaseDeviceComponent):: -Very occasionally, you may also need to edit `settings.py`, which writes out the set-up code for the whole experiment (e.g., to define the window). For example, this was necessary for the ApertureComponent, to pass `allowStencil=True` to the window creation. + from psychopy.experiment.components import BaseVisualComponent, BaseDeviceComponent -Your new Component writes code into a buffer that becomes an executable python file, `xxx_lastrun.py` (where `xxx` is whatever the experimenter specifies when saving from the Builder, `xxx.psyexp`). You will do a bunch of this kind of call in your `newcomp.py` file:: + class MyNewDeviceComponent(BaseDeviceComponent): + pass + + class MyNewVisualComponent(BaseVisualComponent): + pass - buff.writeIndented(your_python_syntax_string_here) +In order to be detected by the |PsychoPy| Studio/Standalone app, your Component class needs to either: -You have to manage the indentation level of the output code, see `experiment.IndentingBuffer()`. +* Be defined in a .py file in its own folder in the the folder `psychopy/experiment/components` within the PsychoPy library (if you're contributing directly to PsychoPy) +* Be connected to the module ``psychopy.experiment.components`` by an `entry point <_entryPoints>`_ (if you're writing a plugin) -`xxx_lastrun.py` is the file that gets built when you run `xxx.psyexp` from the Builder. So you will want to look at `xxx_lastrun.py` frequently when developing your component. +Both apps use the same function in the |PsychoPy| library to get a list of all Components, so you can test whether your Component will be detected without having to actually start either app by running the following:: -**Name-space** + from psychopy.experiment import getAllComponents -There are several internal variables (i.e. names of Python objects) that have a specific, hardcoded meaning within `xxx_lastrun.py`. You can expect the -following to be there, and they should only be used in the original way (or something will break for the end-user, likely in a mysterious way):: + print( + getAllComponents() + ) - win # the window - t # time within the trial loop, referenced to `trialClock` - x, y # mouse coordinates, but only if the experimenter uses a mouse component +If your Component is configured correctly, you should see its name in the output. -Handling of variable names is under active development, so this list may well be out of date. (If so, you might consider updating it or posting a note to the |PsychoPy| Discourse developer forum.) -Preliminary testing suggests that there are 600-ish names from numpy or numpy.random, plus the following:: - - ['KeyResponse', '__builtins__', '__doc__', '__file__', '__name__', '__package__', 'buttons', 'core', 'data', 'dlg', 'event', 'expInfo', 'expName', 'filename', 'gui', 'logFile', 'os', 'psychopy', 'sound', 't', 'visual', 'win', 'x', 'y'] +Adding parameters +--------------------------------------------------------- -Yet other names get derived from user-entered names, like `trials` --> `thisTrial`. +The controls that you see in |PsychoPy| Studio/Standalone are created based on the value of the ``.params`` attribute of your Component. + +.. image:: slider-params-example.png + +This attribute should be a ``dict``, with each key being a ``Param`` object. These objects can be created with the following values: + +* *val*: A default value for this param +* *valType*: This tells |PsychoPy| how to write the value of this param when compiling the experiment code. Note that, if the param's value begins with a $, it will always be treated as code regardless of valType. Options are: + + * ``str``: A string, will be compiled with " around it + * ``extendedStr``: A long string, will be compiled with " around it and linebreaks will be preserved + * ``code``: Some code, will be compiled verbatim or translated to JS (no ") + * ``extendedCode``: A block of code, will be compiled verbatim or translated to JS and linebreaks will be preserved + * ``file``: A file path, will be compiled like str but will replace unescaped \\ with / + * ``list``: A list of values, will be compiled like code but if there's no [] or () then these are added + +* *inputType*: This tells |PsychoPy| how to represent the parameter in the Builder; what kind of control to show for it. Options are: + + * ``single``: A single-line text control + * ``multi``: A multi-line text control + * ``color``: A single-line text control with a button to open the color picker + * ``survey``: A single-line text control with a button to open Pavlovia surveys list + * ``file``: A single-line text control with a button to open a file browser + * ``fileList``: Several file controls with buttons to add/remove + * ``table``: A file control with an additional button to open in Excel + * ``choice``: A single-choice control (dropdown) whose choices are listed in ``allowedVals`` and ``allowedLabels`` + * ``device``: A single-choice control (dropdown) whose choices are the devices from DeviceManager which match the classes listed in ``allowedVals`` + * ``multiChoice``: A multi-choice control (tickboxes) + * ``richChoice``: A single-choice control (dropdown) with rich text for each option + * ``bool``: A single checkbox control + * ``dict``: Several key:value pair controls with buttons to add/remove fields + +* *allowedVals*: For params with a fixed set of options, this tells |PsychoPy| what the values of those options are. These *should not* be translated, as they are the actual values used in code. +* *allowedLabels*: A list of labels corresponding to the values in ``allowedVals`` which tells |PsychoPy| what to display each option as. These can (and should) be translated, as they are not used in code. +* *categ*: Which tab this param should appear in, leave as ``None`` to have the param appear at the top of the Component dialog (use this sparingly; it is best saved for core functionality like the Component name and disabled control) +* *label*: What to label this param as. Keep in mind, the param will still be referred to in code by its key, this is just for the graphical interface of Builder. +* *hint*: The hint shown when this param's label is hovered over. +* *updates*: When the value of this param should be set, should be one of: + + * *constant*: Value is set just the once + * *set every repeat*: Value is set at the start of each Routine + * *set every frame*: Value is set each frame + +* *allowedUpdates*: Which values for ``updates`` the user is allowed to choose (leave as None to hide the control) + +So, for example, the parameter in the Keyboard Component which determines whether keypresses should be registered on press or on release looks like this:: + + self.params['registerOn'] = Param( + # starts off as "press" + "press", + # written to code as a string + valType="str", + # shown in Builder as a dropdown menu + inputType="choice", + # has two options... + allowedVals=["press", "release"], + # ...which should be labelled as follows + allowedLabels=[_translate("Press"), _translate("Release")], + # shown in the "Basic" tab + categ="Basic", + # the label shown in Builder + label=_translate("Register keypress on..."), + # the tooltip when hovered over + hint=_translate( + "When should the keypress be registered? As soon as pressed, or when released?" + ), + # set just the once, when the Keyboard is created + updates="constant", + # there is no option to set each repeat/frame + allowedUpdates=None + ) + +Many params will already exist by virtue of subclassing. For example, the ``name`` param is always present as it's added by ``BaseComponent``, and the ``pos`` and ``size`` params are always present on subclasses of ``BaseVisualComponent`` for the same reason. + +Translating labels +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +PsychoPy has a team of volunteer translators who provide translations for all labels in the PsychoPy apps. To use an existing translation (or make sure your label is picked up by the code which tells the team what to translate), you need to import the ``_translate`` function from ``psychopy.localization`` and wrap any string in the following:: + + _translate("My label") + +This should be done on labels and tooltips which are *presented to the user*, never on values which are *used in code*, as translating a string could break code further down the line (e.g. if you had code which said ``if fruit == "banana":`` and ``fruit`` was translated into another language, this statement would always be ``False``). Also, as strings are submitted for translation before interpretation, any string formatting needs to be done after ``_translate``, so for example:: + + # CORRECT + _translate("The value is {}").format(someValue) + # INCORRECT + _translate("The value is {}".format(someValue)) + +As in the former example, translaters can translate ``"The value is "`` and leave ``{}`` as is. Whereas in the latter, the string to translate would depend on the value of ``someValue``, so the translation would only cover the default value. + +Writing code +--------------------------------------------------------- -**Params** +What a Component is, at its core, is something which *takes parameter values* and returns *Python or JavaScript code*. It does this by defining a set of functions which write code at certain points in an experiment, using the values of its parameters to construct `Python formatted strings `_. Some of this code is already defined by the base class you're subclassing (e.g. if you're creating a subclass of BaseVisualComponent, then it will already know how to draw itself each frame according to start/stop parameters), but generally you will want to overwrite some or all of these to get the desired behaviour. + +The functions which write a Component's code are roughly equivalent to the tabs of a :ref:`_code`: + +* ``writePreCode/writePreCodeJS``: Written before the experiment starts (Before Experiment) +* ``writeInitCode/writeInitCodeJS``: Written at the start of the experiment (Begin Experiment) +* ``writeRoutineStartCode/writeRoutineStartCodeJS``: Written at the start of this Component's Routine (Begin Routine) +* ``writeFrameCode/writeFrameCodeJS``: Written each frame of this Component's Routine (Each Frame) +* ``writeRoutineEndCode/writeRoutineEndCodeJS``: Written at the end of this Component's Routine (End Routine) +* ``writeExperimentEndCode/writeExperimentEndCodeJS``: Written at the end of the experiment (End Experiment) + +All of these functions take an input argument ``buff``, which is the text buffer to which the current experiment is being written. You can therefore write your code to this buffer by calling:: + + buff.writeIndentedLines(code) + +You will also need to use this buffer to manage indenting:: + + buff.setIndentLevel(, relative=) + +The ``BaseComponent`` class also offers some helper functions which you can use to make your code writing a bit easier: + +* ``writeStartTestCode(self, buff, extra="")/writeStartTestCodeJS(self, buff, extra="")``: Writes code to check whether the Component needs to start, based on its start params, leaving the relevant ``if`` statements open. The returned value is how many ``if`` statements were left open, so after writing whatever you would like to happen on the starting frame, you will need to call ``buff.setIndentLevel(-)`` (and write some ``}``'s in JS) to return to normal after. Use the optional argument ``extra`` to add additional conditions to check for (e.g. ``and someLimitingFactor == True``). +* ``writeStopTestCode(self, buff, extra="")/writeStopTestCodeJS(self, buff, extra="")``: Same as ``writeStartTestCode`` but rather than checking whether the Component is ready to start, checks whether it is ready to stop. +* ``writeActiveTestCode(self, buff, extra="")/writeActiveTestCodeJS(self, buff, extra="")``: Same as ``writeStartTestCode`` and ``writeStopTestCode`` but rather than checking whether the Component is ready to start or stop, checks whether it has started and is yet to stop. +* ``writeParamUpdates(self, buff, updateType)/writeParamUpdatesJS(self, buff, updateType)``: Writes the necessary updates to params if their update type matches what is given. This is already called by ``writeActiveTestCode`` and in the default behaviour of ``writeRoutineStartCode``. +* ``getPosInRoutine(self)``: Gets the position of this Component in its Routine; useful for choosing what value to give for ``depth`` with visual Components. + +Using parameters +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``Param`` class has some clever functions for handling turning itself into a string (``__str__``), which means you don't have to worry about getting and processing the value of the parameter yourself. Simply pass it to a Python ``format`` function, and it will write itself correctly according to its ``valType`` attribute. Examples of how you might do this:: + + # using %s formatting + code = ( + "%(name)s = visual.SomeStim(\n" + " name='%(name)s',\n" + " pos=%(pos)s,\n" + ");" + ) % self.params + # using .format + code = ( + "{name} = visual.SomeStim(\n" + " name='{name}',\n" + " pos={pos},\n" + ");" + ).format(**self.params) + # using f strings + code = ( + f"{self.params['name']} = visual.SomeStim(\n" + f" name='{self.params['name']}',\n" + f" pos={self.params['pos']},\n" + f");" + ) + +It is worth noting that in ``writeInitCode``, parameters may need to have different values, as if a parameter's value is ``set each frame`` or ``set each repeat`` then it will need to be initialised as a safe default. To get these defaults, you can use the function ``psychopy.experiment.components:getInitVals``, which will return a dict which you can use in place of ``self.params``. + +Name space +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +There are several internal variables (i.e. names of Python objects) that have a specific, hardcoded meaning within `xxx_lastrun.py`. You can expect the following to be there, and they should only be used in the original way (or something will break for the end-user, likely in a mysterious way) + +=================== ============== +Name Use +=================== ============== +win The window +t Time (s) since the Routine began +continueRoutine Boolean which will end the Routine on next frame flip if set to False +logging PsychoPy's ``logging`` module +thisExperiment ``psychopy.data.experiment:ExperimentHandler`` object representing the current experiment +currentLoop Trial handler object representing the current loop, if any +=================== ============== + +Other names will also get derived from user-entered names, like `trials` (name of a loop) --> `thisTrial`.Handling of variable names is under active development, so this list may well be out of date. (If so, you might consider updating it or posting a note to the |PsychoPy| Discourse developer forum.) + +Creating an icon +--------------------------------------------------------- -`self.params` is a key construct that you build up in `__init__`. You need name, startTime, duration, and several other params to be defined or you get errors. `'name'` should be of type `'code'`. +The best way to make an icon which will work in both PsychoPy Studio and PsychoPy Standalone is to use a vector editor like `Affinity `_, `Inkscape `_ or `Adobe Illustrator `_ which allows you to export your icon to whatever file format is required. While the appearance of your Component icon is totally up to you, we recommend using thick grey lines with curved corners and limiting colors to just red and blue. -The `Param()` class is defined in `psychopy.app.builder.experiment.Param()`. A very useful thing that Params know is how to create a string suitable for writing into the .py script. In particular, the `__str__` representation of a Param will format its value (`.val`) based on its type (`.valType`) appropriately. This means that you don't need to check or handle whether the user entered a plain string, a string with a code trigger character (`$`), or the field was of type `code` in the first place. If you simply request the `str()` representation of the param, it is formatted correctly. +For PsychoPy Studio +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -To indicate that a param (eg, `thisParam`) should be considered as an advanced feature, set its category to advanced: `self.params['thisParam'].categ = 'Advanced'`. Then the GUI shown to the experimenter will automatically place it on the 'Advanced' tab. Other categories work similarly (`Custom`, etc). +As PsychoPy Studio uses modern web-based technologies, icons for PsychoPy Studio are .svg files. This makes them small, fast to load, infinitely scalable and theme responsive. The app interface of PsychoPy Studio is Chromium-based, so if you can open your icon in Google Chrome (or other Chromium-based browsers) then it should look the same in PsychoPy Studio. -During development, it can sometimes be helpful to save the params into the `xxx_lastrun.py` file as comments, so you can see what is happening:: +Because .svg is a text-based format, you can make your icon theme-responsive by opening the exported .svg file in a text editor and replacing the color values (e.g. ``#F2545B``) with named CSS variables (e.g. ``var(--red)``). See below for the color names used by PsychoPy Studio and what color they correspond to in PsychoPy Light and PsychoPy Dark themes. - def writeInitCode(self,buff): - # for debugging during Component development: - buff.writeIndented("# self.params for aperture:\n") - for p in self.params: - try: buff.writeIndented("# %s: %s \n" % (p, self.params[p].val, self.params[p].valType)) - except: pass +=============== =============== =============== +Name PsychoPy Light PsychoPy Dark +=============== =============== =============== +--red #F2545B #F2545B +--purple #C3BEF7 #C3BEF7 +--blue #02A9EA #02A9EA +--green #6CCC74 #6CCC74 +--yellow #F1D302 #F1D302 +--orange #EC9703 #EC9703 +--base #FFFFFF #75757d +--mantle #F2F2F2 #75757d +--crust #E4E4E4 #57575f +--overlay #D6D6D6 #484850 +--outline #66666E #ACACB0 +--text #242427 #FFFFFF +--hltext #FFFFFF #242427 +=============== =============== =============== -A lot more detail can be inferred from existing components. +In general, PsychoPy icons stick to just ``--red``, ``--blue`` and ``--outline``, so your icon will best fit in with existing Components if you limit your palette to these three colors. -Making things loop-compatible looks interesting -- see `keyboard.py` for an example, especially code for saving data at the end. +Once you have a .svg file ready to add, you can assign it to your Component by setting the class attribute ``iconSVG`` of your Component class to be the file's path. The easiest way to do this is to put it in the same folder as your Component and set the attribute to:: -Notes & gotchas ----------------- + pathlib.Path(__file__).parent / '.svg' - *syntax errors in new_comp.py:* - The |PsychoPy| app will fail to start if there are syntax error in any of the components that are auto-detected. Just correct them and start the app again. +For PsychoPy Standalone +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - *param[].val:* - If you have a boolean variable (e.g., `my_flag`) as one of your params, note that `self.param["my_flag"]` is always True (the param exists --> True). So in a boolean context you almost always want the `.val` part, e.g., `if self.param["my_flag"].val:`. +As PsychoPy Standalone was written in wxPython, adding icons for this app means exporting a .png file for each of the three app themes: Light, dark and classic. These should all be 48x48px, though in order for your Component to look correct on an Apple Retina screen you should also export it as 96x96px with ``@2x`` at the end of the file stem (just before ``.png``). Many image editors will include this as an export preset. - However, you do not always want `.val`. Specifically, in a string/unicode context (= to trigger the self-formatting features of Param()s), you almost always want `"%s" % self.param['my_flag']`, without `.val`. Note that it's better to do this via `"%s"` than `str()` because `str(self.param["my_flag"])` coerces things to type str (squashing unicode) whereas `%s` works for both str and unicode. +Light and dark icons should be broadly the same, only with a different shade of grey for outlines so as to be more visible on light/dark background. PsychoPy icons generally stick to the following colors for each theme: - *Travis testing* - Before submitting a pull request with the new component, you should regenerate the `componsTemplate.txt` file. This is a text file that lists the attributes of all of the user interface settings and options in the various components. It is used during the Travis automated testing process when a pull request is submitted to GitHub, allowing the detection of errors that may have been caused in refactoring. Your new component needs to have entries added to this file if the Travis testing is going to pass successfully. - - To re-generate the file, cd to this directory `.../psychopy/tests/test_app/test_builder/` and run:: +=============== =============== +PsychoPy Light PsychoPy Dark +=============== =============== +#F2545B #F2545B +#02A9EA #02A9EA +#66666E #ACACB0 +=============== =============== - `python genComponsTemplate.py --out` +The classic theme exists for compatability with PsychoPy's old (pre-2020) look, so icons for that theme should be drawn from the `Crystal icon pack `_. If none meet your needs, you can create a copy of your dark mode icon, as this will be visible on either background. - This will over-write the existing file so you might want to make a copy in case the process fails. *Compatibility issues:* As at May 2018, that script is not yet Python 3 compatible, and on a Mac you might need to use `pythonw`. +Icons should be sorted into folders (called ``light``, ``dark`` and ``classic``) by theme. To connect them to your Component, set the ``iconFile`` attribute of the Component class to be the file path of the icon *without the theme subfolder or @2x*. For example, if your file structure looked like this:: -2. Icon: newcomp.png ------------------------- -Using your favorite image software, make an icon for your Component with a descriptive name, e.g., `newcomp.png`. Dimensions = 48 × 48. Put it in the components directory. + -> myComponent + -> light + -> myComponentIcon.png + -> myComponentIcon@2x.png + -> dark + -> myComponentIcon.png + -> myComponentIcon@2x.png + -> classic + -> myComponentIcon.png + -> myComponentIcon@2x.png -In `newcomp.py`, have a line near the top:: +Then you would set ``icon`` as:: - iconFile = path.join(thisFolder, 'newcomp.png') + Path(__file__).parent / 'myComponentIcon.png' -3. Documentation: newcomp.rst ---------------------------------- -Just make a descriptively-named text file that ends in `.rst` ("restructured text"), and put it in `psychopy/docs/source/builder/components/` . It will get auto-formatted and end up at `https://www.psychopy.org/builder/components/newcomp.html` diff --git a/source/developers/slider-params-example.png b/source/developers/slider-params-example.png new file mode 100644 index 0000000..35c147d Binary files /dev/null and b/source/developers/slider-params-example.png differ