psychopy 2024.2.1__py3-none-any.whl → 2024.2.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of psychopy might be problematic. Click here for more details.
- psychopy/.DS_Store +0 -0
- psychopy/GIT_SHA +1 -1
- psychopy/VERSION +1 -1
- psychopy/__init__.py +10 -1
- psychopy/__init__.py.orig +65 -0
- psychopy/app/{locale/ar_001/.DS_Store → .DS_Store} +0 -0
- psychopy/app/Resources/.DS_Store +0 -0
- psychopy/app/_psychopyApp.py +11 -3
- psychopy/app/appData.spec +1 -1
- psychopy/app/builder/builder.py +1 -1
- psychopy/app/builder/builder.py.orig +3932 -0
- psychopy/app/builder/dialogs/__init__.py.orig +1679 -0
- psychopy/app/builder/dialogs/paramCtrls.py +1 -1
- psychopy/app/builder/dialogs/paramCtrls.py.orig +713 -0
- psychopy/app/colorpicker/__init__.py.orig +411 -0
- psychopy/app/cortex.log +0 -0
- psychopy/app/jobs.py +8 -1
- psychopy/app/locale/ar_001/LC_MESSAGE/messages.po +2452 -1731
- psychopy/app/locale/zh_CN/LC_MESSAGE/zh_CN.mo +0 -0
- psychopy/app/locale/zh_CN/LC_MESSAGE/zh_CN.po +6127 -0
- psychopy/app/locale/zh_CN/LC_MESSAGE/zh_CN_allFlagged.mo +0 -0
- psychopy/app/locale/zh_CN/LC_MESSAGE/zh_CN_allFlagged.po +7366 -0
- psychopy/app/plugin_manager/dialog.py +9 -7
- psychopy/app/ribbon.py +2 -1
- psychopy/app/runner/runner.py +7 -5
- psychopy/clock.py +8 -4
- psychopy/core.py.orig +169 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/html/index.html +23 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/html/randomisedBlocks-legacy-browsers.js +423 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/html/randomisedBlocks.js +427 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/chooseBlock.xlsx +0 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/facesBlock.xlsx +0 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/housesBlock.xlsx +0 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/stims/face01.jpg +0 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/stims/face02.jpg +0 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/stims/face03.jpg +0 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/stims/house01.jpg +0 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/stims/house02.jpg +0 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/stims/house03.jpg +0 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/randomisedBlocks.py +330 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/randomisedBlocks_lastrun.py +330 -0
- psychopy/demos/builder/Feature Demos/eyetracking/eyetracking.xml +298 -0
- psychopy/demos/builder/Feature Demos/eyetracking/eyetracking.xsd +120 -0
- psychopy/demos/builder/Tools/.DS_Store +0 -0
- psychopy/demos/builder/Tools/gammaCalibration/.DS_Store +0 -0
- psychopy/demos/builder/Tools/gammaCalibration/data/_gamma_correction_visual_2022-05-18_14h18.29.439.csv +38 -0
- psychopy/demos/builder/Tools/gammaCalibration/data/_gamma_correction_visual_2022-05-18_14h18.29.439.log +3418 -0
- psychopy/demos/builder/Tools/gammaCalibration/data/_gamma_correction_visual_2022-05-18_14h18.29.439.psydat +0 -0
- psychopy/demos/builder/Tools/gammaCalibration/data/x1_gamma_correction_visual_2022-05-17_13h59.42.928.csv +2 -0
- psychopy/demos/builder/Tools/gammaCalibration/data/x1_gamma_correction_visual_2022-05-17_13h59.42.928.log +15 -0
- psychopy/demos/builder/Tools/gammaCalibration/data/x1_gamma_correction_visual_2022-05-17_13h59.42.928.psydat +0 -0
- psychopy/demos/builder/Tools/gammaCalibration/gamma_correction_visual.psyexp +323 -0
- psychopy/demos/builder/Tools/gammaCalibration/gamma_correction_visual.py +562 -0
- psychopy/demos/builder/Tools/gammaCalibration/gamma_correction_visual_lastrun.py +562 -0
- psychopy/demos/builder/Tools/gammaCalibration/questStairs.xlsx +0 -0
- psychopy/demos/builder/Tools/gammaCalibration/readme.md +0 -0
- psychopy/demos/builder/Tools/gammaCalibration/resources/low_contrast.png +0 -0
- psychopy/demos/builder/Tools/gammaCalibration/resources/make_2nd_order_tex.py +59 -0
- psychopy/demos/builder/Tools/gammaCalibration/resources/second_order_tex.png +0 -0
- psychopy/demos/coder/.DS_Store +0 -0
- psychopy/demos/coder/experiment control/info_gamma.pickle +0 -0
- psychopy/demos/coder/iohub/.iohpid +1 -0
- psychopy/demos/coder/iohub/eyetracking/.iohpid +1 -0
- psychopy/demos/coder/iohub/wintab/.DS_Store +0 -0
- psychopy/demos/coder/stimuli/.DS_Store +0 -0
- psychopy/demos/coder/stimuli/radialGratingContracting.py +29 -0
- psychopy/experiment/_experiment.py.orig +1032 -0
- psychopy/experiment/components/.DS_Store +0 -0
- psychopy/experiment/components/_base.py +13 -4
- psychopy/experiment/components/_base.py.orig +823 -0
- psychopy/experiment/components/form/.DS_Store +0 -0
- psychopy/experiment/components/microphone/__init__.py +10 -1
- psychopy/experiment/components/microphone/__init__.py.orig +490 -0
- psychopy/experiment/components/polygon/__init__.py +21 -22
- psychopy/experiment/components/settings/__init__.py +13 -14
- psychopy/experiment/components/settings/__init__.py.orig +1337 -0
- psychopy/experiment/components/textbox/__init__.py.orig +310 -0
- psychopy/experiment/components/webcam/.DS_Store +0 -0
- psychopy/experiment/components/webcam/light/.DS_Store +0 -0
- psychopy/experiment/flow.py +10 -8
- psychopy/experiment/loops.py.orig +829 -0
- psychopy/experiment/params.py +8 -3
- psychopy/experiment/params.py.orig +408 -0
- psychopy/experiment/routine.py.orig +503 -0
- psychopy/experiment/routines/_base.py +15 -6
- psychopy/experiment/routines/counterbalance/__init__.py +1 -0
- psychopy/gui/qtgui.py +14 -7
- psychopy/gui/util.py +10 -14
- psychopy/gui/wxgui.py +10 -4
- psychopy/hardware/.DS_Store +0 -0
- psychopy/hardware/brainproducts.py.orig +680 -0
- psychopy/hardware/iolab.py.orig +238 -0
- psychopy/hardware/manager.py +1 -1
- psychopy/hardware/photodiode.py +59 -27
- psychopy/hardware/serialport.py +51 -0
- psychopy/hardware/speaker.py +4 -4
- psychopy/iohub/datastore/__init__.py.orig +443 -0
- psychopy/iohub/datastore/util.py.orig +692 -0
- psychopy/iohub/devices/mouse/darwin.py.orig +427 -0
- psychopy/iohub/devices/mouse/linux2.py.orig +198 -0
- psychopy/preferences/.DS_Store +0 -0
- psychopy/projects/pavlovia.py +10 -3
- psychopy/projects/pavlovia.py.orig +1295 -0
- psychopy/sound/backend_ptb.py +22 -5
- psychopy/sound/transcribe.py +24 -4
- psychopy/tests/.DS_Store +0 -0
- psychopy/tests/data/.DS_Store +0 -0
- psychopy/tests/data/TestCircle_fill_local.png +0 -0
- psychopy/tests/data/__test.png +0 -0
- psychopy/tests/data/aperture1_normHexbackground_local.png +0 -0
- psychopy/tests/data/aperture1_norm_local.png +0 -0
- psychopy/tests/data/aperture2_normHexbackground_local.png +0 -0
- psychopy/tests/data/beatandrcos_height_local.png +0 -0
- psychopy/tests/data/beatandrcos_normAddBlend_local.png +0 -0
- psychopy/tests/data/beatandrcos_normHexbackground_local.png +0 -0
- psychopy/tests/data/beatandrcos_norm_local.png +0 -0
- psychopy/tests/data/beatandrcos_stencil_local.png +0 -0
- psychopy/tests/data/blend_add_height_local.png +0 -0
- psychopy/tests/data/blend_add_normAddBlend_local.png +0 -0
- psychopy/tests/data/blend_add_normHexbackground_local.png +0 -0
- psychopy/tests/data/blend_add_normNoShade_local.png +0 -0
- psychopy/tests/data/blend_add_norm_local.png +0 -0
- psychopy/tests/data/blend_add_stencil_local.png +0 -0
- psychopy/tests/data/bufferimg_gabor_height_local.png +0 -0
- psychopy/tests/data/bufferimg_gabor_normAddBlend_local.png +0 -0
- psychopy/tests/data/bufferimg_gabor_normHexbackground_local.png +0 -0
- psychopy/tests/data/bufferimg_gabor_normNoShade_local.png +0 -0
- psychopy/tests/data/bufferimg_gabor_norm_local.png +0 -0
- psychopy/tests/data/bufferimg_gabor_stencil_local.png +0 -0
- psychopy/tests/data/circleHex_height_local.png +0 -0
- psychopy/tests/data/circleHex_normAddBlend_local.png +0 -0
- psychopy/tests/data/circleHex_normHexbackground_local.png +0 -0
- psychopy/tests/data/circleHex_normNoShade_local.png +0 -0
- psychopy/tests/data/circleHex_norm_local.png +0 -0
- psychopy/tests/data/circleHex_stencil_local.png +0 -0
- psychopy/tests/data/color_comparison_local.png +0 -0
- psychopy/tests/data/corrFullRandom_local.csv +16 -0
- psychopy/tests/data/corrFullRandom_local.tsv +6 -0
- psychopy/tests/data/correctScript/.DS_Store +0 -0
- psychopy/tests/data/dots_height_local.png +0 -0
- psychopy/tests/data/dots_normAddBlend_local.png +0 -0
- psychopy/tests/data/dots_normHexbackground_local.png +0 -0
- psychopy/tests/data/dots_normNoShade_local.png +0 -0
- psychopy/tests/data/dots_norm_local.png +0 -0
- psychopy/tests/data/dots_stencil_local.png +0 -0
- psychopy/tests/data/elarray1_height_local.png +0 -0
- psychopy/tests/data/elarray1_normAddBlend_local.png +0 -0
- psychopy/tests/data/elarray1_normHexbackground_local.png +0 -0
- psychopy/tests/data/elarray1_norm_local.png +0 -0
- psychopy/tests/data/elarray1_stencil_local.png +0 -0
- psychopy/tests/data/envelopeandrcos_height_local.png +0 -0
- psychopy/tests/data/envelopeandrcos_normAddBlend_local.png +0 -0
- psychopy/tests/data/envelopeandrcos_normHexbackground_local.png +0 -0
- psychopy/tests/data/envelopeandrcos_norm_local.png +0 -0
- psychopy/tests/data/envelopeandrcos_stencil_local.png +0 -0
- psychopy/tests/data/envelopepowerandrcos_height_local.png +0 -0
- psychopy/tests/data/envelopepowerandrcos_normAddBlend_local.png +0 -0
- psychopy/tests/data/envelopepowerandrcos_normHexbackground_local.png +0 -0
- psychopy/tests/data/envelopepowerandrcos_norm_local.png +0 -0
- psychopy/tests/data/envelopepowerandrcos_stencil_local.png +0 -0
- psychopy/tests/data/gabor1_height_local.png +0 -0
- psychopy/tests/data/gabor1_normAddBlend_local.png +0 -0
- psychopy/tests/data/gabor1_normHexbackground_local.png +0 -0
- psychopy/tests/data/gabor1_normNoShade_local.png +0 -0
- psychopy/tests/data/gabor1_norm_local.png +0 -0
- psychopy/tests/data/gabor1_stencil_local.png +0 -0
- psychopy/tests/data/greyscale_normHexbackground_local.png +0 -0
- psychopy/tests/data/imageAndGauss_height_local.png +0 -0
- psychopy/tests/data/imageAndGauss_normAddBlend_local.png +0 -0
- psychopy/tests/data/imageAndGauss_normHexbackground_local.png +0 -0
- psychopy/tests/data/imageAndGauss_normNoShade_local.png +0 -0
- psychopy/tests/data/imageAndGauss_norm_local.png +0 -0
- psychopy/tests/data/imageAndGauss_stencil_local.png +0 -0
- psychopy/tests/data/movFrame1_stencil_local.png +0 -0
- psychopy/tests/data/noiseAndRcos_height_local.png +0 -0
- psychopy/tests/data/noiseAndRcos_normAddBlend_local.png +0 -0
- psychopy/tests/data/noiseAndRcos_normHexbackground_local.png +0 -0
- psychopy/tests/data/noiseAndRcos_normNoShade_local.png +0 -0
- psychopy/tests/data/noiseAndRcos_norm_local.png +0 -0
- psychopy/tests/data/noiseAndRcos_stencil_local.png +0 -0
- psychopy/tests/data/noiseFiltersAndRcos_height_local.png +0 -0
- psychopy/tests/data/noiseFiltersAndRcos_normAddBlend_local.png +0 -0
- psychopy/tests/data/noiseFiltersAndRcos_normHexbackground_local.png +0 -0
- psychopy/tests/data/noiseFiltersAndRcos_normNoShade_local.png +0 -0
- psychopy/tests/data/noiseFiltersAndRcos_norm_local.png +0 -0
- psychopy/tests/data/noiseFiltersAndRcos_stencil_local.png +0 -0
- psychopy/tests/data/numpyImage_height_local.png +0 -0
- psychopy/tests/data/numpyImage_normAddBlend_local.png +0 -0
- psychopy/tests/data/numpyImage_normHexbackground_local.png +0 -0
- psychopy/tests/data/numpyImage_normNoShade_local.png +0 -0
- psychopy/tests/data/numpyImage_norm_local.png +0 -0
- psychopy/tests/data/numpyImage_stencil_local.png +0 -0
- psychopy/tests/data/shape2_1_normAddBlend_local.png +0 -0
- psychopy/tests/data/shape2_1_normHexbackground_local.png +0 -0
- psychopy/tests/data/shape2_1_normNoShade_local.png +0 -0
- psychopy/tests/data/shape2_1_norm_local.png +0 -0
- psychopy/tests/data/shape2_1_stencil_local.png +0 -0
- psychopy/tests/data/testLoopsBlocks.psyexp_local.py +328 -0
- psychopy/tests/data/text1_height_local.png +0 -0
- psychopy/tests/data/text1_normAddBlend_local.png +0 -0
- psychopy/tests/data/text1_normHexbackground_local.png +0 -0
- psychopy/tests/data/text1_norm_local.png +0 -0
- psychopy/tests/data/text1_stencil_local.png +0 -0
- psychopy/tests/data/text2_height.png +0 -0
- psychopy/tests/data/text2_normAddBlend.png +0 -0
- psychopy/tests/data/text2_normHexbackground.png +0 -0
- psychopy/tests/data/text2_stencil.png +0 -0
- psychopy/tests/data/wedge1_height_local.png +0 -0
- psychopy/tests/data/wedge1_normAddBlend_local.png +0 -0
- psychopy/tests/data/wedge1_normHexbackground_local.png +0 -0
- psychopy/tests/data/wedge1_normNoShade_local.png +0 -0
- psychopy/tests/data/wedge1_norm_local.png +0 -0
- psychopy/tests/data/wedge1_stencil_local.png +0 -0
- psychopy/tests/test_app/.DS_Store +0 -0
- psychopy/tests/test_app/test_builder/.DS_Store +0 -0
- psychopy/tests/test_app/test_builder/data/_2021_ 5_03_1206.csv +9 -0
- psychopy/tests/test_app/test_builder/data/_2021_ 5_03_1206.log +177 -0
- psychopy/tests/test_app/test_builder/data/_2021_ 5_03_1206.psydat +0 -0
- psychopy/tests/test_app/test_builder/data/_2021_ 5_03_1206.xlsx +0 -0
- psychopy/tests/test_app/test_builder/data/_2021_ 5_03_1324.csv +9 -0
- psychopy/tests/test_app/test_builder/data/_2021_ 5_03_1324.log +168 -0
- psychopy/tests/test_app/test_builder/data/_2021_ 5_03_1324.psydat +0 -0
- psychopy/tests/test_app/test_builder/data/_2021_ 5_03_1324.xlsx +0 -0
- psychopy/tests/test_data/.DS_Store +0 -0
- psychopy/tests/test_hardware/test_CRS_BitsSharp.py.orig +68 -0
- psychopy/tests/test_tools/test_arraytools.py +112 -0
- psychopy/tests/test_visual/test_image.py.orig +219 -0
- psychopy/tools/arraytools.py +47 -0
- psychopy/tools/versionchooser.py +1 -1
- psychopy/visual/backends/pygletbackend.py +26 -8
- psychopy/visual/basevisual.py.orig +1723 -0
- psychopy/visual/form.py.orig +1181 -0
- psychopy/visual/text.py.orig +752 -0
- psychopy/visual/textbox2/textbox2.py.orig +1315 -0
- psychopy/visual/window.py +13 -5
- psychopy/visual/windowwarp.py.orig +463 -0
- {psychopy-2024.2.1.dist-info → psychopy-2024.2.4.dist-info}/METADATA +9 -9
- {psychopy-2024.2.1.dist-info → psychopy-2024.2.4.dist-info}/RECORD +244 -78
- {psychopy-2024.2.1.dist-info → psychopy-2024.2.4.dist-info}/WHEEL +1 -1
- {psychopy-2024.2.1.dist-info → psychopy-2024.2.4.dist-info}/entry_points.txt +2 -0
- psychopy/app/locale/ar_001/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/cs_CZ/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/da_DK/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/de_DE/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/el_GR/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/en_NZ/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/en_US/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/es_CO/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/es_ES/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/es_US/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/et_EE/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/fa_IR/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/fi_FI/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/fr_FR/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/he_IL/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/hi_IN/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/hu_HU/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/it_IT/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/ja_JP/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/ko_KR/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/ms_MY/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/nl_NL/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/nn_NO/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/pl_PL/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/pt_PT/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/ro_RO/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/ru_RU/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/sv_SE/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/tr_TR/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/zh_CN/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/zh_TW/LC_MESSAGE/messages.mo +0 -0
- psychopy-2024.2.1.dist-info/licenses/AUTHORS.md +0 -138
- /psychopy/{app/locale/ar_001/LC_MESSAGE → demos/builder}/.DS_Store +0 -0
- /psychopy/{app/locale/es_ES/LC_MESSAGE → demos/builder/Experiments}/.DS_Store +0 -0
- /psychopy/{visual → demos/builder/Tools/gammaCalibration/data}/.DS_Store +0 -0
- {psychopy-2024.2.1.dist-info → psychopy-2024.2.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,3932 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
Defines the behavior of Psychopy's Builder view window
|
|
6
|
+
Part of the PsychoPy library
|
|
7
|
+
Copyright (C) 2002-2018 Jonathan Peirce (C) 2019-2022 Open Science Tools Ltd.
|
|
8
|
+
Distributed under the terms of the GNU General Public License (GPL).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os, sys
|
|
12
|
+
import subprocess
|
|
13
|
+
import webbrowser
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import glob
|
|
16
|
+
import copy
|
|
17
|
+
import traceback
|
|
18
|
+
import codecs
|
|
19
|
+
import numpy
|
|
20
|
+
|
|
21
|
+
from pkg_resources import parse_version
|
|
22
|
+
import wx.stc
|
|
23
|
+
from wx.lib import scrolledpanel
|
|
24
|
+
from wx.lib import platebtn
|
|
25
|
+
from wx.html import HtmlWindow
|
|
26
|
+
|
|
27
|
+
from .validators import WarningManager
|
|
28
|
+
from ..pavlovia_ui import sync
|
|
29
|
+
from ..pavlovia_ui.project import ProjectFrame
|
|
30
|
+
from ..pavlovia_ui.search import SearchFrame
|
|
31
|
+
from ..pavlovia_ui.user import UserFrame
|
|
32
|
+
from ...experiment.components import getAllCategories
|
|
33
|
+
from ...experiment.routines import Routine, BaseStandaloneRoutine
|
|
34
|
+
from ...tools.stringtools import prettyname
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
import markdown_it as md
|
|
38
|
+
except ImportError:
|
|
39
|
+
md = None
|
|
40
|
+
import wx.lib.agw.aui as aui # some versions of phoenix
|
|
41
|
+
try:
|
|
42
|
+
from wx.adv import PseudoDC
|
|
43
|
+
except ImportError:
|
|
44
|
+
from wx import PseudoDC
|
|
45
|
+
|
|
46
|
+
if parse_version(wx.__version__) < parse_version('4.0.3'):
|
|
47
|
+
wx.NewIdRef = wx.NewId
|
|
48
|
+
|
|
49
|
+
from psychopy.localization import _translate
|
|
50
|
+
from ... import experiment, prefs
|
|
51
|
+
from .. import dialogs
|
|
52
|
+
from ..themes import icons, colors, handlers
|
|
53
|
+
from ..themes.ui import ThemeSwitcher
|
|
54
|
+
from ..ui import BaseAuiFrame
|
|
55
|
+
from psychopy import logging, data
|
|
56
|
+
from psychopy.tools.filetools import mergeFolder
|
|
57
|
+
from .dialogs import (DlgComponentProperties, DlgExperimentProperties,
|
|
58
|
+
DlgCodeComponentProperties, DlgLoopProperties,
|
|
59
|
+
ParamNotebook, DlgNewRoutine)
|
|
60
|
+
from ..utils import (BasePsychopyToolbar, PsychopyPlateBtn, WindowFrozen,
|
|
61
|
+
FileDropTarget, FrameSwitcher, updateDemosMenu,
|
|
62
|
+
ToggleButtonArray, HoverMixin)
|
|
63
|
+
|
|
64
|
+
from psychopy.experiment import getAllStandaloneRoutines
|
|
65
|
+
from psychopy.app import pavlovia_ui
|
|
66
|
+
from psychopy.projects import pavlovia
|
|
67
|
+
|
|
68
|
+
from psychopy.scripts.psyexpCompile import generateScript
|
|
69
|
+
|
|
70
|
+
# _localized separates internal (functional) from displayed strings
|
|
71
|
+
# long form here allows poedit string discovery
|
|
72
|
+
_localized = {
|
|
73
|
+
'Field': _translate('Field'),
|
|
74
|
+
'Default': _translate('Default'),
|
|
75
|
+
'Favorites': _translate('Favorites'),
|
|
76
|
+
'Stimuli': _translate('Stimuli'),
|
|
77
|
+
'Responses': _translate('Responses'),
|
|
78
|
+
'Custom': _translate('Custom'),
|
|
79
|
+
'I/O': _translate('I/O'),
|
|
80
|
+
'Add to favorites': _translate('Add to favorites'),
|
|
81
|
+
'Remove from favorites': _translate('Remove from favorites'),
|
|
82
|
+
# contextMenuLabels
|
|
83
|
+
'edit': _translate('edit'),
|
|
84
|
+
'remove': _translate('remove'),
|
|
85
|
+
'copy': _translate('copy'),
|
|
86
|
+
'paste above': _translate('paste above'),
|
|
87
|
+
'paste below': _translate('paste below'),
|
|
88
|
+
'move to top': _translate('move to top'),
|
|
89
|
+
'move up': _translate('move up'),
|
|
90
|
+
'move down': _translate('move down'),
|
|
91
|
+
'move to bottom': _translate('move to bottom')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Components which are always hidden
|
|
96
|
+
alwaysHidden = [
|
|
97
|
+
'SettingsComponent', 'UnknownComponent', 'UnknownRoutine', 'UnknownStandaloneRoutine'
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TemplateManager(dict):
|
|
102
|
+
mainFolder = Path(prefs.paths['resources']).absolute() / 'routine_templates'
|
|
103
|
+
userFolder = Path(prefs.paths['userPrefsDir']).absolute() / 'routine_templates'
|
|
104
|
+
experimentFiles = {}
|
|
105
|
+
|
|
106
|
+
def __init__(self):
|
|
107
|
+
dict.__init__(self)
|
|
108
|
+
self.updateTemplates()
|
|
109
|
+
|
|
110
|
+
def updateTemplates(self, ):
|
|
111
|
+
"""Search and import templates in the standard files"""
|
|
112
|
+
for folder in [TemplateManager.mainFolder, TemplateManager.userFolder]:
|
|
113
|
+
categs = folder.glob("*.psyexp")
|
|
114
|
+
for filePath in categs:
|
|
115
|
+
thisExp = experiment.Experiment()
|
|
116
|
+
thisExp.loadFromXML(filePath)
|
|
117
|
+
categName = filePath.stem
|
|
118
|
+
self[categName]={}
|
|
119
|
+
for routineName in thisExp.routines:
|
|
120
|
+
self[categName][routineName] = copy.copy(thisExp.routines[routineName])
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class BuilderFrame(BaseAuiFrame, handlers.ThemeMixin):
|
|
124
|
+
"""Defines construction of the Psychopy Builder Frame"""
|
|
125
|
+
|
|
126
|
+
routineTemplates = TemplateManager()
|
|
127
|
+
|
|
128
|
+
def __init__(self, parent, id=-1, title='PsychoPy (Experiment Builder)',
|
|
129
|
+
pos=wx.DefaultPosition, fileName=None, frameData=None,
|
|
130
|
+
style=wx.DEFAULT_FRAME_STYLE, app=None):
|
|
131
|
+
|
|
132
|
+
if (fileName is not None) and (type(fileName) == bytes):
|
|
133
|
+
fileName = fileName.decode(sys.getfilesystemencoding())
|
|
134
|
+
|
|
135
|
+
self.app = app
|
|
136
|
+
self.dpi = self.app.dpi
|
|
137
|
+
# things the user doesn't set like winsize etc:
|
|
138
|
+
self.appData = self.app.prefs.appData['builder']
|
|
139
|
+
# things about the builder that the user can set:
|
|
140
|
+
self.prefs = self.app.prefs.builder
|
|
141
|
+
self.appPrefs = self.app.prefs.app
|
|
142
|
+
self.paths = self.app.prefs.paths
|
|
143
|
+
self.frameType = 'builder'
|
|
144
|
+
self.filename = fileName
|
|
145
|
+
self.htmlPath = None
|
|
146
|
+
self.session = pavlovia.getCurrentSession()
|
|
147
|
+
self.scriptProcess = None
|
|
148
|
+
self.stdoutBuffer = None
|
|
149
|
+
self.readmeFrame = None
|
|
150
|
+
self.generateScript = generateScript
|
|
151
|
+
|
|
152
|
+
# default window title
|
|
153
|
+
self.winTitle = 'PsychoPy Builder (v{})'.format(self.app.version)
|
|
154
|
+
|
|
155
|
+
if fileName in self.appData['frames']:
|
|
156
|
+
self.frameData = self.appData['frames'][fileName]
|
|
157
|
+
else: # work out a new frame size/location
|
|
158
|
+
dispW, dispH = self.app.getPrimaryDisplaySize()
|
|
159
|
+
default = self.appData['defaultFrame']
|
|
160
|
+
default['winW'] = int(dispW * 0.75)
|
|
161
|
+
default['winH'] = int(dispH * 0.75)
|
|
162
|
+
if default['winX'] + default['winW'] > dispW:
|
|
163
|
+
default['winX'] = 5
|
|
164
|
+
if default['winY'] + default['winH'] > dispH:
|
|
165
|
+
default['winY'] = 5
|
|
166
|
+
self.frameData = dict(self.appData['defaultFrame']) # copy
|
|
167
|
+
# increment default for next frame
|
|
168
|
+
default['winX'] += 10
|
|
169
|
+
default['winY'] += 10
|
|
170
|
+
|
|
171
|
+
# we didn't have the key or the win was minimized / invalid
|
|
172
|
+
if self.frameData['winH'] == 0 or self.frameData['winW'] == 0:
|
|
173
|
+
self.frameData['winX'], self.frameData['winY'] = (0, 0)
|
|
174
|
+
if self.frameData['winY'] < 20:
|
|
175
|
+
self.frameData['winY'] = 20
|
|
176
|
+
|
|
177
|
+
BaseAuiFrame.__init__(self, parent=parent, id=id, title=title,
|
|
178
|
+
pos=(int(self.frameData['winX']),
|
|
179
|
+
int(self.frameData['winY'])),
|
|
180
|
+
size=(int(self.frameData['winW']),
|
|
181
|
+
int(self.frameData['winH'])),
|
|
182
|
+
style=style)
|
|
183
|
+
|
|
184
|
+
# detect retina displays (then don't use double-buffering)
|
|
185
|
+
self.isRetina = \
|
|
186
|
+
self.GetContentScaleFactor() != 1 and wx.Platform == '__WXMAC__'
|
|
187
|
+
|
|
188
|
+
# create icon
|
|
189
|
+
if sys.platform != 'darwin':
|
|
190
|
+
# doesn't work on darwin and not necessary: handled by app bundle
|
|
191
|
+
iconFile = os.path.join(self.paths['resources'], 'builder.ico')
|
|
192
|
+
if os.path.isfile(iconFile):
|
|
193
|
+
self.SetIcon(wx.Icon(iconFile, wx.BITMAP_TYPE_ICO))
|
|
194
|
+
|
|
195
|
+
# create our panels
|
|
196
|
+
self.flowPanel = FlowPanel(frame=self)
|
|
197
|
+
self.routinePanel = RoutinesNotebook(self)
|
|
198
|
+
self.componentButtons = ComponentsPanel(self)
|
|
199
|
+
# menus and toolbars
|
|
200
|
+
self.toolbar = BuilderToolbar(frame=self)
|
|
201
|
+
self.SetToolBar(self.toolbar)
|
|
202
|
+
self.toolbar.Realize()
|
|
203
|
+
self.makeMenus()
|
|
204
|
+
self.CreateStatusBar()
|
|
205
|
+
self.SetStatusText("")
|
|
206
|
+
|
|
207
|
+
# setup universal shortcuts
|
|
208
|
+
accelTable = self.app.makeAccelTable()
|
|
209
|
+
self.SetAcceleratorTable(accelTable)
|
|
210
|
+
|
|
211
|
+
# setup a default exp
|
|
212
|
+
if fileName is not None and os.path.isfile(fileName):
|
|
213
|
+
self.fileOpen(filename=fileName, closeCurrent=False)
|
|
214
|
+
else:
|
|
215
|
+
self.lastSavedCopy = None
|
|
216
|
+
# don't try to close before opening
|
|
217
|
+
self.fileNew(closeCurrent=False)
|
|
218
|
+
|
|
219
|
+
self.updateReadme() # check/create frame as needed
|
|
220
|
+
|
|
221
|
+
# control the panes using aui manager
|
|
222
|
+
self._mgr = self.getAuiManager()
|
|
223
|
+
|
|
224
|
+
#self._mgr.SetArtProvider(PsychopyDockArt())
|
|
225
|
+
#self._art = self._mgr.GetArtProvider()
|
|
226
|
+
# Create panels
|
|
227
|
+
self._mgr.AddPane(self.routinePanel,
|
|
228
|
+
aui.AuiPaneInfo().
|
|
229
|
+
Name("Routines").Caption("Routines").CaptionVisible(True).
|
|
230
|
+
Floatable(False).
|
|
231
|
+
Movable(False).
|
|
232
|
+
CloseButton(False).MaximizeButton(True).PaneBorder(False).
|
|
233
|
+
Center()) # 'center panes' expand
|
|
234
|
+
rtPane = self._mgr.GetPane('Routines')
|
|
235
|
+
self._mgr.AddPane(self.componentButtons,
|
|
236
|
+
aui.AuiPaneInfo().
|
|
237
|
+
Name("Components").Caption("Components").CaptionVisible(True).
|
|
238
|
+
Floatable(False).
|
|
239
|
+
RightDockable(True).LeftDockable(True).
|
|
240
|
+
CloseButton(False).PaneBorder(False))
|
|
241
|
+
compPane = self._mgr.GetPane('Components')
|
|
242
|
+
self._mgr.AddPane(self.flowPanel,
|
|
243
|
+
aui.AuiPaneInfo().
|
|
244
|
+
Name("Flow").Caption("Flow").CaptionVisible(True).
|
|
245
|
+
BestSize((8 * self.dpi, 2 * self.dpi)).
|
|
246
|
+
Floatable(False).
|
|
247
|
+
RightDockable(True).LeftDockable(True).
|
|
248
|
+
CloseButton(False).PaneBorder(False))
|
|
249
|
+
flowPane = self._mgr.GetPane('Flow')
|
|
250
|
+
self.layoutPanes()
|
|
251
|
+
rtPane.CaptionVisible(True)
|
|
252
|
+
# tell the manager to 'commit' all the changes just made
|
|
253
|
+
self._mgr.Update()
|
|
254
|
+
# self.SetSizer(self.mainSizer) # not necessary for aui type controls
|
|
255
|
+
if self.frameData['auiPerspective']:
|
|
256
|
+
self._mgr.LoadPerspective(self.frameData['auiPerspective'])
|
|
257
|
+
self.SetMinSize(wx.Size(600, 400)) # min size for the whole window
|
|
258
|
+
self.SetSize(
|
|
259
|
+
(int(self.frameData['winW']), int(self.frameData['winH'])))
|
|
260
|
+
self.SendSizeEvent()
|
|
261
|
+
self._mgr.Update()
|
|
262
|
+
|
|
263
|
+
# self.SetAutoLayout(True)
|
|
264
|
+
self.Bind(wx.EVT_CLOSE, self.closeFrame)
|
|
265
|
+
self.Bind(wx.EVT_SIZE, self.onResize)
|
|
266
|
+
|
|
267
|
+
self.app.trackFrame(self)
|
|
268
|
+
self.SetDropTarget(FileDropTarget(targetFrame=self))
|
|
269
|
+
|
|
270
|
+
self.theme = colors.theme
|
|
271
|
+
|
|
272
|
+
# Synonymise Aui manager for use with theme mixin
|
|
273
|
+
def GetAuiManager(self):
|
|
274
|
+
return self._mgr
|
|
275
|
+
|
|
276
|
+
def makeMenus(self):
|
|
277
|
+
"""
|
|
278
|
+
Produces Menus for the Builder Frame
|
|
279
|
+
"""
|
|
280
|
+
|
|
281
|
+
# ---Menus---#000000#FFFFFF-------------------------------------------
|
|
282
|
+
menuBar = wx.MenuBar()
|
|
283
|
+
# ---_file---#000000#FFFFFF-------------------------------------------
|
|
284
|
+
self.fileMenu = wx.Menu()
|
|
285
|
+
menuBar.Append(self.fileMenu, _translate('&File'))
|
|
286
|
+
|
|
287
|
+
# create a file history submenu
|
|
288
|
+
self.fileHistoryMaxFiles = 10
|
|
289
|
+
self.fileHistory = wx.FileHistory(maxFiles=self.fileHistoryMaxFiles)
|
|
290
|
+
self.recentFilesMenu = wx.Menu()
|
|
291
|
+
self.fileHistory.UseMenu(self.recentFilesMenu)
|
|
292
|
+
for filename in self.appData['fileHistory']:
|
|
293
|
+
if os.path.exists(filename):
|
|
294
|
+
self.fileHistory.AddFileToHistory(filename)
|
|
295
|
+
self.Bind(wx.EVT_MENU_RANGE, self.OnFileHistory,
|
|
296
|
+
id=wx.ID_FILE1, id2=wx.ID_FILE9)
|
|
297
|
+
keys = self.app.keys
|
|
298
|
+
menu = self.fileMenu
|
|
299
|
+
menu.Append(
|
|
300
|
+
wx.ID_NEW,
|
|
301
|
+
_translate("&New\t%s") % keys['new'])
|
|
302
|
+
menu.Append(
|
|
303
|
+
wx.ID_OPEN,
|
|
304
|
+
_translate("&Open...\t%s") % keys['open'])
|
|
305
|
+
menu.AppendSubMenu(
|
|
306
|
+
self.recentFilesMenu,
|
|
307
|
+
_translate("Open &Recent"))
|
|
308
|
+
menu.Append(
|
|
309
|
+
wx.ID_SAVE,
|
|
310
|
+
_translate("&Save\t%s") % keys['save'],
|
|
311
|
+
_translate("Save current experiment file"))
|
|
312
|
+
menu.Append(
|
|
313
|
+
wx.ID_SAVEAS,
|
|
314
|
+
_translate("Save &as...\t%s") % keys['saveAs'],
|
|
315
|
+
_translate("Save current experiment file as..."))
|
|
316
|
+
exportMenu = menu.Append(
|
|
317
|
+
-1,
|
|
318
|
+
_translate("Export HTML...\t%s") % keys['exportHTML'],
|
|
319
|
+
_translate("Export experiment to html/javascript file"))
|
|
320
|
+
menu.Append(
|
|
321
|
+
wx.ID_CLOSE,
|
|
322
|
+
_translate("&Close file\t%s") % keys['close'],
|
|
323
|
+
_translate("Close current experiment"))
|
|
324
|
+
self.Bind(wx.EVT_MENU, self.app.newBuilderFrame, id=wx.ID_NEW)
|
|
325
|
+
self.Bind(wx.EVT_MENU, self.fileExport, id=exportMenu.GetId())
|
|
326
|
+
self.Bind(wx.EVT_MENU, self.fileSave, id=wx.ID_SAVE)
|
|
327
|
+
menu.Enable(wx.ID_SAVE, False)
|
|
328
|
+
self.Bind(wx.EVT_MENU, self.fileSaveAs, id=wx.ID_SAVEAS)
|
|
329
|
+
self.Bind(wx.EVT_MENU, self.fileOpen, id=wx.ID_OPEN)
|
|
330
|
+
self.Bind(wx.EVT_MENU, self.commandCloseFrame, id=wx.ID_CLOSE)
|
|
331
|
+
self.fileMenu.AppendSeparator()
|
|
332
|
+
item = menu.Append(
|
|
333
|
+
wx.ID_PREFERENCES,
|
|
334
|
+
_translate("&Preferences\t%s") % keys['preferences'])
|
|
335
|
+
self.Bind(wx.EVT_MENU, self.app.showPrefs, item)
|
|
336
|
+
item = menu.Append(
|
|
337
|
+
wx.ID_ANY, _translate("Reset preferences...")
|
|
338
|
+
)
|
|
339
|
+
self.Bind(wx.EVT_MENU, self.resetPrefs, item)
|
|
340
|
+
# item = menu.Append(wx.NewId(), "Plug&ins")
|
|
341
|
+
# self.Bind(wx.EVT_MENU, self.pluginManager, item)
|
|
342
|
+
menu.AppendSeparator()
|
|
343
|
+
msg = _translate("Close PsychoPy Builder")
|
|
344
|
+
item = menu.Append(wx.ID_ANY, msg)
|
|
345
|
+
self.Bind(wx.EVT_MENU, self.closeFrame, id=item.GetId())
|
|
346
|
+
self.fileMenu.AppendSeparator()
|
|
347
|
+
self.fileMenu.Append(wx.ID_EXIT,
|
|
348
|
+
_translate("&Quit\t%s") % keys['quit'],
|
|
349
|
+
_translate("Terminate the program"))
|
|
350
|
+
self.Bind(wx.EVT_MENU, self.quit, id=wx.ID_EXIT)
|
|
351
|
+
|
|
352
|
+
# ------------- edit ------------------------------------
|
|
353
|
+
self.editMenu = wx.Menu()
|
|
354
|
+
menuBar.Append(self.editMenu, _translate('&Edit'))
|
|
355
|
+
menu = self.editMenu
|
|
356
|
+
self._undoLabel = menu.Append(wx.ID_UNDO,
|
|
357
|
+
_translate("Undo\t%s") % keys['undo'],
|
|
358
|
+
_translate("Undo last action"),
|
|
359
|
+
wx.ITEM_NORMAL)
|
|
360
|
+
self.Bind(wx.EVT_MENU, self.undo, id=wx.ID_UNDO)
|
|
361
|
+
self._redoLabel = menu.Append(wx.ID_REDO,
|
|
362
|
+
_translate("Redo\t%s") % keys['redo'],
|
|
363
|
+
_translate("Redo last action"),
|
|
364
|
+
wx.ITEM_NORMAL)
|
|
365
|
+
self.Bind(wx.EVT_MENU, self.redo, id=wx.ID_REDO)
|
|
366
|
+
menu.Append(wx.ID_PASTE, _translate("&Paste\t%s") % keys['paste'])
|
|
367
|
+
self.Bind(wx.EVT_MENU, self.paste, id=wx.ID_PASTE)
|
|
368
|
+
|
|
369
|
+
# ---_view---#000000#FFFFFF-------------------------------------------
|
|
370
|
+
self.viewMenu = wx.Menu()
|
|
371
|
+
menuBar.Append(self.viewMenu, _translate('&View'))
|
|
372
|
+
menu = self.viewMenu
|
|
373
|
+
|
|
374
|
+
# item = menu.Append(wx.ID_ANY,
|
|
375
|
+
# _translate("Open Coder view"),
|
|
376
|
+
# _translate("Open a new Coder view"))
|
|
377
|
+
# self.Bind(wx.EVT_MENU, self.app.showCoder, item)
|
|
378
|
+
#
|
|
379
|
+
# item = menu.Append(wx.ID_ANY,
|
|
380
|
+
# _translate("Open Runner view"),
|
|
381
|
+
# _translate("Open the Runner view"))
|
|
382
|
+
# self.Bind(wx.EVT_MENU, self.app.showRunner, item)
|
|
383
|
+
# menu.AppendSeparator()
|
|
384
|
+
|
|
385
|
+
item = menu.Append(wx.ID_ANY,
|
|
386
|
+
_translate("&Toggle readme\t%s") % self.app.keys[
|
|
387
|
+
'toggleReadme'],
|
|
388
|
+
_translate("Toggle Readme"))
|
|
389
|
+
self.Bind(wx.EVT_MENU, self.toggleReadme, item)
|
|
390
|
+
item = menu.Append(wx.ID_ANY,
|
|
391
|
+
_translate("&Flow Larger\t%s") % self.app.keys[
|
|
392
|
+
'largerFlow'],
|
|
393
|
+
_translate("Larger flow items"))
|
|
394
|
+
self.Bind(wx.EVT_MENU, self.flowPanel.increaseSize, item)
|
|
395
|
+
item = menu.Append(wx.ID_ANY,
|
|
396
|
+
_translate("&Flow Smaller\t%s") % self.app.keys[
|
|
397
|
+
'smallerFlow'],
|
|
398
|
+
_translate("Smaller flow items"))
|
|
399
|
+
self.Bind(wx.EVT_MENU, self.flowPanel.decreaseSize, item)
|
|
400
|
+
item = menu.Append(wx.ID_ANY,
|
|
401
|
+
_translate("&Routine Larger\t%s") % keys[
|
|
402
|
+
'largerRoutine'],
|
|
403
|
+
_translate("Larger routine items"))
|
|
404
|
+
self.Bind(wx.EVT_MENU, self.routinePanel.increaseSize, item)
|
|
405
|
+
item = menu.Append(wx.ID_ANY,
|
|
406
|
+
_translate("&Routine Smaller\t%s") % keys[
|
|
407
|
+
'smallerRoutine'],
|
|
408
|
+
_translate("Smaller routine items"))
|
|
409
|
+
self.Bind(wx.EVT_MENU, self.routinePanel.decreaseSize, item)
|
|
410
|
+
menu.AppendSeparator()
|
|
411
|
+
# Add Theme Switcher
|
|
412
|
+
self.themesMenu = ThemeSwitcher(app=self.app)
|
|
413
|
+
menu.AppendSubMenu(self.themesMenu,
|
|
414
|
+
_translate("Themes"))
|
|
415
|
+
|
|
416
|
+
# ---_tools ---#000000#FFFFFF-----------------------------------------
|
|
417
|
+
self.toolsMenu = wx.Menu()
|
|
418
|
+
menuBar.Append(self.toolsMenu, _translate('&Tools'))
|
|
419
|
+
menu = self.toolsMenu
|
|
420
|
+
item = menu.Append(wx.ID_ANY,
|
|
421
|
+
_translate("Monitor Center"),
|
|
422
|
+
_translate("To set information about your monitor"))
|
|
423
|
+
self.Bind(wx.EVT_MENU, self.app.openMonitorCenter, item)
|
|
424
|
+
|
|
425
|
+
item = menu.Append(wx.ID_ANY,
|
|
426
|
+
_translate("Compile\t%s") % keys['compileScript'],
|
|
427
|
+
_translate("Compile the exp to a script"))
|
|
428
|
+
self.Bind(wx.EVT_MENU, self.compileScript, item)
|
|
429
|
+
self.bldrRun = menu.Append(wx.ID_ANY,
|
|
430
|
+
_translate("Run\t%s") % keys['runScript'],
|
|
431
|
+
_translate("Run the current script"))
|
|
432
|
+
self.Bind(wx.EVT_MENU, self.runFile, self.bldrRun, id=self.bldrRun)
|
|
433
|
+
item = menu.Append(wx.ID_ANY,
|
|
434
|
+
_translate("Send to runner\t%s") % keys['runnerScript'],
|
|
435
|
+
_translate("Send current script to runner"))
|
|
436
|
+
self.Bind(wx.EVT_MENU, self.runFile, item)
|
|
437
|
+
menu.AppendSeparator()
|
|
438
|
+
item = menu.Append(wx.ID_ANY,
|
|
439
|
+
_translate("PsychoPy updates..."),
|
|
440
|
+
_translate("Update PsychoPy to the latest, or a "
|
|
441
|
+
"specific, version"))
|
|
442
|
+
self.Bind(wx.EVT_MENU, self.app.openUpdater, item)
|
|
443
|
+
if hasattr(self.app, 'benchmarkWizard'):
|
|
444
|
+
item = menu.Append(wx.ID_ANY,
|
|
445
|
+
_translate("Benchmark wizard"),
|
|
446
|
+
_translate("Check software & hardware, generate "
|
|
447
|
+
"report"))
|
|
448
|
+
self.Bind(wx.EVT_MENU, self.app.benchmarkWizard, item)
|
|
449
|
+
|
|
450
|
+
# ---_experiment---#000000#FFFFFF-------------------------------------
|
|
451
|
+
self.expMenu = wx.Menu()
|
|
452
|
+
menuBar.Append(self.expMenu, _translate('&Experiment'))
|
|
453
|
+
menu = self.expMenu
|
|
454
|
+
item = menu.Append(wx.ID_ANY,
|
|
455
|
+
_translate("&New Routine\t%s") % keys['newRoutine'],
|
|
456
|
+
_translate("Create a new routine (e.g. the trial "
|
|
457
|
+
"definition)"))
|
|
458
|
+
self.Bind(wx.EVT_MENU, self.addRoutine, item)
|
|
459
|
+
item = menu.Append(wx.ID_ANY,
|
|
460
|
+
_translate("&Copy Routine\t%s") % keys[
|
|
461
|
+
'copyRoutine'],
|
|
462
|
+
_translate("Copy the current routine so it can be "
|
|
463
|
+
"used in another exp"),
|
|
464
|
+
wx.ITEM_NORMAL)
|
|
465
|
+
self.Bind(wx.EVT_MENU, self.onCopyRoutine, item)
|
|
466
|
+
item = menu.Append(wx.ID_ANY,
|
|
467
|
+
_translate("&Paste Routine\t%s") % keys[
|
|
468
|
+
'pasteRoutine'],
|
|
469
|
+
_translate("Paste the Routine into the current "
|
|
470
|
+
"experiment"),
|
|
471
|
+
wx.ITEM_NORMAL)
|
|
472
|
+
self.Bind(wx.EVT_MENU, self.onPasteRoutine, item)
|
|
473
|
+
item = menu.Append(wx.ID_ANY,
|
|
474
|
+
_translate("&Rename Routine\t%s") % keys[
|
|
475
|
+
'renameRoutine'],
|
|
476
|
+
_translate("Change the name of this routine"))
|
|
477
|
+
self.Bind(wx.EVT_MENU, self.renameRoutine, item)
|
|
478
|
+
item = menu.Append(wx.ID_ANY,
|
|
479
|
+
_translate("Paste Component\t%s") % keys[
|
|
480
|
+
'pasteCompon'],
|
|
481
|
+
_translate(
|
|
482
|
+
"Paste the Component at bottom of the current "
|
|
483
|
+
"Routine"),
|
|
484
|
+
wx.ITEM_NORMAL)
|
|
485
|
+
self.Bind(wx.EVT_MENU, self.onPasteCompon, item)
|
|
486
|
+
menu.AppendSeparator()
|
|
487
|
+
|
|
488
|
+
item = menu.Append(wx.ID_ANY,
|
|
489
|
+
_translate("Insert Routine in Flow"),
|
|
490
|
+
_translate(
|
|
491
|
+
"Select one of your routines to be inserted"
|
|
492
|
+
" into the experiment flow"))
|
|
493
|
+
self.Bind(wx.EVT_MENU, self.flowPanel.onInsertRoutine, item)
|
|
494
|
+
item = menu.Append(wx.ID_ANY,
|
|
495
|
+
_translate("Insert Loop in Flow"),
|
|
496
|
+
_translate("Create a new loop in your flow window"))
|
|
497
|
+
self.Bind(wx.EVT_MENU, self.flowPanel.insertLoop, item)
|
|
498
|
+
menu.AppendSeparator()
|
|
499
|
+
|
|
500
|
+
item = menu.Append(wx.ID_ANY,
|
|
501
|
+
_translate("README..."),
|
|
502
|
+
_translate("Add or edit the text shown when your experiment is opened"))
|
|
503
|
+
self.Bind(wx.EVT_MENU, self.editREADME, item)
|
|
504
|
+
|
|
505
|
+
# ---_demos---#000000#FFFFFF------------------------------------------
|
|
506
|
+
# for demos we need a dict where the event ID will correspond to a
|
|
507
|
+
# filename
|
|
508
|
+
|
|
509
|
+
self.demosMenu = wx.Menu()
|
|
510
|
+
# unpack demos option
|
|
511
|
+
menu = self.demosMenu
|
|
512
|
+
item = menu.Append(wx.ID_ANY,
|
|
513
|
+
_translate("&Unpack Demos..."),
|
|
514
|
+
_translate(
|
|
515
|
+
"Unpack demos to a writable location (so that"
|
|
516
|
+
" they can be run)"))
|
|
517
|
+
self.Bind(wx.EVT_MENU, self.demosUnpack, item)
|
|
518
|
+
item = menu.Append(wx.ID_ANY,
|
|
519
|
+
_translate("Browse on Pavlovia"),
|
|
520
|
+
_translate("Get more demos from the online demos "
|
|
521
|
+
"repository on Pavlovia")
|
|
522
|
+
)
|
|
523
|
+
self.Bind(wx.EVT_MENU, self.openPavloviaDemos, item)
|
|
524
|
+
item = menu.Append(wx.ID_ANY,
|
|
525
|
+
_translate("Open demos folder"),
|
|
526
|
+
_translate("Open the local folder where demos are stored")
|
|
527
|
+
)
|
|
528
|
+
self.Bind(wx.EVT_MENU, self.openLocalDemos, item)
|
|
529
|
+
menu.AppendSeparator()
|
|
530
|
+
# add any demos that are found in the prefs['demosUnpacked'] folder
|
|
531
|
+
updateDemosMenu(self, self.demosMenu, self.prefs['unpackedDemosDir'], ext=".psyexp")
|
|
532
|
+
menuBar.Append(self.demosMenu, _translate('&Demos'))
|
|
533
|
+
|
|
534
|
+
# ---_onlineStudies---#000000#FFFFFF-------------------------------------------
|
|
535
|
+
self.pavloviaMenu = pavlovia_ui.menu.PavloviaMenu(parent=self)
|
|
536
|
+
menuBar.Append(self.pavloviaMenu, _translate("Pavlovia.org"))
|
|
537
|
+
|
|
538
|
+
# ---_window---#000000#FFFFFF-----------------------------------------
|
|
539
|
+
self.windowMenu = FrameSwitcher(self)
|
|
540
|
+
menuBar.Append(self.windowMenu,
|
|
541
|
+
_translate("Window"))
|
|
542
|
+
|
|
543
|
+
# ---_help---#000000#FFFFFF-------------------------------------------
|
|
544
|
+
self.helpMenu = wx.Menu()
|
|
545
|
+
menuBar.Append(self.helpMenu, _translate('&Help'))
|
|
546
|
+
menu = self.helpMenu
|
|
547
|
+
|
|
548
|
+
item = menu.Append(wx.ID_ANY,
|
|
549
|
+
_translate("&PsychoPy Homepage"),
|
|
550
|
+
_translate("Go to the PsychoPy homepage"))
|
|
551
|
+
self.Bind(wx.EVT_MENU, self.app.followLink, item)
|
|
552
|
+
self.app.urls[item.GetId()] = self.app.urls['psychopyHome']
|
|
553
|
+
item = menu.Append(wx.ID_ANY,
|
|
554
|
+
_translate("&PsychoPy Builder Help"),
|
|
555
|
+
_translate(
|
|
556
|
+
"Go to the online documentation for PsychoPy"
|
|
557
|
+
" Builder"))
|
|
558
|
+
self.Bind(wx.EVT_MENU, self.app.followLink, item)
|
|
559
|
+
self.app.urls[item.GetId()] = self.app.urls['builderHelp']
|
|
560
|
+
|
|
561
|
+
menu.AppendSeparator()
|
|
562
|
+
item = menu.Append(wx.ID_ANY,
|
|
563
|
+
_translate("&System Info..."),
|
|
564
|
+
_translate("Get system information."))
|
|
565
|
+
self.Bind(wx.EVT_MENU, self.app.showSystemInfo, id=item.GetId())
|
|
566
|
+
|
|
567
|
+
menu.AppendSeparator()
|
|
568
|
+
menu.Append(wx.ID_ABOUT, _translate(
|
|
569
|
+
"&About..."), _translate("About PsychoPy"))
|
|
570
|
+
self.Bind(wx.EVT_MENU, self.app.showAbout, id=wx.ID_ABOUT)
|
|
571
|
+
item = menu.Append(wx.ID_ANY,
|
|
572
|
+
_translate("&News..."),
|
|
573
|
+
_translate("News"))
|
|
574
|
+
self.Bind(wx.EVT_MENU, self.app.showNews, id=item.GetId())
|
|
575
|
+
|
|
576
|
+
self.SetMenuBar(menuBar)
|
|
577
|
+
|
|
578
|
+
def commandCloseFrame(self, event):
|
|
579
|
+
"""Defines Builder Frame Closing Event"""
|
|
580
|
+
self.Close()
|
|
581
|
+
|
|
582
|
+
def closeFrame(self, event=None, checkSave=True):
|
|
583
|
+
"""Defines Frame closing behavior, such as checking for file
|
|
584
|
+
saving"""
|
|
585
|
+
# close file first (check for save) but no need to update view
|
|
586
|
+
okToClose = self.fileClose(updateViews=False, checkSave=checkSave)
|
|
587
|
+
|
|
588
|
+
if not okToClose:
|
|
589
|
+
if hasattr(event, 'Veto'):
|
|
590
|
+
event.Veto()
|
|
591
|
+
return
|
|
592
|
+
else:
|
|
593
|
+
# as of wx3.0 the AUI manager needs to be uninitialised explicitly
|
|
594
|
+
self._mgr.UnInit()
|
|
595
|
+
# is it the last frame?
|
|
596
|
+
lastFrame = len(self.app.getAllFrames()) == 1
|
|
597
|
+
quitting = self.app.quitting
|
|
598
|
+
if lastFrame and sys.platform != 'darwin' and not quitting:
|
|
599
|
+
self.app.quit(event)
|
|
600
|
+
else:
|
|
601
|
+
self.app.forgetFrame(self)
|
|
602
|
+
self.Destroy() # required
|
|
603
|
+
|
|
604
|
+
# Show Runner if hidden
|
|
605
|
+
if self.app.runner is not None:
|
|
606
|
+
self.app.showRunner()
|
|
607
|
+
self.app.updateWindowMenu()
|
|
608
|
+
|
|
609
|
+
def quit(self, event=None):
|
|
610
|
+
"""quit the app
|
|
611
|
+
"""
|
|
612
|
+
self.app.quit(event)
|
|
613
|
+
|
|
614
|
+
def onResize(self, event):
|
|
615
|
+
"""Called when the frame is resized."""
|
|
616
|
+
self.componentButtons.Refresh()
|
|
617
|
+
self.flowPanel.Refresh()
|
|
618
|
+
event.Skip()
|
|
619
|
+
|
|
620
|
+
@property
|
|
621
|
+
def filename(self):
|
|
622
|
+
"""Name of the currently open file"""
|
|
623
|
+
return self._filename
|
|
624
|
+
|
|
625
|
+
@filename.setter
|
|
626
|
+
def filename(self, value):
|
|
627
|
+
self._filename = value
|
|
628
|
+
# Skip if there's no toolbar
|
|
629
|
+
if not hasattr(self, "toolbar"):
|
|
630
|
+
return
|
|
631
|
+
# Enable/disable compile buttons
|
|
632
|
+
if 'compile_py' in self.toolbar.buttons:
|
|
633
|
+
self.toolbar.EnableTool(
|
|
634
|
+
self.toolbar.buttons['compile_py'].GetId(),
|
|
635
|
+
Path(value).is_file()
|
|
636
|
+
)
|
|
637
|
+
if 'compile_js' in self.toolbar.buttons:
|
|
638
|
+
self.toolbar.EnableTool(
|
|
639
|
+
self.toolbar.buttons['compile_js'].GetId(),
|
|
640
|
+
Path(value).is_file()
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
def fileNew(self, event=None, closeCurrent=True):
|
|
644
|
+
"""Create a default experiment (maybe an empty one instead)
|
|
645
|
+
"""
|
|
646
|
+
# Note: this is NOT the method called by the File>New menu item.
|
|
647
|
+
# That calls app.newBuilderFrame() instead
|
|
648
|
+
if closeCurrent: # if no exp exists then don't try to close it
|
|
649
|
+
if not self.fileClose(updateViews=False):
|
|
650
|
+
# close the existing (and prompt for save if necess)
|
|
651
|
+
return False
|
|
652
|
+
self.filename = 'untitled.psyexp'
|
|
653
|
+
self.exp = experiment.Experiment(prefs=self.app.prefs)
|
|
654
|
+
defaultName = 'trial'
|
|
655
|
+
# create the trial routine as an example
|
|
656
|
+
self.exp.addRoutine(defaultName)
|
|
657
|
+
self.exp.flow.addRoutine(
|
|
658
|
+
self.exp.routines[defaultName], pos=1) # add it to flow
|
|
659
|
+
# add it to user's namespace
|
|
660
|
+
self.exp.namespace.add(defaultName, self.exp.namespace.user)
|
|
661
|
+
routine = self.exp.routines[defaultName]
|
|
662
|
+
## add an ISI component by default
|
|
663
|
+
# components = self.componentButtons.components
|
|
664
|
+
# Static = components['StaticComponent']
|
|
665
|
+
# ISI = Static(self.exp, parentName=defaultName, name='ISI',
|
|
666
|
+
# startType='time (s)', startVal=0.0,
|
|
667
|
+
# stopType='duration (s)', stopVal=0.5)
|
|
668
|
+
# routine.addComponent(ISI)
|
|
669
|
+
self.resetUndoStack()
|
|
670
|
+
self.setIsModified(False)
|
|
671
|
+
self.updateAllViews()
|
|
672
|
+
self.app.updateWindowMenu()
|
|
673
|
+
|
|
674
|
+
def fileOpen(self, event=None, filename=None, closeCurrent=True):
|
|
675
|
+
"""Open a FileDialog, then load the file if possible.
|
|
676
|
+
"""
|
|
677
|
+
if filename is None:
|
|
678
|
+
# Set wildcard
|
|
679
|
+
if sys.platform != 'darwin':
|
|
680
|
+
wildcard = _translate("PsychoPy experiments (*.psyexp)|*.psyexp|Any file (*.*)|*.*")
|
|
681
|
+
else:
|
|
682
|
+
wildcard = _translate("PsychoPy experiments (*.psyexp)|*.psyexp|Any file (*.*)|*")
|
|
683
|
+
# get path of current file (empty if current file is '')
|
|
684
|
+
if self.filename:
|
|
685
|
+
initPath = str(Path(self.filename).parent)
|
|
686
|
+
else:
|
|
687
|
+
initPath = ""
|
|
688
|
+
# Open dlg
|
|
689
|
+
dlg = wx.FileDialog(self, message=_translate("Open file ..."),
|
|
690
|
+
defaultDir=initPath,
|
|
691
|
+
style=wx.FD_OPEN,
|
|
692
|
+
wildcard=wildcard)
|
|
693
|
+
if dlg.ShowModal() != wx.ID_OK:
|
|
694
|
+
return 0
|
|
695
|
+
filename = dlg.GetPath()
|
|
696
|
+
|
|
697
|
+
# did user try to open a script in Builder?
|
|
698
|
+
if filename.endswith('.py'):
|
|
699
|
+
self.app.showCoder() # ensures that a coder window exists
|
|
700
|
+
self.app.coder.setCurrentDoc(filename)
|
|
701
|
+
self.app.coder.setFileModified(False)
|
|
702
|
+
return
|
|
703
|
+
with WindowFrozen(ctrl=self):
|
|
704
|
+
# try to pause rendering until all panels updated
|
|
705
|
+
if closeCurrent:
|
|
706
|
+
if not self.fileClose(updateViews=False):
|
|
707
|
+
# close the existing (and prompt for save if necess)
|
|
708
|
+
return False
|
|
709
|
+
self.exp = experiment.Experiment(prefs=self.app.prefs)
|
|
710
|
+
try:
|
|
711
|
+
self.exp.loadFromXML(filename)
|
|
712
|
+
except Exception:
|
|
713
|
+
print(u"Failed to load {}. Please send the following to"
|
|
714
|
+
u" the PsychoPy user list".format(filename))
|
|
715
|
+
traceback.print_exc()
|
|
716
|
+
logging.flush()
|
|
717
|
+
self.resetUndoStack()
|
|
718
|
+
self.setIsModified(False)
|
|
719
|
+
self.filename = filename
|
|
720
|
+
# routinePanel.addRoutinePage() is done in
|
|
721
|
+
# routinePanel.redrawRoutines(), called by self.updateAllViews()
|
|
722
|
+
# update the views
|
|
723
|
+
self.updateAllViews() # if frozen effect will be visible on thaw
|
|
724
|
+
self.updateReadme()
|
|
725
|
+
self.fileHistory.AddFileToHistory(filename)
|
|
726
|
+
self.htmlPath = None # so we won't accidentally save to other html exp
|
|
727
|
+
|
|
728
|
+
if self.app.runner:
|
|
729
|
+
self.app.runner.addTask(fileName=self.filename) # Add to Runner
|
|
730
|
+
|
|
731
|
+
self.project = pavlovia.getProject(filename)
|
|
732
|
+
self.app.updateWindowMenu()
|
|
733
|
+
|
|
734
|
+
def fileSave(self, event=None, filename=None):
|
|
735
|
+
"""Save file, revert to SaveAs if the file hasn't yet been saved
|
|
736
|
+
"""
|
|
737
|
+
if filename is None:
|
|
738
|
+
filename = self.filename
|
|
739
|
+
if filename.startswith('untitled'):
|
|
740
|
+
if not self.fileSaveAs(filename):
|
|
741
|
+
return False # the user cancelled during saveAs
|
|
742
|
+
else:
|
|
743
|
+
filename = self.exp.saveToXML(filename)
|
|
744
|
+
self.fileHistory.AddFileToHistory(filename)
|
|
745
|
+
self.setIsModified(False)
|
|
746
|
+
# if export on save then we should have an html file to update
|
|
747
|
+
if self._getExportPref('on save') and os.path.split(filename)[0]:
|
|
748
|
+
self.filename = filename
|
|
749
|
+
self.fileExport(htmlPath=self.htmlPath)
|
|
750
|
+
return True
|
|
751
|
+
|
|
752
|
+
def fileSaveAs(self, event=None, filename=None):
|
|
753
|
+
"""Defines Save File as Behavior
|
|
754
|
+
"""
|
|
755
|
+
shortFilename = self.getShortFilename()
|
|
756
|
+
expName = self.exp.getExpName()
|
|
757
|
+
if (not expName) or (shortFilename == expName):
|
|
758
|
+
usingDefaultName = True
|
|
759
|
+
else:
|
|
760
|
+
usingDefaultName = False
|
|
761
|
+
if filename is None:
|
|
762
|
+
filename = self.filename
|
|
763
|
+
initPath, filename = os.path.split(filename)
|
|
764
|
+
|
|
765
|
+
if sys.platform != 'darwin':
|
|
766
|
+
wildcard = _translate("PsychoPy experiments (*.psyexp)|*.psyexp|Any file (*.*)|*.*")
|
|
767
|
+
else:
|
|
768
|
+
wildcard = _translate("PsychoPy experiments (*.psyexp)|*.psyexp|Any file (*.*)|*")
|
|
769
|
+
returnVal = False
|
|
770
|
+
dlg = wx.FileDialog(
|
|
771
|
+
self, message=_translate("Save file as ..."), defaultDir=initPath,
|
|
772
|
+
defaultFile=filename, style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT,
|
|
773
|
+
wildcard=wildcard)
|
|
774
|
+
|
|
775
|
+
if dlg.ShowModal() == wx.ID_OK:
|
|
776
|
+
newPath = dlg.GetPath()
|
|
777
|
+
# update exp name
|
|
778
|
+
# if user has not manually renamed experiment
|
|
779
|
+
if usingDefaultName:
|
|
780
|
+
newShortName = os.path.splitext(
|
|
781
|
+
os.path.split(newPath)[1])[0]
|
|
782
|
+
self.exp.setExpName(newShortName)
|
|
783
|
+
# actually save
|
|
784
|
+
self.fileSave(event=None, filename=newPath)
|
|
785
|
+
self.filename = newPath
|
|
786
|
+
self.project = pavlovia.getProject(filename)
|
|
787
|
+
returnVal = 1
|
|
788
|
+
dlg.Destroy()
|
|
789
|
+
|
|
790
|
+
self.updateWindowTitle()
|
|
791
|
+
return returnVal
|
|
792
|
+
|
|
793
|
+
def fileExport(self, event=None, htmlPath=None):
|
|
794
|
+
"""Exports the script as an HTML file (PsychoJS library)
|
|
795
|
+
"""
|
|
796
|
+
# get path if not given one
|
|
797
|
+
expPath, expName = os.path.split(self.filename)
|
|
798
|
+
if htmlPath is None:
|
|
799
|
+
htmlPath = self._getHtmlPath(self.filename)
|
|
800
|
+
if not htmlPath:
|
|
801
|
+
return
|
|
802
|
+
|
|
803
|
+
exportPath = os.path.join(htmlPath, expName.replace('.psyexp', '.js'))
|
|
804
|
+
self.generateScript(experimentPath=exportPath,
|
|
805
|
+
exp=self.exp,
|
|
806
|
+
target="PsychoJS")
|
|
807
|
+
# Open exported files
|
|
808
|
+
self.app.showCoder()
|
|
809
|
+
self.app.coder.fileNew(filepath=exportPath)
|
|
810
|
+
self.app.coder.fileReload(event=None, filename=exportPath)
|
|
811
|
+
|
|
812
|
+
def editREADME(self, event):
|
|
813
|
+
folder = Path(self.filename).parent
|
|
814
|
+
if folder == folder.parent:
|
|
815
|
+
dlg = wx.MessageDialog(
|
|
816
|
+
self,
|
|
817
|
+
_translate("Please save experiment before editing the README file"),
|
|
818
|
+
_translate("No readme file"),
|
|
819
|
+
wx.OK | wx.ICON_WARNING | wx.CENTRE)
|
|
820
|
+
dlg.ShowModal()
|
|
821
|
+
return
|
|
822
|
+
self.updateReadme()
|
|
823
|
+
self.showReadme()
|
|
824
|
+
return
|
|
825
|
+
|
|
826
|
+
def getShortFilename(self):
|
|
827
|
+
"""returns the filename without path or extension
|
|
828
|
+
"""
|
|
829
|
+
return os.path.splitext(os.path.split(self.filename)[1])[0]
|
|
830
|
+
|
|
831
|
+
# def pluginManager(self, evt=None, value=True):
|
|
832
|
+
# """Show the plugin manager frame."""
|
|
833
|
+
# PluginManagerFrame(self).ShowModal()
|
|
834
|
+
|
|
835
|
+
def updateReadme(self):
|
|
836
|
+
"""Check whether there is a readme file in this folder and try to show
|
|
837
|
+
"""
|
|
838
|
+
# create the frame if we don't have one yet
|
|
839
|
+
if self.readmeFrame is None:
|
|
840
|
+
self.readmeFrame = ReadmeFrame(parent=self)
|
|
841
|
+
# look for a readme file
|
|
842
|
+
if self.filename and self.filename != 'untitled.psyexp':
|
|
843
|
+
dirname = Path(self.filename).parent
|
|
844
|
+
possibles = list(dirname.glob('readme*'))
|
|
845
|
+
if len(possibles) == 0:
|
|
846
|
+
possibles = list(dirname.glob('Readme*'))
|
|
847
|
+
possibles.extend(dirname.glob('README*'))
|
|
848
|
+
# still haven't found a file so use default name
|
|
849
|
+
if len(possibles) == 0:
|
|
850
|
+
self.readmeFilename = str(dirname / 'readme.md') # use this as our default
|
|
851
|
+
else:
|
|
852
|
+
self.readmeFilename = str(possibles[0]) # take the first one found
|
|
853
|
+
else:
|
|
854
|
+
self.readmeFilename = None
|
|
855
|
+
self.readmeFrame.setFile(self.readmeFilename)
|
|
856
|
+
content = self.readmeFrame.ctrl.ToText()
|
|
857
|
+
if content and self.prefs['alwaysShowReadme']:
|
|
858
|
+
self.showReadme()
|
|
859
|
+
|
|
860
|
+
def showReadme(self, evt=None, value=True):
|
|
861
|
+
"""Shows Readme file
|
|
862
|
+
"""
|
|
863
|
+
if not self.readmeFrame:
|
|
864
|
+
self.updateReadme()
|
|
865
|
+
if not self.readmeFrame.IsShown():
|
|
866
|
+
self.readmeFrame.Show(value)
|
|
867
|
+
|
|
868
|
+
def toggleReadme(self, evt=None):
|
|
869
|
+
"""Toggles visibility of Readme file
|
|
870
|
+
"""
|
|
871
|
+
if self.readmeFrame is None:
|
|
872
|
+
self.showReadme()
|
|
873
|
+
else:
|
|
874
|
+
self.readmeFrame.toggleVisible()
|
|
875
|
+
|
|
876
|
+
def OnFileHistory(self, evt=None):
|
|
877
|
+
"""get the file based on the menu ID
|
|
878
|
+
"""
|
|
879
|
+
fileNum = evt.GetId() - wx.ID_FILE1
|
|
880
|
+
path = self.fileHistory.GetHistoryFile(fileNum)
|
|
881
|
+
self.fileOpen(filename=path)
|
|
882
|
+
# add it back to the history so it will be moved up the list
|
|
883
|
+
self.fileHistory.AddFileToHistory(path)
|
|
884
|
+
|
|
885
|
+
def checkSave(self):
|
|
886
|
+
"""Check whether we need to save before quitting
|
|
887
|
+
"""
|
|
888
|
+
if hasattr(self, 'isModified') and self.isModified:
|
|
889
|
+
self.Show(True)
|
|
890
|
+
self.Raise()
|
|
891
|
+
self.app.SetTopWindow(self)
|
|
892
|
+
msg = _translate('Experiment %s has changed. Save before '
|
|
893
|
+
'quitting?') % self.filename
|
|
894
|
+
dlg = dialogs.MessageDialog(self, msg, type='Warning')
|
|
895
|
+
resp = dlg.ShowModal()
|
|
896
|
+
if resp == wx.ID_CANCEL:
|
|
897
|
+
return False # return, don't quit
|
|
898
|
+
elif resp == wx.ID_YES:
|
|
899
|
+
if not self.fileSave():
|
|
900
|
+
return False # user might cancel during save
|
|
901
|
+
elif resp == wx.ID_NO:
|
|
902
|
+
pass # don't save just quit
|
|
903
|
+
return True
|
|
904
|
+
|
|
905
|
+
def fileClose(self, event=None, checkSave=True, updateViews=True):
|
|
906
|
+
"""This is typically only called when the user x
|
|
907
|
+
"""
|
|
908
|
+
if checkSave:
|
|
909
|
+
ok = self.checkSave()
|
|
910
|
+
if not ok:
|
|
911
|
+
return False # user cancelled
|
|
912
|
+
if self.filename is None:
|
|
913
|
+
frameData = self.appData['defaultFrame']
|
|
914
|
+
else:
|
|
915
|
+
frameData = dict(self.appData['defaultFrame'])
|
|
916
|
+
self.appData['prevFiles'].append(self.filename)
|
|
917
|
+
|
|
918
|
+
# get size and window layout info
|
|
919
|
+
if self.IsIconized():
|
|
920
|
+
self.Iconize(False) # will return to normal mode to get size info
|
|
921
|
+
frameData['state'] = 'normal'
|
|
922
|
+
elif self.IsMaximized():
|
|
923
|
+
# will briefly return to normal mode to get size info
|
|
924
|
+
self.Maximize(False)
|
|
925
|
+
frameData['state'] = 'maxim'
|
|
926
|
+
else:
|
|
927
|
+
frameData['state'] = 'normal'
|
|
928
|
+
frameData['auiPerspective'] = self._mgr.SavePerspective()
|
|
929
|
+
frameData['winW'], frameData['winH'] = self.GetSize()
|
|
930
|
+
frameData['winX'], frameData['winY'] = self.GetPosition()
|
|
931
|
+
|
|
932
|
+
# truncate history to the recent-most last N unique files, where
|
|
933
|
+
# N = self.fileHistoryMaxFiles, as defined in makeMenus()
|
|
934
|
+
for ii in range(self.fileHistory.GetCount()):
|
|
935
|
+
self.appData['fileHistory'].append(
|
|
936
|
+
self.fileHistory.GetHistoryFile(ii))
|
|
937
|
+
# fileClose gets calls multiple times, so remove redundancy
|
|
938
|
+
# while preserving order; end of the list is recent-most:
|
|
939
|
+
tmp = []
|
|
940
|
+
fhMax = self.fileHistoryMaxFiles
|
|
941
|
+
for f in self.appData['fileHistory'][-3 * fhMax:]:
|
|
942
|
+
if f not in tmp:
|
|
943
|
+
tmp.append(f)
|
|
944
|
+
self.appData['fileHistory'] = copy.copy(tmp[-fhMax:])
|
|
945
|
+
|
|
946
|
+
# assign the data to this filename
|
|
947
|
+
self.appData['frames'][self.filename] = frameData
|
|
948
|
+
# save the display data only for those frames in the history:
|
|
949
|
+
tmp2 = {}
|
|
950
|
+
for f in self.appData['frames']:
|
|
951
|
+
if f in self.appData['fileHistory']:
|
|
952
|
+
tmp2[f] = self.appData['frames'][f]
|
|
953
|
+
self.appData['frames'] = copy.copy(tmp2)
|
|
954
|
+
|
|
955
|
+
# close self
|
|
956
|
+
self.routinePanel.removePages()
|
|
957
|
+
self.filename = 'untitled.psyexp'
|
|
958
|
+
# add the current exp as the start point for undo:
|
|
959
|
+
self.resetUndoStack()
|
|
960
|
+
if updateViews:
|
|
961
|
+
self.updateAllViews()
|
|
962
|
+
return 1
|
|
963
|
+
|
|
964
|
+
def updateAllViews(self):
|
|
965
|
+
"""Updates Flow Panel, Routine Panel, and Window Title simultaneously
|
|
966
|
+
"""
|
|
967
|
+
self.flowPanel.draw()
|
|
968
|
+
self.routinePanel.redrawRoutines()
|
|
969
|
+
self.componentButtons.Refresh()
|
|
970
|
+
self.updateWindowTitle()
|
|
971
|
+
|
|
972
|
+
def layoutPanes(self):
|
|
973
|
+
# Get panes
|
|
974
|
+
flowPane = self._mgr.GetPane('Flow')
|
|
975
|
+
compPane = self._mgr.GetPane('Components')
|
|
976
|
+
rtPane = self._mgr.GetPane('Routines')
|
|
977
|
+
# Arrange panes according to prefs
|
|
978
|
+
if 'FlowBottom' in self.prefs['builderLayout']:
|
|
979
|
+
flowPane.Bottom()
|
|
980
|
+
elif 'FlowTop' in self.prefs['builderLayout']:
|
|
981
|
+
flowPane.Top()
|
|
982
|
+
if 'CompRight' in self.prefs['builderLayout']:
|
|
983
|
+
compPane.Right()
|
|
984
|
+
if 'CompLeft' in self.prefs['builderLayout']:
|
|
985
|
+
compPane.Left()
|
|
986
|
+
rtPane.Center()
|
|
987
|
+
# Commit
|
|
988
|
+
self._mgr.Update()
|
|
989
|
+
|
|
990
|
+
def resetPrefs(self, event):
|
|
991
|
+
"""Reset preferences to default"""
|
|
992
|
+
# Present "are you sure" dialog
|
|
993
|
+
dlg = wx.MessageDialog(self, _translate("Are you sure you want to reset your preferences? This cannot be undone."),
|
|
994
|
+
caption="Reset Preferences...", style=wx.ICON_WARNING | wx.CANCEL)
|
|
995
|
+
dlg.SetOKCancelLabels(
|
|
996
|
+
_translate("I'm sure"),
|
|
997
|
+
_translate("Wait, go back!")
|
|
998
|
+
)
|
|
999
|
+
if dlg.ShowModal() == wx.ID_OK:
|
|
1000
|
+
# If okay is pressed, remove prefs file (meaning a new one will be created on next restart)
|
|
1001
|
+
os.remove(prefs.paths['userPrefsFile'])
|
|
1002
|
+
# Show confirmation
|
|
1003
|
+
dlg = wx.MessageDialog(self, _translate("Done! Your preferences have been reset. Changes will be applied when you next open PsychoPy."))
|
|
1004
|
+
dlg.ShowModal()
|
|
1005
|
+
else:
|
|
1006
|
+
pass
|
|
1007
|
+
|
|
1008
|
+
def updateWindowTitle(self, newTitle=None):
|
|
1009
|
+
"""Defines behavior to update window Title
|
|
1010
|
+
"""
|
|
1011
|
+
if newTitle is None:
|
|
1012
|
+
shortName = os.path.split(self.filename)[-1]
|
|
1013
|
+
self.setTitle(title=self.winTitle, document=shortName)
|
|
1014
|
+
|
|
1015
|
+
def setIsModified(self, newVal=None):
|
|
1016
|
+
"""Sets current modified status and updates save icon accordingly.
|
|
1017
|
+
|
|
1018
|
+
This method is called by the methods fileSave, undo, redo,
|
|
1019
|
+
addToUndoStack and it is usually preferably to call those
|
|
1020
|
+
than to call this directly.
|
|
1021
|
+
|
|
1022
|
+
Call with ``newVal=None``, to only update the save icon(s)
|
|
1023
|
+
"""
|
|
1024
|
+
if newVal is None:
|
|
1025
|
+
newVal = self.getIsModified()
|
|
1026
|
+
else:
|
|
1027
|
+
self.isModified = newVal
|
|
1028
|
+
if hasattr(self, 'bldrBtnSave'):
|
|
1029
|
+
self.toolbar.EnableTool(self.bldrBtnSave.Id, newVal)
|
|
1030
|
+
self.fileMenu.Enable(wx.ID_SAVE, newVal)
|
|
1031
|
+
|
|
1032
|
+
def getIsModified(self):
|
|
1033
|
+
"""Checks if changes were made"""
|
|
1034
|
+
return self.isModified
|
|
1035
|
+
|
|
1036
|
+
def resetUndoStack(self):
|
|
1037
|
+
"""Reset the undo stack. do *immediately after* creating a new exp.
|
|
1038
|
+
|
|
1039
|
+
Implicitly calls addToUndoStack() using the current exp as the state
|
|
1040
|
+
"""
|
|
1041
|
+
self.currentUndoLevel = 1 # 1 is current, 2 is back one setp...
|
|
1042
|
+
self.currentUndoStack = []
|
|
1043
|
+
self.addToUndoStack()
|
|
1044
|
+
self.updateUndoRedo()
|
|
1045
|
+
self.setIsModified(newVal=False) # update save icon if needed
|
|
1046
|
+
|
|
1047
|
+
def addToUndoStack(self, action="", state=None):
|
|
1048
|
+
"""Add the given ``action`` to the currentUndoStack, associated
|
|
1049
|
+
with the @state@. ``state`` should be a copy of the exp
|
|
1050
|
+
from *immediately after* the action was taken.
|
|
1051
|
+
If no ``state`` is given the current state of the experiment is used.
|
|
1052
|
+
|
|
1053
|
+
If we are at end of stack already then simply append the action. If
|
|
1054
|
+
not (user has done an undo) then remove orphan actions and append.
|
|
1055
|
+
"""
|
|
1056
|
+
if state is None:
|
|
1057
|
+
state = copy.deepcopy(self.exp)
|
|
1058
|
+
# remove actions from after the current level
|
|
1059
|
+
if self.currentUndoLevel > 1:
|
|
1060
|
+
self.currentUndoStack = self.currentUndoStack[
|
|
1061
|
+
:-(self.currentUndoLevel - 1)]
|
|
1062
|
+
self.currentUndoLevel = 1
|
|
1063
|
+
# append this action
|
|
1064
|
+
self.currentUndoStack.append({'action': action, 'state': state})
|
|
1065
|
+
self.setIsModified(newVal=True) # update save icon if needed
|
|
1066
|
+
self.updateUndoRedo()
|
|
1067
|
+
|
|
1068
|
+
def undo(self, event=None):
|
|
1069
|
+
"""Step the exp back one level in the @currentUndoStack@ if possible,
|
|
1070
|
+
and update the windows.
|
|
1071
|
+
|
|
1072
|
+
Returns the final undo level (1=current, >1 for further in past)
|
|
1073
|
+
or -1 if redo failed (probably can't undo)
|
|
1074
|
+
"""
|
|
1075
|
+
if self.currentUndoLevel >= len(self.currentUndoStack):
|
|
1076
|
+
return -1 # can't undo
|
|
1077
|
+
self.currentUndoLevel += 1
|
|
1078
|
+
state = self.currentUndoStack[-self.currentUndoLevel]['state']
|
|
1079
|
+
self.exp = copy.deepcopy(state)
|
|
1080
|
+
self.updateAllViews()
|
|
1081
|
+
self.setIsModified(newVal=True) # update save icon if needed
|
|
1082
|
+
self.updateUndoRedo()
|
|
1083
|
+
|
|
1084
|
+
return self.currentUndoLevel
|
|
1085
|
+
|
|
1086
|
+
def redo(self, event=None):
|
|
1087
|
+
"""Step the exp up one level in the @currentUndoStack@ if possible,
|
|
1088
|
+
and update the windows.
|
|
1089
|
+
|
|
1090
|
+
Returns the final undo level (0=current, >0 for further in past)
|
|
1091
|
+
or -1 if redo failed (probably can't redo)
|
|
1092
|
+
"""
|
|
1093
|
+
if self.currentUndoLevel <= 1:
|
|
1094
|
+
return -1 # can't redo, we're already at latest state
|
|
1095
|
+
self.currentUndoLevel -= 1
|
|
1096
|
+
self.exp = copy.deepcopy(
|
|
1097
|
+
self.currentUndoStack[-self.currentUndoLevel]['state'])
|
|
1098
|
+
self.updateUndoRedo()
|
|
1099
|
+
self.updateAllViews()
|
|
1100
|
+
self.setIsModified(newVal=True) # update save icon if needed
|
|
1101
|
+
return self.currentUndoLevel
|
|
1102
|
+
|
|
1103
|
+
def paste(self, event=None):
|
|
1104
|
+
"""This receives paste commands for all child dialog boxes as well
|
|
1105
|
+
"""
|
|
1106
|
+
foc = self.FindFocus()
|
|
1107
|
+
if hasattr(foc, 'Paste'):
|
|
1108
|
+
foc.Paste()
|
|
1109
|
+
|
|
1110
|
+
def updateUndoRedo(self):
|
|
1111
|
+
"""Defines Undo and Redo commands for the window
|
|
1112
|
+
"""
|
|
1113
|
+
undoLevel = self.currentUndoLevel
|
|
1114
|
+
# check undo
|
|
1115
|
+
if undoLevel >= len(self.currentUndoStack):
|
|
1116
|
+
# can't undo if we're at top of undo stack
|
|
1117
|
+
label = _translate("Undo\t%s") % self.app.keys['undo']
|
|
1118
|
+
enable = False
|
|
1119
|
+
else:
|
|
1120
|
+
action = self.currentUndoStack[-undoLevel]['action']
|
|
1121
|
+
txt = _translate("Undo %(action)s\t%(key)s")
|
|
1122
|
+
fmt = {'action': action, 'key': self.app.keys['undo']}
|
|
1123
|
+
label = txt % fmt
|
|
1124
|
+
enable = True
|
|
1125
|
+
self._undoLabel.SetItemLabel(label)
|
|
1126
|
+
if hasattr(self, 'bldrBtnUndo'):
|
|
1127
|
+
self.toolbar.EnableTool(self.bldrBtnUndo.Id, enable)
|
|
1128
|
+
self.editMenu.Enable(wx.ID_UNDO, enable)
|
|
1129
|
+
|
|
1130
|
+
# check redo
|
|
1131
|
+
if undoLevel == 1:
|
|
1132
|
+
label = _translate("Redo\t%s") % self.app.keys['redo']
|
|
1133
|
+
enable = False
|
|
1134
|
+
else:
|
|
1135
|
+
action = self.currentUndoStack[-undoLevel + 1]['action']
|
|
1136
|
+
txt = _translate("Redo %(action)s\t%(key)s")
|
|
1137
|
+
fmt = {'action': action, 'key': self.app.keys['redo']}
|
|
1138
|
+
label = txt % fmt
|
|
1139
|
+
enable = True
|
|
1140
|
+
self._redoLabel.SetItemLabel(label)
|
|
1141
|
+
if hasattr(self, 'bldrBtnRedo'):
|
|
1142
|
+
self.toolbar.EnableTool(self.bldrBtnRedo.Id, enable)
|
|
1143
|
+
self.editMenu.Enable(wx.ID_REDO, enable)
|
|
1144
|
+
|
|
1145
|
+
def demosUnpack(self, event=None):
|
|
1146
|
+
"""Get a folder location from the user and unpack demos into it."""
|
|
1147
|
+
# choose a dir to unpack in
|
|
1148
|
+
dlg = wx.DirDialog(parent=self, message=_translate(
|
|
1149
|
+
"Location to unpack demos"))
|
|
1150
|
+
if dlg.ShowModal() == wx.ID_OK:
|
|
1151
|
+
unpackFolder = dlg.GetPath()
|
|
1152
|
+
else:
|
|
1153
|
+
return -1 # user cancelled
|
|
1154
|
+
# ensure it's an empty dir:
|
|
1155
|
+
if os.listdir(unpackFolder) != []:
|
|
1156
|
+
unpackFolder = os.path.join(unpackFolder, 'PsychoPy3 Demos')
|
|
1157
|
+
if not os.path.isdir(unpackFolder):
|
|
1158
|
+
os.mkdir(unpackFolder)
|
|
1159
|
+
mergeFolder(os.path.join(self.paths['demos'], 'builder'),
|
|
1160
|
+
unpackFolder)
|
|
1161
|
+
self.prefs['unpackedDemosDir'] = unpackFolder
|
|
1162
|
+
self.app.prefs.saveUserPrefs()
|
|
1163
|
+
updateDemosMenu(self, self.demosMenu, self.prefs['unpackedDemosDir'], ext=".psyexp")
|
|
1164
|
+
|
|
1165
|
+
def demoLoad(self, event=None):
|
|
1166
|
+
"""Defines Demo Loading Event."""
|
|
1167
|
+
fileDir = self.demos[event.GetId()]
|
|
1168
|
+
files = glob.glob(os.path.join(fileDir, '*.psyexp'))
|
|
1169
|
+
if len(files) == 0:
|
|
1170
|
+
print("Found no psyexp files in %s" % fileDir)
|
|
1171
|
+
else:
|
|
1172
|
+
self.fileOpen(event=None, filename=files[0], closeCurrent=True)
|
|
1173
|
+
|
|
1174
|
+
def openLocalDemos(self, event=None):
|
|
1175
|
+
# Choose a command according to OS
|
|
1176
|
+
if sys.platform in ['win32']:
|
|
1177
|
+
comm = "explorer"
|
|
1178
|
+
elif sys.platform in ['darwin']:
|
|
1179
|
+
comm = "open"
|
|
1180
|
+
elif sys.platform in ['linux', 'linux2']:
|
|
1181
|
+
comm = "dolphin"
|
|
1182
|
+
# Use command to open themes folder
|
|
1183
|
+
subprocess.call(f"{comm} {prefs.builder['unpackedDemosDir']}", shell=True)
|
|
1184
|
+
|
|
1185
|
+
def openPavloviaDemos(self, event=None):
|
|
1186
|
+
webbrowser.open("https://pavlovia.org/explore")
|
|
1187
|
+
|
|
1188
|
+
def runFile(self, event=None):
|
|
1189
|
+
"""Open Runner for running the psyexp file."""
|
|
1190
|
+
# Check whether file is truly untitled (not just saved as untitled)
|
|
1191
|
+
untitled = os.path.abspath("untitled.psyexp")
|
|
1192
|
+
if not os.path.exists(self.filename) or os.path.abspath(self.filename) == untitled:
|
|
1193
|
+
ok = self.fileSave(self.filename)
|
|
1194
|
+
if not ok:
|
|
1195
|
+
return # save file before compiling script
|
|
1196
|
+
|
|
1197
|
+
if self.getIsModified():
|
|
1198
|
+
ok = self.fileSave(self.filename)
|
|
1199
|
+
if not ok:
|
|
1200
|
+
return # save file before compiling script
|
|
1201
|
+
self.app.showRunner()
|
|
1202
|
+
self.stdoutFrame.addTask(fileName=self.filename)
|
|
1203
|
+
self.app.runner.Raise()
|
|
1204
|
+
if event:
|
|
1205
|
+
if event.Id in [self.bldrBtnRun.Id, self.bldrRun.Id]:
|
|
1206
|
+
self.app.runner.panel.runLocal(event)
|
|
1207
|
+
else:
|
|
1208
|
+
self.app.showRunner()
|
|
1209
|
+
|
|
1210
|
+
def onCopyRoutine(self, event=None):
|
|
1211
|
+
"""copy the current routine from self.routinePanel
|
|
1212
|
+
to self.app.copiedRoutine.
|
|
1213
|
+
"""
|
|
1214
|
+
r = self.routinePanel.getCurrentRoutine().copy()
|
|
1215
|
+
if r is not None:
|
|
1216
|
+
self.app.copiedRoutine = r
|
|
1217
|
+
|
|
1218
|
+
def onPasteRoutine(self, event=None):
|
|
1219
|
+
"""Paste the current routine from self.app.copiedRoutine to a new page
|
|
1220
|
+
in self.routinePanel after prompting for a new name.
|
|
1221
|
+
"""
|
|
1222
|
+
if self.app.copiedRoutine is None:
|
|
1223
|
+
return -1
|
|
1224
|
+
origName = self.app.copiedRoutine.name
|
|
1225
|
+
defaultName = self.exp.namespace.makeValid(origName)
|
|
1226
|
+
msg = _translate('New name for copy of "%(copied)s"? [%(default)s]')
|
|
1227
|
+
vals = {'copied': origName, 'default': defaultName}
|
|
1228
|
+
message = msg % vals
|
|
1229
|
+
dlg = wx.TextEntryDialog(self, message=message,
|
|
1230
|
+
caption=_translate('Paste Routine'))
|
|
1231
|
+
if dlg.ShowModal() == wx.ID_OK:
|
|
1232
|
+
routineName = dlg.GetValue()
|
|
1233
|
+
if not routineName:
|
|
1234
|
+
routineName = defaultName
|
|
1235
|
+
newRoutine = self.app.copiedRoutine.copy()
|
|
1236
|
+
self.pasteRoutine(newRoutine, routineName)
|
|
1237
|
+
dlg.Destroy()
|
|
1238
|
+
|
|
1239
|
+
def pasteRoutine(self, newRoutine, routineName):
|
|
1240
|
+
"""
|
|
1241
|
+
Paste a copied Routine into the current Experiment. Returns a copy of that Routine
|
|
1242
|
+
"""
|
|
1243
|
+
newRoutine.name = self.exp.namespace.makeValid(routineName, prefix="routine")
|
|
1244
|
+
newRoutine.params['name'] = newRoutine.name
|
|
1245
|
+
newRoutine.exp = self.exp
|
|
1246
|
+
self.exp.namespace.add(newRoutine.name)
|
|
1247
|
+
# add to the experiment
|
|
1248
|
+
self.exp.addRoutine(newRoutine.name, newRoutine)
|
|
1249
|
+
for newComp in newRoutine: # routine == list of components
|
|
1250
|
+
newName = self.exp.namespace.makeValid(newComp.params['name'])
|
|
1251
|
+
self.exp.namespace.add(newName)
|
|
1252
|
+
newComp.params['name'].val = newName
|
|
1253
|
+
newComp.exp = self.exp
|
|
1254
|
+
# could do redrawRoutines but would be slower?
|
|
1255
|
+
self.routinePanel.addRoutinePage(newRoutine.name, newRoutine)
|
|
1256
|
+
self.routinePanel.setCurrentRoutine(newRoutine)
|
|
1257
|
+
return newRoutine
|
|
1258
|
+
|
|
1259
|
+
def onPasteCompon(self, event=None):
|
|
1260
|
+
"""
|
|
1261
|
+
Paste the copied Component (if there is one) into the current
|
|
1262
|
+
Routine
|
|
1263
|
+
"""
|
|
1264
|
+
routinePage = self.routinePanel.getCurrentPage()
|
|
1265
|
+
routinePage.pasteCompon()
|
|
1266
|
+
|
|
1267
|
+
def onURL(self, evt):
|
|
1268
|
+
"""decompose the URL of a file and line number"""
|
|
1269
|
+
# "C:\Program Files\wxPython...\samples\hangman\hangman.py"
|
|
1270
|
+
filename = evt.GetString().split('"')[1]
|
|
1271
|
+
lineNumber = int(evt.GetString().split(',')[1][5:])
|
|
1272
|
+
self.app.showCoder()
|
|
1273
|
+
self.app.coder.gotoLine(filename, lineNumber)
|
|
1274
|
+
|
|
1275
|
+
def setExperimentSettings(self, event=None, timeout=None):
|
|
1276
|
+
"""Defines ability to save experiment settings
|
|
1277
|
+
"""
|
|
1278
|
+
component = self.exp.settings
|
|
1279
|
+
# does this component have a help page?
|
|
1280
|
+
if hasattr(component, 'url'):
|
|
1281
|
+
helpUrl = component.url
|
|
1282
|
+
else:
|
|
1283
|
+
helpUrl = None
|
|
1284
|
+
title = '%s Properties' % self.exp.getExpName()
|
|
1285
|
+
dlg = DlgExperimentProperties(
|
|
1286
|
+
frame=self, element=component, experiment=self.exp, timeout=timeout)
|
|
1287
|
+
|
|
1288
|
+
if dlg.OK:
|
|
1289
|
+
self.addToUndoStack("EDIT experiment settings")
|
|
1290
|
+
self.setIsModified(True)
|
|
1291
|
+
|
|
1292
|
+
def addRoutine(self, event=None):
|
|
1293
|
+
"""Defines ability to add routine in the routine panel
|
|
1294
|
+
"""
|
|
1295
|
+
self.routinePanel.createNewRoutine()
|
|
1296
|
+
|
|
1297
|
+
def renameRoutine(self, name, event=None):
|
|
1298
|
+
"""Defines ability to rename routine in the routine panel
|
|
1299
|
+
"""
|
|
1300
|
+
# get notebook details
|
|
1301
|
+
currentRoutine = self.routinePanel.getCurrentPage()
|
|
1302
|
+
currentRoutineIndex = self.routinePanel.GetPageIndex(currentRoutine)
|
|
1303
|
+
routine = self.routinePanel.GetPage(
|
|
1304
|
+
self.routinePanel.GetSelection()).routine
|
|
1305
|
+
oldName = routine.name
|
|
1306
|
+
msg = _translate("What is the new name for the Routine?")
|
|
1307
|
+
dlg = wx.TextEntryDialog(self, message=msg, value=oldName,
|
|
1308
|
+
caption=_translate('Rename'))
|
|
1309
|
+
exp = self.exp
|
|
1310
|
+
if dlg.ShowModal() == wx.ID_OK:
|
|
1311
|
+
name = dlg.GetValue()
|
|
1312
|
+
# silently auto-adjust the name to be valid, and register in the
|
|
1313
|
+
# namespace:
|
|
1314
|
+
name = exp.namespace.makeValid(
|
|
1315
|
+
name, prefix='routine')
|
|
1316
|
+
if oldName in self.exp.routines:
|
|
1317
|
+
# Swap old with new names
|
|
1318
|
+
self.exp.routines[oldName].name = name
|
|
1319
|
+
self.exp.routines[name] = self.exp.routines.pop(oldName)
|
|
1320
|
+
self.exp.namespace.rename(oldName, name)
|
|
1321
|
+
self.routinePanel.renameRoutinePage(currentRoutineIndex, name)
|
|
1322
|
+
self.addToUndoStack("`RENAME Routine `%s`" % oldName)
|
|
1323
|
+
dlg.Destroy()
|
|
1324
|
+
self.flowPanel.draw()
|
|
1325
|
+
|
|
1326
|
+
def compileScript(self, event=None):
|
|
1327
|
+
"""Defines compile script button behavior"""
|
|
1328
|
+
fullPath = self.filename.replace('.psyexp', '.py')
|
|
1329
|
+
self.generateScript(experimentPath=fullPath, exp=self.exp)
|
|
1330
|
+
self.app.showCoder() # make sure coder is visible
|
|
1331
|
+
self.app.coder.fileNew(filepath=fullPath)
|
|
1332
|
+
self.app.coder.fileReload(event=None, filename=fullPath)
|
|
1333
|
+
|
|
1334
|
+
@property
|
|
1335
|
+
def stdoutFrame(self):
|
|
1336
|
+
"""
|
|
1337
|
+
Gets Experiment Runner stdout.
|
|
1338
|
+
"""
|
|
1339
|
+
if not self.app.runner:
|
|
1340
|
+
self.app.runner = self.app.showRunner()
|
|
1341
|
+
return self.app.runner
|
|
1342
|
+
|
|
1343
|
+
def _getHtmlPath(self, filename):
|
|
1344
|
+
expPath = os.path.split(filename)[0]
|
|
1345
|
+
if not os.path.isdir(expPath):
|
|
1346
|
+
retVal = self.fileSave()
|
|
1347
|
+
if retVal:
|
|
1348
|
+
return self._getHtmlPath(self.filename)
|
|
1349
|
+
else:
|
|
1350
|
+
return False
|
|
1351
|
+
|
|
1352
|
+
htmlPath = os.path.join(expPath, self.exp.htmlFolder)
|
|
1353
|
+
return htmlPath
|
|
1354
|
+
|
|
1355
|
+
def _getExportPref(self, pref):
|
|
1356
|
+
"""Returns True if pref matches exportHTML preference"""
|
|
1357
|
+
if pref.lower() not in [prefs.lower() for prefs in self.exp.settings.params['exportHTML'].allowedVals]:
|
|
1358
|
+
raise ValueError("'{}' is not an allowed value for {}".format(pref, 'exportHTML'))
|
|
1359
|
+
exportHtml = str(self.exp.settings.params['exportHTML'].val).lower()
|
|
1360
|
+
if exportHtml == pref.lower():
|
|
1361
|
+
return True
|
|
1362
|
+
|
|
1363
|
+
def onPavloviaSync(self, evt=None):
|
|
1364
|
+
self.fileSave(self.filename)
|
|
1365
|
+
if self._getExportPref('on sync'):
|
|
1366
|
+
htmlPath = self._getHtmlPath(self.filename)
|
|
1367
|
+
if htmlPath:
|
|
1368
|
+
self.fileExport(htmlPath=htmlPath)
|
|
1369
|
+
else:
|
|
1370
|
+
return
|
|
1371
|
+
|
|
1372
|
+
self.enablePavloviaButton(['pavloviaSync', 'pavloviaRun'], False)
|
|
1373
|
+
pavlovia_ui.syncProject(parent=self, file=self.filename, project=self.project)
|
|
1374
|
+
self.enablePavloviaButton(['pavloviaSync', 'pavloviaRun'], True)
|
|
1375
|
+
|
|
1376
|
+
def onPavloviaRun(self, evt=None):
|
|
1377
|
+
if self._getExportPref('on save') or self._getExportPref('on sync'):
|
|
1378
|
+
# If export on save/sync, sync now
|
|
1379
|
+
pavlovia_ui.syncProject(parent=self, project=self.project)
|
|
1380
|
+
elif self._getExportPref('manually'):
|
|
1381
|
+
# If set to manual, only sync if needed to create a project to run
|
|
1382
|
+
if self.project is None:
|
|
1383
|
+
pavlovia_ui.syncProject(parent=self, project=self.project)
|
|
1384
|
+
|
|
1385
|
+
if self.project is not None:
|
|
1386
|
+
# Make sure we have a html file to run
|
|
1387
|
+
if not (Path(self.project.localRoot) / 'index.html').is_file():
|
|
1388
|
+
self.fileExport(htmlPath=Path(self.project.localRoot) / 'index.html')
|
|
1389
|
+
# Update project status
|
|
1390
|
+
self.project.pavloviaStatus = 'ACTIVATED'
|
|
1391
|
+
# Run
|
|
1392
|
+
url = "https://pavlovia.org/run/{}".format(self.project['path_with_namespace'])
|
|
1393
|
+
wx.LaunchDefaultBrowser(url)
|
|
1394
|
+
|
|
1395
|
+
def enablePavloviaButton(self, buttons, enable):
|
|
1396
|
+
"""
|
|
1397
|
+
Enables or disables Pavlovia buttons.
|
|
1398
|
+
|
|
1399
|
+
Parameters
|
|
1400
|
+
----------
|
|
1401
|
+
name: string, list
|
|
1402
|
+
Takes single buttons 'pavloviaSync', 'pavloviaRun', 'pavloviaSearch', 'pavloviaUser',
|
|
1403
|
+
or multiple buttons in string 'pavloviaSync, pavloviaRun',
|
|
1404
|
+
or comma separated list of strings ['pavloviaSync', 'pavloviaRun', ...].
|
|
1405
|
+
enable: bool
|
|
1406
|
+
True enables and False disables the button
|
|
1407
|
+
"""
|
|
1408
|
+
if isinstance(buttons, str):
|
|
1409
|
+
buttons = buttons.split(',')
|
|
1410
|
+
for button in buttons:
|
|
1411
|
+
self.toolbar.EnableTool(self.btnHandles[button.strip(' ')].GetId(), enable)
|
|
1412
|
+
|
|
1413
|
+
def setPavloviaUser(self, user):
|
|
1414
|
+
# TODO: update user icon on button to user avatar
|
|
1415
|
+
pass
|
|
1416
|
+
|
|
1417
|
+
def gitFeedback(self, val):
|
|
1418
|
+
"""
|
|
1419
|
+
Set feedback color for the Pavlovia Sync toolbar button.
|
|
1420
|
+
|
|
1421
|
+
Parameters
|
|
1422
|
+
----------
|
|
1423
|
+
val: int
|
|
1424
|
+
Status of git sync. 1 for SUCCESS (green), 0 or -1 for FAIL (RED)
|
|
1425
|
+
"""
|
|
1426
|
+
feedbackTime = 1500
|
|
1427
|
+
colour = {0: "red", -1: "red", 1: "green"}
|
|
1428
|
+
toolbarSize = 32
|
|
1429
|
+
|
|
1430
|
+
# Store original
|
|
1431
|
+
origBtn = self.btnHandles['pavloviaSync'].NormalBitmap
|
|
1432
|
+
# Create new feedback bitmap
|
|
1433
|
+
feedbackBmp = icons.ButtonIcon(f"{colour[val]}globe.png", size=toolbarSize).bitmap
|
|
1434
|
+
|
|
1435
|
+
# Set feedback button
|
|
1436
|
+
self.btnHandles['pavloviaSync'].SetNormalBitmap(feedbackBmp)
|
|
1437
|
+
self.toolbar.Realize()
|
|
1438
|
+
self.toolbar.Refresh()
|
|
1439
|
+
|
|
1440
|
+
# Reset button to default state after time
|
|
1441
|
+
wx.CallLater(feedbackTime, self.btnHandles['pavloviaSync'].SetNormalBitmap, origBtn)
|
|
1442
|
+
wx.CallLater(feedbackTime + 50, self.toolbar.Realize)
|
|
1443
|
+
wx.CallLater(feedbackTime + 50, self.toolbar.Refresh)
|
|
1444
|
+
|
|
1445
|
+
@property
|
|
1446
|
+
def project(self):
|
|
1447
|
+
"""A PavloviaProject object if one is known for this experiment
|
|
1448
|
+
"""
|
|
1449
|
+
if hasattr(self, "_project"):
|
|
1450
|
+
return self._project
|
|
1451
|
+
elif self.filename:
|
|
1452
|
+
return pavlovia.getProject(self.filename)
|
|
1453
|
+
else:
|
|
1454
|
+
return None
|
|
1455
|
+
|
|
1456
|
+
@project.setter
|
|
1457
|
+
def project(self, project):
|
|
1458
|
+
self._project = project
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
class RoutinesNotebook(aui.AuiNotebook, handlers.ThemeMixin):
|
|
1462
|
+
"""A notebook that stores one or more routines
|
|
1463
|
+
"""
|
|
1464
|
+
|
|
1465
|
+
def __init__(self, frame, id=-1):
|
|
1466
|
+
self.frame = frame
|
|
1467
|
+
self.app = frame.app
|
|
1468
|
+
self.routineMaxSize = 2
|
|
1469
|
+
self.appData = self.app.prefs.appData
|
|
1470
|
+
aui.AuiNotebook.__init__(self, frame, id,
|
|
1471
|
+
agwStyle=aui.AUI_NB_TAB_MOVE | aui.AUI_NB_CLOSE_ON_ACTIVE_TAB)
|
|
1472
|
+
self.Bind(aui.EVT_AUINOTEBOOK_PAGE_CLOSE, self.onClosePane)
|
|
1473
|
+
|
|
1474
|
+
# double buffered better rendering except if retina
|
|
1475
|
+
|
|
1476
|
+
self.SetDoubleBuffered(not self.frame.isRetina)
|
|
1477
|
+
|
|
1478
|
+
# This needs to be done on init, otherwise it gets an outline
|
|
1479
|
+
self.GetAuiManager().SetArtProvider(handlers.PsychopyDockArt())
|
|
1480
|
+
|
|
1481
|
+
if not hasattr(self.frame, 'exp'):
|
|
1482
|
+
return # we haven't yet added an exp
|
|
1483
|
+
|
|
1484
|
+
def getCurrentRoutine(self):
|
|
1485
|
+
routinePage = self.getCurrentPage()
|
|
1486
|
+
if routinePage:
|
|
1487
|
+
return routinePage.routine # no routine page
|
|
1488
|
+
return None
|
|
1489
|
+
|
|
1490
|
+
def setCurrentRoutine(self, routine):
|
|
1491
|
+
for ii in range(self.GetPageCount()):
|
|
1492
|
+
if routine is self.GetPage(ii).routine:
|
|
1493
|
+
self.SetSelection(ii)
|
|
1494
|
+
|
|
1495
|
+
def SetSelection(self, index, force=False):
|
|
1496
|
+
aui.AuiNotebook.SetSelection(self, index, force=force)
|
|
1497
|
+
self.frame.componentButtons.enableComponents(
|
|
1498
|
+
not isinstance(self.GetPage(index).routine, BaseStandaloneRoutine)
|
|
1499
|
+
)
|
|
1500
|
+
|
|
1501
|
+
def getCurrentPage(self):
|
|
1502
|
+
if self.GetSelection() >= 0:
|
|
1503
|
+
return self.GetPage(self.GetSelection())
|
|
1504
|
+
return None
|
|
1505
|
+
|
|
1506
|
+
def addRoutinePage(self, routineName, routine):
|
|
1507
|
+
# Make page
|
|
1508
|
+
routinePage = None
|
|
1509
|
+
if isinstance(routine, Routine):
|
|
1510
|
+
routinePage = RoutineCanvas(notebook=self, routine=routine)
|
|
1511
|
+
elif isinstance(routine, BaseStandaloneRoutine):
|
|
1512
|
+
routinePage = StandaloneRoutineCanvas(parent=self, routine=routine)
|
|
1513
|
+
# Add page
|
|
1514
|
+
if routinePage:
|
|
1515
|
+
self.AddPage(routinePage, routineName)
|
|
1516
|
+
|
|
1517
|
+
def renameRoutinePage(self, index, newName, ):
|
|
1518
|
+
self.SetPageText(index, newName)
|
|
1519
|
+
|
|
1520
|
+
def removePages(self):
|
|
1521
|
+
for ii in range(self.GetPageCount()):
|
|
1522
|
+
currId = self.GetSelection()
|
|
1523
|
+
self.DeletePage(currId)
|
|
1524
|
+
|
|
1525
|
+
def createNewRoutine(self, template=None):
|
|
1526
|
+
msg = _translate("What is the name for the new Routine? "
|
|
1527
|
+
"(e.g. instr, trial, feedback)")
|
|
1528
|
+
dlg = DlgNewRoutine(self)
|
|
1529
|
+
routineName = None
|
|
1530
|
+
if dlg.ShowModal() == wx.ID_OK:
|
|
1531
|
+
routineName = dlg.nameCtrl.GetValue()
|
|
1532
|
+
template = copy.deepcopy(dlg.selectedTemplate)
|
|
1533
|
+
self.frame.pasteRoutine(template, routineName)
|
|
1534
|
+
self.frame.addToUndoStack("NEW Routine `%s`" % routineName)
|
|
1535
|
+
dlg.Destroy()
|
|
1536
|
+
return routineName
|
|
1537
|
+
|
|
1538
|
+
def onClosePane(self, event=None):
|
|
1539
|
+
"""Close the pane and remove the routine from the exp.
|
|
1540
|
+
"""
|
|
1541
|
+
currentPage = self.GetPage(event.GetSelection())
|
|
1542
|
+
routine = currentPage.routine
|
|
1543
|
+
name = routine.name
|
|
1544
|
+
|
|
1545
|
+
# name is not valid for some reason
|
|
1546
|
+
if name not in self.frame.exp.routines:
|
|
1547
|
+
event.Skip()
|
|
1548
|
+
return
|
|
1549
|
+
|
|
1550
|
+
# check if the user wants a prompt
|
|
1551
|
+
showDlg = self.app.prefs.builder.get('confirmRoutineClose', False)
|
|
1552
|
+
if showDlg:
|
|
1553
|
+
# message to display
|
|
1554
|
+
msg = _translate(
|
|
1555
|
+
"Do you want to remove routine '{}' from the experiment?")
|
|
1556
|
+
|
|
1557
|
+
# dialog asking if the user wants to remove the routine
|
|
1558
|
+
dlg = wx.MessageDialog(
|
|
1559
|
+
self,
|
|
1560
|
+
_translate(msg).format(name),
|
|
1561
|
+
_translate('Remove routine?'),
|
|
1562
|
+
wx.YES_NO | wx.NO_DEFAULT | wx.CENTRE | wx.STAY_ON_TOP)
|
|
1563
|
+
|
|
1564
|
+
# show the dialog and get the response
|
|
1565
|
+
dlgResult = dlg.ShowModal()
|
|
1566
|
+
dlg.Destroy()
|
|
1567
|
+
|
|
1568
|
+
if dlgResult == wx.ID_NO: # if NO, stop the tab from closing
|
|
1569
|
+
event.Veto()
|
|
1570
|
+
return
|
|
1571
|
+
|
|
1572
|
+
# remove names of the routine and its components from namespace
|
|
1573
|
+
_nsp = self.frame.exp.namespace
|
|
1574
|
+
for c in self.frame.exp.routines[name]:
|
|
1575
|
+
_nsp.remove(c.params['name'].val)
|
|
1576
|
+
_nsp.remove(self.frame.exp.routines[name].name)
|
|
1577
|
+
del self.frame.exp.routines[name]
|
|
1578
|
+
|
|
1579
|
+
if routine in self.frame.exp.flow:
|
|
1580
|
+
self.frame.exp.flow.removeComponent(routine)
|
|
1581
|
+
self.frame.flowPanel.draw()
|
|
1582
|
+
self.frame.addToUndoStack("REMOVE Routine `%s`" % (name))
|
|
1583
|
+
|
|
1584
|
+
def increaseSize(self, event=None):
|
|
1585
|
+
self.appData['routineSize'] = min(
|
|
1586
|
+
self.routineMaxSize, self.appData['routineSize'] + 1)
|
|
1587
|
+
with WindowFrozen(self):
|
|
1588
|
+
self.redrawRoutines()
|
|
1589
|
+
|
|
1590
|
+
def decreaseSize(self, event=None):
|
|
1591
|
+
self.appData['routineSize'] = max(0, self.appData['routineSize'] - 1)
|
|
1592
|
+
with WindowFrozen(self):
|
|
1593
|
+
self.redrawRoutines()
|
|
1594
|
+
|
|
1595
|
+
def redrawRoutines(self):
|
|
1596
|
+
"""Removes all the routines, adds them back (alphabetical order),
|
|
1597
|
+
sets current back to orig
|
|
1598
|
+
"""
|
|
1599
|
+
currPage = self.GetSelection()
|
|
1600
|
+
self.removePages()
|
|
1601
|
+
displayOrder = sorted(self.frame.exp.routines.keys()) # alphabetical
|
|
1602
|
+
for routineName in displayOrder:
|
|
1603
|
+
if isinstance(self.frame.exp.routines[routineName], (Routine, BaseStandaloneRoutine)):
|
|
1604
|
+
self.addRoutinePage(
|
|
1605
|
+
routineName, self.frame.exp.routines[routineName])
|
|
1606
|
+
if currPage > -1:
|
|
1607
|
+
self.SetSelection(currPage)
|
|
1608
|
+
|
|
1609
|
+
|
|
1610
|
+
class RoutineCanvas(wx.ScrolledWindow, handlers.ThemeMixin):
|
|
1611
|
+
"""Represents a single routine (used as page in RoutinesNotebook)"""
|
|
1612
|
+
|
|
1613
|
+
def __init__(self, notebook, id=wx.ID_ANY, routine=None):
|
|
1614
|
+
"""This window is based heavily on the PseudoDC demo of wxPython
|
|
1615
|
+
"""
|
|
1616
|
+
wx.ScrolledWindow.__init__(
|
|
1617
|
+
self, notebook, id, (0, 0), style=wx.BORDER_NONE | wx.VSCROLL)
|
|
1618
|
+
|
|
1619
|
+
self.frame = notebook.frame
|
|
1620
|
+
self.app = self.frame.app
|
|
1621
|
+
self.dpi = self.app.dpi
|
|
1622
|
+
self.lines = []
|
|
1623
|
+
self.maxWidth = self.GetSize().GetWidth()
|
|
1624
|
+
self.maxHeight = 15 * self.dpi
|
|
1625
|
+
self.x = self.y = 0
|
|
1626
|
+
self.curLine = []
|
|
1627
|
+
self.drawing = False
|
|
1628
|
+
self.drawSize = self.app.prefs.appData['routineSize']
|
|
1629
|
+
# auto-rescale based on number of components and window size is jumpy
|
|
1630
|
+
# when switch between routines of diff drawing sizes
|
|
1631
|
+
self.iconSize = (24, 24, 48)[self.drawSize] # only 24, 48 so far
|
|
1632
|
+
self.fontBaseSize = (1100, 1200, 1300)[self.drawSize] # depends on OS?
|
|
1633
|
+
#self.scroller = PsychopyScrollbar(self, wx.VERTICAL)
|
|
1634
|
+
self.SetVirtualSize((self.maxWidth, self.maxHeight))
|
|
1635
|
+
self.SetScrollRate(self.dpi / 4, self.dpi / 4)
|
|
1636
|
+
|
|
1637
|
+
self.routine = routine
|
|
1638
|
+
self.yPositions = None
|
|
1639
|
+
self.yPosTop = (25, 40, 60)[self.drawSize]
|
|
1640
|
+
# the step in Y between each component
|
|
1641
|
+
self.componentStep = (25, 32, 50)[self.drawSize]
|
|
1642
|
+
self.timeXposStart = (150, 150, 200)[self.drawSize]
|
|
1643
|
+
# the left hand edge of the icons:
|
|
1644
|
+
_scale = (1.3, 1.5, 1.5)[self.drawSize]
|
|
1645
|
+
self.iconXpos = self.timeXposStart - self.iconSize * _scale
|
|
1646
|
+
self.timeXposEnd = self.timeXposStart + 400 # onResize() overrides
|
|
1647
|
+
|
|
1648
|
+
# create a PseudoDC to record our drawing
|
|
1649
|
+
self.pdc = PseudoDC()
|
|
1650
|
+
self.pen_cache = {}
|
|
1651
|
+
self.brush_cache = {}
|
|
1652
|
+
# vars for handling mouse clicks
|
|
1653
|
+
self.dragid = -1
|
|
1654
|
+
self.lastpos = (0, 0)
|
|
1655
|
+
# use the ID of the drawn icon to retrieve component name:
|
|
1656
|
+
self.componentFromID = {}
|
|
1657
|
+
self.contextMenuItems = [
|
|
1658
|
+
'copy', 'paste above', 'paste below', 'edit', 'remove',
|
|
1659
|
+
'move to top', 'move up', 'move down', 'move to bottom']
|
|
1660
|
+
# labels are only for display, and allow localization
|
|
1661
|
+
self.contextMenuLabels = {k: _localized[k]
|
|
1662
|
+
for k in self.contextMenuItems}
|
|
1663
|
+
self.contextItemFromID = {}
|
|
1664
|
+
self.contextIDFromItem = {}
|
|
1665
|
+
for item in self.contextMenuItems:
|
|
1666
|
+
id = wx.NewIdRef()
|
|
1667
|
+
self.contextItemFromID[id] = item
|
|
1668
|
+
self.contextIDFromItem[item] = id
|
|
1669
|
+
|
|
1670
|
+
self.Bind(wx.EVT_PAINT, self.OnPaint)
|
|
1671
|
+
self.Bind(wx.EVT_ERASE_BACKGROUND, lambda x: None)
|
|
1672
|
+
self.Bind(wx.EVT_MOUSE_EVENTS, self.OnMouse)
|
|
1673
|
+
self.Bind(wx.EVT_MOUSEWHEEL, self.OnScroll)
|
|
1674
|
+
self.Bind(wx.EVT_SIZE, self.onResize)
|
|
1675
|
+
# crashes if drop on OSX:
|
|
1676
|
+
# self.SetDropTarget(FileDropTarget(builder = self.frame))
|
|
1677
|
+
|
|
1678
|
+
def _applyAppTheme(self, target=None):
|
|
1679
|
+
"""Synonymise app theme method with redraw method"""
|
|
1680
|
+
return self.redrawRoutine()
|
|
1681
|
+
|
|
1682
|
+
def onResize(self, event):
|
|
1683
|
+
self.sizePix = event.GetSize()
|
|
1684
|
+
self.timeXposStart = (150, 150, 200)[self.drawSize]
|
|
1685
|
+
self.timeXposEnd = self.sizePix[0] - (60, 80, 100)[self.drawSize]
|
|
1686
|
+
self.redrawRoutine() # then redraw visible
|
|
1687
|
+
|
|
1688
|
+
def ConvertEventCoords(self, event):
|
|
1689
|
+
xView, yView = self.GetViewStart()
|
|
1690
|
+
xDelta, yDelta = self.GetScrollPixelsPerUnit()
|
|
1691
|
+
return (event.GetX() + (xView * xDelta),
|
|
1692
|
+
event.GetY() + (yView * yDelta))
|
|
1693
|
+
|
|
1694
|
+
def OffsetRect(self, r):
|
|
1695
|
+
"""Offset the rectangle, r, to appear in the given pos in the window
|
|
1696
|
+
"""
|
|
1697
|
+
xView, yView = self.GetViewStart()
|
|
1698
|
+
xDelta, yDelta = self.GetScrollPixelsPerUnit()
|
|
1699
|
+
r.OffsetXY(-(xView * xDelta), -(yView * yDelta))
|
|
1700
|
+
|
|
1701
|
+
def OnMouse(self, event):
|
|
1702
|
+
if event.LeftDown():
|
|
1703
|
+
x, y = self.ConvertEventCoords(event)
|
|
1704
|
+
icons = self.pdc.FindObjectsByBBox(x, y)
|
|
1705
|
+
if len(icons):
|
|
1706
|
+
self.editComponentProperties(
|
|
1707
|
+
component=self.componentFromID[icons[0]])
|
|
1708
|
+
elif event.RightDown():
|
|
1709
|
+
x, y = self.ConvertEventCoords(event)
|
|
1710
|
+
icons = self.pdc.FindObjectsByBBox(x, y)
|
|
1711
|
+
menuPos = event.GetPosition()
|
|
1712
|
+
if 'flowTop' in self.app.prefs.builder['builderLayout']:
|
|
1713
|
+
# width of components panel
|
|
1714
|
+
menuPos[0] += self.frame.componentButtons.GetSize()[0]
|
|
1715
|
+
# height of flow panel
|
|
1716
|
+
menuPos[1] += self.frame.flowPanel.GetSize()[1]
|
|
1717
|
+
if len(icons):
|
|
1718
|
+
self._menuComponent = self.componentFromID[icons[0]]
|
|
1719
|
+
self.showContextMenu(self._menuComponent, xy=menuPos)
|
|
1720
|
+
else: # no context
|
|
1721
|
+
self.showContextMenu(None, xy=menuPos)
|
|
1722
|
+
|
|
1723
|
+
elif event.Dragging() or event.LeftUp():
|
|
1724
|
+
if self.dragid != -1:
|
|
1725
|
+
pass
|
|
1726
|
+
if event.LeftUp():
|
|
1727
|
+
pass
|
|
1728
|
+
elif event.Moving():
|
|
1729
|
+
try:
|
|
1730
|
+
x, y = self.ConvertEventCoords(event)
|
|
1731
|
+
id = self.pdc.FindObjectsByBBox(x, y)[0]
|
|
1732
|
+
component = self.componentFromID[id]
|
|
1733
|
+
self.frame.SetStatusText("Component: "+component.params['name'].val)
|
|
1734
|
+
except IndexError:
|
|
1735
|
+
self.frame.SetStatusText("")
|
|
1736
|
+
|
|
1737
|
+
def OnScroll(self, event):
|
|
1738
|
+
xy = self.GetViewStart()
|
|
1739
|
+
multiplier = self.dpi / 1600
|
|
1740
|
+
self.Scroll(xy[0], xy[1] - event.WheelRotation*multiplier)
|
|
1741
|
+
|
|
1742
|
+
def showContextMenu(self, component, xy):
|
|
1743
|
+
"""Show a context menu in the routine view.
|
|
1744
|
+
"""
|
|
1745
|
+
menu = wx.Menu()
|
|
1746
|
+
if component is not None:
|
|
1747
|
+
for item in self.contextMenuItems:
|
|
1748
|
+
id = self.contextIDFromItem[item]
|
|
1749
|
+
# don't show paste option unless something is copied
|
|
1750
|
+
if item.startswith('paste'):
|
|
1751
|
+
if not self.app.copiedCompon: # skip paste options
|
|
1752
|
+
continue
|
|
1753
|
+
itemLabel = " ".join(
|
|
1754
|
+
(self.contextMenuLabels[item],
|
|
1755
|
+
"({})".format(
|
|
1756
|
+
self.app.copiedCompon.params['name'].val)))
|
|
1757
|
+
elif any([item.startswith(op) for op in ('copy', 'remove', 'edit')]):
|
|
1758
|
+
itemLabel = " ".join(
|
|
1759
|
+
(self.contextMenuLabels[item],
|
|
1760
|
+
"({})".format(component.params['name'].val)))
|
|
1761
|
+
else:
|
|
1762
|
+
itemLabel = self.contextMenuLabels[item]
|
|
1763
|
+
|
|
1764
|
+
menu.Append(id, itemLabel)
|
|
1765
|
+
menu.Bind(wx.EVT_MENU, self.onContextSelect, id=id)
|
|
1766
|
+
|
|
1767
|
+
self.frame.PopupMenu(menu, xy)
|
|
1768
|
+
menu.Destroy() # destroy to avoid mem leak
|
|
1769
|
+
else:
|
|
1770
|
+
# anywhere but a hotspot is clicked, show this menu
|
|
1771
|
+
if self.app.copiedCompon:
|
|
1772
|
+
itemLabel = " ".join(
|
|
1773
|
+
(_translate('paste'),
|
|
1774
|
+
"({})".format(
|
|
1775
|
+
self.app.copiedCompon.params['name'].val)))
|
|
1776
|
+
menu.Append(wx.ID_ANY, itemLabel)
|
|
1777
|
+
menu.Bind(wx.EVT_MENU, self.pasteCompon, id=wx.ID_ANY)
|
|
1778
|
+
|
|
1779
|
+
self.frame.PopupMenu(menu, xy)
|
|
1780
|
+
menu.Destroy()
|
|
1781
|
+
|
|
1782
|
+
def onContextSelect(self, event):
|
|
1783
|
+
"""Perform a given action on the component chosen
|
|
1784
|
+
"""
|
|
1785
|
+
op = self.contextItemFromID[event.GetId()]
|
|
1786
|
+
component = self._menuComponent
|
|
1787
|
+
r = self.routine
|
|
1788
|
+
if op == 'edit':
|
|
1789
|
+
self.editComponentProperties(component=component)
|
|
1790
|
+
elif op == 'copy':
|
|
1791
|
+
self.copyCompon(component=component)
|
|
1792
|
+
elif op == 'paste above':
|
|
1793
|
+
self.pasteCompon(index=r.index(component))
|
|
1794
|
+
elif op == 'paste below':
|
|
1795
|
+
self.pasteCompon(index=r.index(component) + 1)
|
|
1796
|
+
elif op == 'remove':
|
|
1797
|
+
r.removeComponent(component)
|
|
1798
|
+
self.frame.addToUndoStack(
|
|
1799
|
+
"REMOVE `%s` from Routine" % component.params['name'].val)
|
|
1800
|
+
self.frame.exp.namespace.remove(component.params['name'].val)
|
|
1801
|
+
elif op.startswith('move'):
|
|
1802
|
+
lastLoc = r.index(component)
|
|
1803
|
+
r.remove(component)
|
|
1804
|
+
if op == 'move to top':
|
|
1805
|
+
r.insert(0, component)
|
|
1806
|
+
if op == 'move up':
|
|
1807
|
+
r.insert(lastLoc - 1, component)
|
|
1808
|
+
if op == 'move down':
|
|
1809
|
+
r.insert(lastLoc + 1, component)
|
|
1810
|
+
if op == 'move to bottom':
|
|
1811
|
+
r.append(component)
|
|
1812
|
+
self.frame.addToUndoStack("MOVED `%s`" %
|
|
1813
|
+
component.params['name'].val)
|
|
1814
|
+
self.redrawRoutine()
|
|
1815
|
+
self._menuComponent = None
|
|
1816
|
+
|
|
1817
|
+
def OnPaint(self, event):
|
|
1818
|
+
# Create a buffered paint DC. It will create the real
|
|
1819
|
+
# wx.PaintDC and then blit the bitmap to it when dc is
|
|
1820
|
+
# deleted.
|
|
1821
|
+
dc = wx.GCDC(wx.BufferedPaintDC(self))
|
|
1822
|
+
# we need to clear the dc BEFORE calling PrepareDC
|
|
1823
|
+
bg = wx.Brush(self.GetBackgroundColour())
|
|
1824
|
+
dc.SetBackground(bg)
|
|
1825
|
+
dc.Clear()
|
|
1826
|
+
# use PrepareDC to set position correctly
|
|
1827
|
+
self.PrepareDC(dc)
|
|
1828
|
+
# create a clipping rect from our position and size
|
|
1829
|
+
# and the Update Region
|
|
1830
|
+
xv, yv = self.GetViewStart()
|
|
1831
|
+
dx, dy = self.GetScrollPixelsPerUnit()
|
|
1832
|
+
x, y = (xv * dx, yv * dy)
|
|
1833
|
+
rgn = self.GetUpdateRegion()
|
|
1834
|
+
rgn.Offset(x, y)
|
|
1835
|
+
r = rgn.GetBox()
|
|
1836
|
+
# draw to the dc using the calculated clipping rect
|
|
1837
|
+
self.pdc.DrawToDCClipped(dc, r)
|
|
1838
|
+
|
|
1839
|
+
def redrawRoutine(self):
|
|
1840
|
+
self.pdc.Clear() # clear the screen
|
|
1841
|
+
self.pdc.RemoveAll() # clear all objects (icon buttons)
|
|
1842
|
+
|
|
1843
|
+
self.SetBackgroundColour(colors.app['tab_bg'])
|
|
1844
|
+
# work out where the component names and icons should be from name
|
|
1845
|
+
# lengths
|
|
1846
|
+
self.setFontSize(self.fontBaseSize // self.dpi, self.pdc)
|
|
1847
|
+
longest = 0
|
|
1848
|
+
w = 50
|
|
1849
|
+
for comp in self.routine:
|
|
1850
|
+
name = comp.params['name'].val
|
|
1851
|
+
if len(name) > longest:
|
|
1852
|
+
longest = len(name)
|
|
1853
|
+
w = self.GetFullTextExtent(name)[0]
|
|
1854
|
+
self.timeXpos = w + (50, 50, 90)[self.drawSize]
|
|
1855
|
+
|
|
1856
|
+
# separate components according to whether they are drawn in separate
|
|
1857
|
+
# row
|
|
1858
|
+
rowComponents = []
|
|
1859
|
+
staticCompons = []
|
|
1860
|
+
for n, component in enumerate(self.routine):
|
|
1861
|
+
if component.type == 'Static':
|
|
1862
|
+
staticCompons.append(component)
|
|
1863
|
+
else:
|
|
1864
|
+
rowComponents.append(component)
|
|
1865
|
+
|
|
1866
|
+
# draw static, time grid, normal (row) comp:
|
|
1867
|
+
yPos = self.yPosTop
|
|
1868
|
+
yPosBottom = yPos + len(rowComponents) * self.componentStep
|
|
1869
|
+
# draw any Static Components first (below the grid)
|
|
1870
|
+
for component in staticCompons:
|
|
1871
|
+
bottom = max(yPosBottom, self.GetSize()[1])
|
|
1872
|
+
self.drawStatic(self.pdc, component, yPos, bottom)
|
|
1873
|
+
self.drawTimeGrid(self.pdc, yPos, yPosBottom)
|
|
1874
|
+
# normal components, one per row
|
|
1875
|
+
for component in rowComponents:
|
|
1876
|
+
self.drawComponent(self.pdc, component, yPos)
|
|
1877
|
+
yPos += self.componentStep
|
|
1878
|
+
|
|
1879
|
+
# the 50 allows space for labels below the time axis
|
|
1880
|
+
self.SetVirtualSize((self.maxWidth, yPos + 50))
|
|
1881
|
+
self.Refresh() # refresh the visible window after drawing (OnPaint)
|
|
1882
|
+
#self.scroller.Resize()
|
|
1883
|
+
|
|
1884
|
+
def getMaxTime(self):
|
|
1885
|
+
"""Return the max time to be drawn in the window
|
|
1886
|
+
"""
|
|
1887
|
+
maxTime, nonSlip = self.routine.getMaxTime()
|
|
1888
|
+
if self.routine.hasOnlyStaticComp():
|
|
1889
|
+
maxTime = int(maxTime) + 1.0
|
|
1890
|
+
return maxTime
|
|
1891
|
+
|
|
1892
|
+
def drawTimeGrid(self, dc, yPosTop, yPosBottom, labelAbove=True):
|
|
1893
|
+
"""Draws the grid of lines and labels the time axes
|
|
1894
|
+
"""
|
|
1895
|
+
tMax = self.getMaxTime() * 1.1
|
|
1896
|
+
xScale = self.getSecsPerPixel()
|
|
1897
|
+
xSt = self.timeXposStart
|
|
1898
|
+
xEnd = self.timeXposEnd
|
|
1899
|
+
|
|
1900
|
+
# dc.SetId(wx.NewIdRef())
|
|
1901
|
+
dc.SetPen(wx.Pen(colors.app['rt_timegrid']))
|
|
1902
|
+
dc.SetTextForeground(wx.Colour(colors.app['rt_timegrid']))
|
|
1903
|
+
# draw horizontal lines on top and bottom
|
|
1904
|
+
dc.DrawLine(x1=xSt, y1=yPosTop,
|
|
1905
|
+
x2=xEnd, y2=yPosTop)
|
|
1906
|
+
dc.DrawLine(x1=xSt, y1=yPosBottom,
|
|
1907
|
+
x2=xEnd, y2=yPosBottom)
|
|
1908
|
+
# draw vertical time points
|
|
1909
|
+
# gives roughly 1/10 the width, but in rounded to base 10 of
|
|
1910
|
+
# 0.1,1,10...
|
|
1911
|
+
unitSize = 10 ** numpy.ceil(numpy.log10(tMax * 0.8)) / 10.0
|
|
1912
|
+
if tMax / unitSize < 3:
|
|
1913
|
+
# gives units of 2 (0.2,2,20)
|
|
1914
|
+
unitSize = 10 ** numpy.ceil(numpy.log10(tMax * 0.8)) / 50.0
|
|
1915
|
+
elif tMax / unitSize < 6:
|
|
1916
|
+
# gives units of 5 (0.5,5,50)
|
|
1917
|
+
unitSize = 10 ** numpy.ceil(numpy.log10(tMax * 0.8)) / 20.0
|
|
1918
|
+
for lineN in range(int(numpy.floor((tMax / unitSize)))):
|
|
1919
|
+
# vertical line:
|
|
1920
|
+
dc.DrawLine(xSt + lineN * unitSize / xScale, yPosTop - 4,
|
|
1921
|
+
xSt + lineN * unitSize / xScale, yPosBottom + 4)
|
|
1922
|
+
# label above:
|
|
1923
|
+
dc.DrawText('%.2g' % (lineN * unitSize), xSt + lineN *
|
|
1924
|
+
unitSize / xScale - 4, yPosTop - 30)
|
|
1925
|
+
if yPosBottom > 300:
|
|
1926
|
+
# if bottom of grid is far away then draw labels here too
|
|
1927
|
+
dc.DrawText('%.2g' % (lineN * unitSize), xSt + lineN *
|
|
1928
|
+
unitSize / xScale - 4, yPosBottom + 10)
|
|
1929
|
+
# add a label
|
|
1930
|
+
self.setFontSize(self.fontBaseSize // self.dpi, dc)
|
|
1931
|
+
# y is y-half height of text
|
|
1932
|
+
dc.DrawText('t (sec)', xEnd + 5,
|
|
1933
|
+
yPosTop - self.GetFullTextExtent('t')[1] / 2.0)
|
|
1934
|
+
# or draw bottom labels only if scrolling is turned on, virtual size >
|
|
1935
|
+
# available size?
|
|
1936
|
+
if yPosBottom > 300:
|
|
1937
|
+
# if bottom of grid is far away then draw labels there too
|
|
1938
|
+
# y is y-half height of text
|
|
1939
|
+
dc.DrawText('t (sec)', xEnd + 5,
|
|
1940
|
+
yPosBottom - self.GetFullTextExtent('t')[1] / 2.0)
|
|
1941
|
+
dc.SetTextForeground(colors.app['text'])
|
|
1942
|
+
|
|
1943
|
+
def setFontSize(self, size, dc):
|
|
1944
|
+
font = self.GetFont()
|
|
1945
|
+
font.SetPointSize(size)
|
|
1946
|
+
dc.SetFont(font)
|
|
1947
|
+
self.SetFont(font)
|
|
1948
|
+
|
|
1949
|
+
def drawStatic(self, dc, component, yPosTop, yPosBottom):
|
|
1950
|
+
"""draw a static (ISI) component box"""
|
|
1951
|
+
# set an id for the region of this component (so it can
|
|
1952
|
+
# act as a button). see if we created this already.
|
|
1953
|
+
id = None
|
|
1954
|
+
for key in self.componentFromID:
|
|
1955
|
+
if self.componentFromID[key] == component:
|
|
1956
|
+
id = key
|
|
1957
|
+
if not id: # then create one and add to the dict
|
|
1958
|
+
id = wx.NewIdRef()
|
|
1959
|
+
self.componentFromID[id] = component
|
|
1960
|
+
dc.SetId(id)
|
|
1961
|
+
# deduce start and stop times if possible
|
|
1962
|
+
startTime, duration, nonSlipSafe = component.getStartAndDuration()
|
|
1963
|
+
# ensure static comps are clickable (even if $code start or duration)
|
|
1964
|
+
unknownTiming = False
|
|
1965
|
+
if startTime is None:
|
|
1966
|
+
startTime = 0
|
|
1967
|
+
unknownTiming = True
|
|
1968
|
+
if duration is None:
|
|
1969
|
+
duration = 0 # minimal extent ensured below
|
|
1970
|
+
unknownTiming = True
|
|
1971
|
+
# calculate rectangle for component
|
|
1972
|
+
xScale = self.getSecsPerPixel()
|
|
1973
|
+
|
|
1974
|
+
if component.params['disabled'].val:
|
|
1975
|
+
dc.SetBrush(wx.Brush(colors.app['rt_static_disabled']))
|
|
1976
|
+
dc.SetPen(wx.Pen(colors.app['rt_static_disabled']))
|
|
1977
|
+
|
|
1978
|
+
else:
|
|
1979
|
+
dc.SetBrush(wx.Brush(colors.app['rt_static']))
|
|
1980
|
+
dc.SetPen(wx.Pen(colors.app['rt_static']))
|
|
1981
|
+
|
|
1982
|
+
xSt = self.timeXposStart + startTime // xScale
|
|
1983
|
+
w = duration // xScale + 1 # +1 b/c border alpha=0 in dc.SetPen
|
|
1984
|
+
w = max(min(w, 10000), 2) # ensure 2..10000 pixels
|
|
1985
|
+
h = yPosBottom - yPosTop
|
|
1986
|
+
# name label, position:
|
|
1987
|
+
name = component.params['name'].val # "ISI"
|
|
1988
|
+
if unknownTiming:
|
|
1989
|
+
# flag it as not literally represented in time, e.g., $code
|
|
1990
|
+
# duration
|
|
1991
|
+
name += ' ???'
|
|
1992
|
+
nameW, nameH = self.GetFullTextExtent(name)[0:2]
|
|
1993
|
+
x = xSt + w // 2
|
|
1994
|
+
staticLabelTop = (0, 50, 60)[self.drawSize]
|
|
1995
|
+
y = staticLabelTop - nameH * 3
|
|
1996
|
+
fullRect = wx.Rect(x - 20, y, nameW, nameH)
|
|
1997
|
+
# draw the rectangle, draw text on top:
|
|
1998
|
+
dc.DrawRectangle(xSt, yPosTop - nameH * 4, w, h + nameH * 5)
|
|
1999
|
+
dc.DrawText(name, x - nameW // 2, y)
|
|
2000
|
+
# update bounds to include time bar
|
|
2001
|
+
fullRect.Union(wx.Rect(xSt, yPosTop, w, h))
|
|
2002
|
+
dc.SetIdBounds(id, fullRect)
|
|
2003
|
+
|
|
2004
|
+
def drawComponent(self, dc, component, yPos):
|
|
2005
|
+
"""Draw the timing of one component on the timeline"""
|
|
2006
|
+
# set an id for the region of this component (so it
|
|
2007
|
+
# can act as a button). see if we created this already
|
|
2008
|
+
id = None
|
|
2009
|
+
for key in self.componentFromID:
|
|
2010
|
+
if self.componentFromID[key] == component:
|
|
2011
|
+
id = key
|
|
2012
|
+
if not id: # then create one and add to the dict
|
|
2013
|
+
id = wx.NewIdRef()
|
|
2014
|
+
self.componentFromID[id] = component
|
|
2015
|
+
dc.SetId(id)
|
|
2016
|
+
|
|
2017
|
+
iconYOffset = (6, 6, 0)[self.drawSize]
|
|
2018
|
+
# get default icon and bar color
|
|
2019
|
+
thisIcon = icons.ComponentIcon(component, size=self.iconSize).bitmap
|
|
2020
|
+
thisColor = colors.app['rt_comp']
|
|
2021
|
+
thisStyle = wx.BRUSHSTYLE_SOLID
|
|
2022
|
+
|
|
2023
|
+
# check True/False on ForceEndRoutine
|
|
2024
|
+
if 'forceEndRoutine' in component.params:
|
|
2025
|
+
if component.params['forceEndRoutine'].val:
|
|
2026
|
+
thisColor = colors.app['rt_comp_force']
|
|
2027
|
+
# check True/False on ForceEndRoutineOnPress
|
|
2028
|
+
if 'forceEndRoutineOnPress' in component.params:
|
|
2029
|
+
<<<<<<< HEAD
|
|
2030
|
+
if component.params['forceEndRoutineOnPress'].val:
|
|
2031
|
+
thisColor = colors.app['rt_comp_force']
|
|
2032
|
+
=======
|
|
2033
|
+
if component.params['forceEndRoutineOnPress'].val in ['any click', 'valid click']:
|
|
2034
|
+
thisColor = ThemeMixin.appColors['rt_comp_force']
|
|
2035
|
+
>>>>>>> e32726e790e8485ee63b1419cb6e567f086836b9
|
|
2036
|
+
# check True aliases on EndRoutineOn
|
|
2037
|
+
if 'endRoutineOn' in component.params:
|
|
2038
|
+
if component.params['endRoutineOn'].val in ['look at', 'look away']:
|
|
2039
|
+
thisColor = colors.app['rt_comp_force']
|
|
2040
|
+
# grey bar if comp is disabled
|
|
2041
|
+
if component.params['disabled'].val:
|
|
2042
|
+
thisIcon = thisIcon.ConvertToDisabled()
|
|
2043
|
+
thisColor = colors.app['rt_comp_disabled']
|
|
2044
|
+
|
|
2045
|
+
dc.DrawBitmap(thisIcon, self.iconXpos, yPos + iconYOffset, True)
|
|
2046
|
+
fullRect = wx.Rect(self.iconXpos, yPos,
|
|
2047
|
+
thisIcon.GetWidth(), thisIcon.GetHeight())
|
|
2048
|
+
|
|
2049
|
+
self.setFontSize(self.fontBaseSize // self.dpi, dc)
|
|
2050
|
+
|
|
2051
|
+
name = component.params['name'].val
|
|
2052
|
+
# get size based on text
|
|
2053
|
+
w, h = self.GetFullTextExtent(name)[0:2]
|
|
2054
|
+
if w > self.iconXpos - self.dpi/5:
|
|
2055
|
+
# If width is greater than space available, split word at point calculated by average letter width
|
|
2056
|
+
maxLen = int(
|
|
2057
|
+
(self.iconXpos - self.GetFullTextExtent("...")[0] - self.dpi/5)
|
|
2058
|
+
/ (w/len(name))
|
|
2059
|
+
)
|
|
2060
|
+
splitAt = int(maxLen/2)
|
|
2061
|
+
name = name[:splitAt] + "..." + name[-splitAt:]
|
|
2062
|
+
w = self.iconXpos - self.dpi/5
|
|
2063
|
+
# draw text
|
|
2064
|
+
# + x position of icon (left side)
|
|
2065
|
+
# - half width of icon (including whitespace around it)
|
|
2066
|
+
# - FULL width of text
|
|
2067
|
+
# + slight adjustment for whitespace
|
|
2068
|
+
x = self.iconXpos - thisIcon.GetWidth()/2 - w + thisIcon.GetWidth()/3
|
|
2069
|
+
_adjust = (5, 5, -2)[self.drawSize]
|
|
2070
|
+
y = yPos + thisIcon.GetHeight() // 2 - h // 2 + _adjust
|
|
2071
|
+
dc.DrawText(name, x, y)
|
|
2072
|
+
fullRect.Union(wx.Rect(x - 20, y, w, h))
|
|
2073
|
+
|
|
2074
|
+
# deduce start and stop times if possible
|
|
2075
|
+
startTime, duration, nonSlipSafe = component.getStartAndDuration()
|
|
2076
|
+
# draw entries on timeline (if they have some time definition)
|
|
2077
|
+
if duration is not None:
|
|
2078
|
+
# then we can draw a sensible time bar!
|
|
2079
|
+
thisPen = wx.Pen(thisColor, style=wx.TRANSPARENT)
|
|
2080
|
+
thisBrush = wx.Brush(thisColor, style=thisStyle)
|
|
2081
|
+
dc.SetPen(thisPen)
|
|
2082
|
+
dc.SetBrush(thisBrush)
|
|
2083
|
+
# If there's a fixed end time and no start time, start 20px before 0
|
|
2084
|
+
if ('stopType' in component.params) and ('startType' in component.params) and (
|
|
2085
|
+
component.params['stopType'].val in ('time (s)', 'duration (s)')
|
|
2086
|
+
and component.params['startType'].val in ('time (s)')
|
|
2087
|
+
and startTime is None
|
|
2088
|
+
):
|
|
2089
|
+
startTime = -20 * self.getSecsPerPixel()
|
|
2090
|
+
duration += 20 * self.getSecsPerPixel()
|
|
2091
|
+
# thisBrush.SetStyle(wx.BRUSHSTYLE_BDIAGONAL_HATCH)
|
|
2092
|
+
# dc.SetBrush(thisBrush)
|
|
2093
|
+
|
|
2094
|
+
if startTime is not None:
|
|
2095
|
+
xScale = self.getSecsPerPixel()
|
|
2096
|
+
yOffset = (3.5, 3.5, 0.5)[self.drawSize]
|
|
2097
|
+
h = self.componentStep // (4, 3.25, 2.5)[self.drawSize]
|
|
2098
|
+
xSt = self.timeXposStart + startTime // xScale
|
|
2099
|
+
w = duration // xScale + 1
|
|
2100
|
+
if w > 10000:
|
|
2101
|
+
w = 10000 # limit width to 10000 pixels!
|
|
2102
|
+
if w < 2:
|
|
2103
|
+
w = 2 # make sure at least one pixel shows
|
|
2104
|
+
dc.DrawRectangle(xSt, y + yOffset, w, h)
|
|
2105
|
+
# update bounds to include time bar
|
|
2106
|
+
fullRect.Union(wx.Rect(xSt, y + yOffset, w, h))
|
|
2107
|
+
dc.SetIdBounds(id, fullRect)
|
|
2108
|
+
|
|
2109
|
+
def copyCompon(self, event=None, component=None):
|
|
2110
|
+
"""This is easy - just take a copy of the component into memory
|
|
2111
|
+
"""
|
|
2112
|
+
self.app.copiedCompon = copy.deepcopy(component)
|
|
2113
|
+
|
|
2114
|
+
def pasteCompon(self, event=None, component=None, index=-1):
|
|
2115
|
+
if not self.app.copiedCompon:
|
|
2116
|
+
return -1 # not possible to paste if nothing copied
|
|
2117
|
+
exp = self.frame.exp
|
|
2118
|
+
origName = self.app.copiedCompon.params['name'].val
|
|
2119
|
+
defaultName = exp.namespace.makeValid(origName)
|
|
2120
|
+
msg = _translate('New name for copy of "%(copied)s"? [%(default)s]')
|
|
2121
|
+
vals = {'copied': origName, 'default': defaultName}
|
|
2122
|
+
message = msg % vals
|
|
2123
|
+
dlg = wx.TextEntryDialog(self, message=message,
|
|
2124
|
+
caption=_translate('Paste Component'))
|
|
2125
|
+
if dlg.ShowModal() == wx.ID_OK:
|
|
2126
|
+
newName = dlg.GetValue()
|
|
2127
|
+
newCompon = copy.deepcopy(self.app.copiedCompon)
|
|
2128
|
+
if not newName:
|
|
2129
|
+
newName = defaultName
|
|
2130
|
+
newName = exp.namespace.makeValid(newName)
|
|
2131
|
+
newCompon.params['name'].val = newName
|
|
2132
|
+
if 'name' in dir(newCompon):
|
|
2133
|
+
newCompon.name = newName
|
|
2134
|
+
self.routine.insertComponent(index, newCompon)
|
|
2135
|
+
self.frame.exp.namespace.user.append(newName)
|
|
2136
|
+
# could do redrawRoutines but would be slower?
|
|
2137
|
+
self.redrawRoutine()
|
|
2138
|
+
self.frame.addToUndoStack("PASTE Component `%s`" % newName)
|
|
2139
|
+
dlg.Destroy()
|
|
2140
|
+
|
|
2141
|
+
def editComponentProperties(self, event=None, component=None):
|
|
2142
|
+
# we got here from a wx.button press (rather than our own drawn icons)
|
|
2143
|
+
if event:
|
|
2144
|
+
componentName = event.EventObject.GetName()
|
|
2145
|
+
component = self.routine.getComponentFromName(componentName)
|
|
2146
|
+
# does this component have a help page?
|
|
2147
|
+
if hasattr(component, 'url'):
|
|
2148
|
+
helpUrl = component.url
|
|
2149
|
+
else:
|
|
2150
|
+
helpUrl = None
|
|
2151
|
+
old_name = component.params['name'].val
|
|
2152
|
+
old_disabled = component.params['disabled'].val
|
|
2153
|
+
# check current timing settings of component (if it changes we
|
|
2154
|
+
# need to update views)
|
|
2155
|
+
initialTimings = component.getStartAndDuration()
|
|
2156
|
+
if 'forceEndRoutine' in component.params \
|
|
2157
|
+
or 'forceEndRoutineOnPress' in component.params:
|
|
2158
|
+
# If component can force end routine, check if it did before
|
|
2159
|
+
initialForce = [component.params[key].val
|
|
2160
|
+
for key in ['forceEndRoutine', 'forceEndRoutineOnPress']
|
|
2161
|
+
if key in component.params]
|
|
2162
|
+
else:
|
|
2163
|
+
initialForce = False
|
|
2164
|
+
# create the dialog
|
|
2165
|
+
if hasattr(component, 'type') and component.type.lower() == 'code':
|
|
2166
|
+
_Dlg = DlgCodeComponentProperties
|
|
2167
|
+
else:
|
|
2168
|
+
_Dlg = DlgComponentProperties
|
|
2169
|
+
dlg = _Dlg(frame=self.frame,
|
|
2170
|
+
element=component,
|
|
2171
|
+
experiment=self.frame.exp, editing=True)
|
|
2172
|
+
if dlg.OK:
|
|
2173
|
+
# Redraw if force end routine has changed
|
|
2174
|
+
if any(key in component.params for key in ['forceEndRoutine', 'forceEndRoutineOnPress', 'endRoutineOn']):
|
|
2175
|
+
newForce = [component.params[key].val
|
|
2176
|
+
for key in ['forceEndRoutine', 'forceEndRoutineOnPress', 'endRoutineOn']
|
|
2177
|
+
if key in component.params]
|
|
2178
|
+
if initialForce != newForce:
|
|
2179
|
+
self.redrawRoutine() # need to refresh timings section
|
|
2180
|
+
self.Refresh() # then redraw visible
|
|
2181
|
+
self.frame.flowPanel.draw()
|
|
2182
|
+
# Redraw if timings have changed
|
|
2183
|
+
if component.getStartAndDuration() != initialTimings:
|
|
2184
|
+
self.redrawRoutine() # need to refresh timings section
|
|
2185
|
+
self.Refresh() # then redraw visible
|
|
2186
|
+
self.frame.flowPanel.draw()
|
|
2187
|
+
# self.frame.flowPanel.Refresh()
|
|
2188
|
+
elif component.params['name'].val != old_name:
|
|
2189
|
+
self.redrawRoutine() # need to refresh name
|
|
2190
|
+
elif component.params['disabled'].val != old_disabled:
|
|
2191
|
+
self.redrawRoutine() # need to refresh color
|
|
2192
|
+
self.frame.exp.namespace.remove(old_name)
|
|
2193
|
+
self.frame.exp.namespace.add(component.params['name'].val)
|
|
2194
|
+
self.frame.addToUndoStack("EDIT `%s`" %
|
|
2195
|
+
component.params['name'].val)
|
|
2196
|
+
|
|
2197
|
+
def getSecsPerPixel(self):
|
|
2198
|
+
pixels = float(self.timeXposEnd - self.timeXposStart)
|
|
2199
|
+
return self.getMaxTime() / pixels
|
|
2200
|
+
|
|
2201
|
+
|
|
2202
|
+
class StandaloneRoutineCanvas(scrolledpanel.ScrolledPanel, handlers.ThemeMixin):
|
|
2203
|
+
def __init__(self, parent, routine=None):
|
|
2204
|
+
# Init super
|
|
2205
|
+
scrolledpanel.ScrolledPanel.__init__(
|
|
2206
|
+
self, parent,
|
|
2207
|
+
style=wx.BORDER_NONE)
|
|
2208
|
+
# Store basics
|
|
2209
|
+
self.frame = parent.frame
|
|
2210
|
+
self.app = self.frame.app
|
|
2211
|
+
self.dpi = self.app.dpi
|
|
2212
|
+
self.routine = routine
|
|
2213
|
+
self.params = routine.params
|
|
2214
|
+
# Setup sizer
|
|
2215
|
+
self.sizer = wx.BoxSizer(wx.VERTICAL)
|
|
2216
|
+
self.SetSizer(self.sizer)
|
|
2217
|
+
# Setup categ notebook
|
|
2218
|
+
self.ctrls = ParamNotebook(self, experiment=self.frame.exp, element=routine)
|
|
2219
|
+
self.paramCtrls = self.ctrls.paramCtrls
|
|
2220
|
+
self.sizer.Add(self.ctrls, border=12, proportion=1, flag=wx.ALIGN_CENTER | wx.ALL)
|
|
2221
|
+
# Make buttons
|
|
2222
|
+
self.btnsSizer = wx.BoxSizer(wx.HORIZONTAL)
|
|
2223
|
+
# Add validator stuff
|
|
2224
|
+
self.warnings = WarningManager(self)
|
|
2225
|
+
self.sizer.Add(self.warnings.output, border=3, flag=wx.EXPAND | wx.ALL)
|
|
2226
|
+
# Add buttons to sizer
|
|
2227
|
+
self.sizer.Add(self.btnsSizer, border=6, proportion=0, flag=wx.ALIGN_RIGHT | wx.ALL)
|
|
2228
|
+
# Style
|
|
2229
|
+
self._applyAppTheme()
|
|
2230
|
+
self.SetupScrolling(scroll_y=True)
|
|
2231
|
+
|
|
2232
|
+
def updateExperiment(self, evt=None):
|
|
2233
|
+
"""Update this routine's saved parameters to what is currently entered"""
|
|
2234
|
+
# Get params in correct formats
|
|
2235
|
+
self.routine.params = self.ctrls.getParams()
|
|
2236
|
+
# Duplicate routine list and iterate through to find this one
|
|
2237
|
+
routines = self.frame.exp.routines.copy()
|
|
2238
|
+
for name, routine in routines.items():
|
|
2239
|
+
if routine == self.routine:
|
|
2240
|
+
# Update the routine dict keys to use the current name for this routine
|
|
2241
|
+
self.frame.exp.routines[self.routine.params['name'].val] = self.frame.exp.routines.pop(name)
|
|
2242
|
+
# Redraw the flow panel
|
|
2243
|
+
self.frame.flowPanel.draw()
|
|
2244
|
+
# Rename this page
|
|
2245
|
+
page = self.frame.routinePanel.GetPageIndex(self)
|
|
2246
|
+
self.frame.routinePanel.SetPageText(page, self.routine.params['name'].val)
|
|
2247
|
+
# Update save button
|
|
2248
|
+
self.frame.setIsModified(True)
|
|
2249
|
+
|
|
2250
|
+
def Validate(self, *args, **kwargs):
|
|
2251
|
+
return self.ctrls.Validate()
|
|
2252
|
+
|
|
2253
|
+
|
|
2254
|
+
class ComponentsPanel(scrolledpanel.ScrolledPanel, handlers.ThemeMixin):
|
|
2255
|
+
"""Panel containing buttons for each component, sorted by category"""
|
|
2256
|
+
|
|
2257
|
+
class CategoryButton(wx.ToggleButton, handlers.ThemeMixin, HoverMixin):
|
|
2258
|
+
"""Button to show/hide a category of components"""
|
|
2259
|
+
def __init__(self, parent, name, cat):
|
|
2260
|
+
if sys.platform == 'darwin':
|
|
2261
|
+
label = name # on macOS the wx.BU_LEFT flag has no effect
|
|
2262
|
+
else:
|
|
2263
|
+
label = " "+name
|
|
2264
|
+
# Initialise button
|
|
2265
|
+
wx.ToggleButton.__init__(self, parent,
|
|
2266
|
+
label=label, size=(-1, 24),
|
|
2267
|
+
style= wx.BORDER_NONE | wx.BU_LEFT)
|
|
2268
|
+
self.parent = parent
|
|
2269
|
+
# Link to category of buttons
|
|
2270
|
+
self.menu = self.parent.catSizers[cat]
|
|
2271
|
+
# # Set own sizer
|
|
2272
|
+
# self.sizer = wx.GridSizer(wx.HORIZONTAL)
|
|
2273
|
+
# self.SetSizer(self.sizer)
|
|
2274
|
+
# # Add icon
|
|
2275
|
+
# self.icon = wx.StaticText(parent=self, label="DOWN")
|
|
2276
|
+
# self.sizer.Add(self.icon, border=5, flag=wx.ALL | wx.ALIGN_RIGHT)
|
|
2277
|
+
# Default states to false
|
|
2278
|
+
self.state = False
|
|
2279
|
+
self.hover = False
|
|
2280
|
+
# Bind toggle function
|
|
2281
|
+
self.Bind(wx.EVT_TOGGLEBUTTON, self.ToggleMenu)
|
|
2282
|
+
# Bind hover functions
|
|
2283
|
+
self.SetupHover()
|
|
2284
|
+
|
|
2285
|
+
def ToggleMenu(self, event):
|
|
2286
|
+
# If triggered manually with a bool, treat that as a substitute for event selection
|
|
2287
|
+
if isinstance(event, bool):
|
|
2288
|
+
state = event
|
|
2289
|
+
else:
|
|
2290
|
+
state = event.GetSelection()
|
|
2291
|
+
self.SetValue(state)
|
|
2292
|
+
if state:
|
|
2293
|
+
# If state is show, then show all non-hidden components
|
|
2294
|
+
for btn in self.menu.GetChildren():
|
|
2295
|
+
btn = btn.GetWindow()
|
|
2296
|
+
if isinstance(btn, ComponentsPanel.ComponentButton):
|
|
2297
|
+
comp = btn.component
|
|
2298
|
+
elif isinstance(btn, ComponentsPanel.RoutineButton):
|
|
2299
|
+
comp = btn.routine
|
|
2300
|
+
else:
|
|
2301
|
+
return
|
|
2302
|
+
# Work out if it should be shown based on filter
|
|
2303
|
+
cond = True
|
|
2304
|
+
if self.parent.filter == 'Any':
|
|
2305
|
+
cond = True
|
|
2306
|
+
elif self.parent.filter == 'Both':
|
|
2307
|
+
cond = 'PsychoJS' in comp.targets and 'PsychoPy' in comp.targets
|
|
2308
|
+
elif self.parent.filter in ['PsychoPy', 'PsychoJS']:
|
|
2309
|
+
cond = self.parent.filter in comp.targets
|
|
2310
|
+
# Always hide if hidden by prefs
|
|
2311
|
+
if comp.__name__ in prefs.builder['hiddenComponents'] + alwaysHidden:
|
|
2312
|
+
cond = False
|
|
2313
|
+
btn.Show(cond)
|
|
2314
|
+
# # Update icon
|
|
2315
|
+
# self.icon.SetLabelText(chr(int("1401", 16)))
|
|
2316
|
+
else:
|
|
2317
|
+
# If state is hide, hide all components
|
|
2318
|
+
self.menu.ShowItems(False)
|
|
2319
|
+
# # Update icon
|
|
2320
|
+
# self.icon.SetLabelText(chr(int("140A", 16)))
|
|
2321
|
+
# Do layout
|
|
2322
|
+
self.parent.Layout()
|
|
2323
|
+
self.parent.SetupScrolling()
|
|
2324
|
+
# Restyle
|
|
2325
|
+
self.OnHover()
|
|
2326
|
+
|
|
2327
|
+
def _applyAppTheme(self):
|
|
2328
|
+
"""Apply app theme to this button"""
|
|
2329
|
+
self.OnHover()
|
|
2330
|
+
|
|
2331
|
+
class ComponentButton(wx.Button, handlers.ThemeMixin):
|
|
2332
|
+
"""Button to open component parameters dialog"""
|
|
2333
|
+
def __init__(self, parent, name, comp, cat):
|
|
2334
|
+
self.parent = parent
|
|
2335
|
+
self.component = comp
|
|
2336
|
+
self.category = cat
|
|
2337
|
+
# Get a shorter, title case version of component name
|
|
2338
|
+
label = name
|
|
2339
|
+
for redundant in ['component', 'Component', "ButtonBox"]:
|
|
2340
|
+
label = label.replace(redundant, "")
|
|
2341
|
+
label = prettyname(label, wrap=10)
|
|
2342
|
+
# Make button
|
|
2343
|
+
wx.Button.__init__(self, parent, wx.ID_ANY,
|
|
2344
|
+
label=label, name=name,
|
|
2345
|
+
size=(68, 68+12*label.count("\n")),
|
|
2346
|
+
style=wx.NO_BORDER)
|
|
2347
|
+
self.SetToolTip(wx.ToolTip(comp.tooltip or name))
|
|
2348
|
+
# Style
|
|
2349
|
+
self._applyAppTheme()
|
|
2350
|
+
# Bind to functions
|
|
2351
|
+
self.Bind(wx.EVT_BUTTON, self.onClick)
|
|
2352
|
+
self.Bind(wx.EVT_RIGHT_DOWN, self.onRightClick)
|
|
2353
|
+
|
|
2354
|
+
def onClick(self, evt=None, timeout=None):
|
|
2355
|
+
"""Called when a component button is clicked on.
|
|
2356
|
+
"""
|
|
2357
|
+
routine = self.parent.frame.routinePanel.getCurrentRoutine()
|
|
2358
|
+
if routine is None:
|
|
2359
|
+
if timeout is not None: # just return, we're testing the UI
|
|
2360
|
+
return
|
|
2361
|
+
# Show a message telling the user there is no routine in the
|
|
2362
|
+
# experiment, making adding a component pointless until they do
|
|
2363
|
+
# so.
|
|
2364
|
+
dlg = wx.MessageDialog(
|
|
2365
|
+
self,
|
|
2366
|
+
_translate(
|
|
2367
|
+
"Cannot add component, experiment has no routines."),
|
|
2368
|
+
_translate("Error"),
|
|
2369
|
+
wx.OK | wx.ICON_ERROR | wx.CENTRE)
|
|
2370
|
+
dlg.ShowModal()
|
|
2371
|
+
dlg.Destroy()
|
|
2372
|
+
return
|
|
2373
|
+
|
|
2374
|
+
page = self.parent.frame.routinePanel.getCurrentPage()
|
|
2375
|
+
comp = self.component(
|
|
2376
|
+
parentName=routine.name,
|
|
2377
|
+
exp=self.parent.frame.exp)
|
|
2378
|
+
|
|
2379
|
+
# does this component have a help page?
|
|
2380
|
+
if hasattr(comp, 'url'):
|
|
2381
|
+
helpUrl = comp.url
|
|
2382
|
+
else:
|
|
2383
|
+
helpUrl = None
|
|
2384
|
+
# create component template
|
|
2385
|
+
if comp.type == 'Code':
|
|
2386
|
+
_Dlg = DlgCodeComponentProperties
|
|
2387
|
+
else:
|
|
2388
|
+
_Dlg = DlgComponentProperties
|
|
2389
|
+
dlg = _Dlg(frame=self.parent.frame,
|
|
2390
|
+
element=comp,
|
|
2391
|
+
experiment=self.parent.frame.exp,
|
|
2392
|
+
timeout=timeout)
|
|
2393
|
+
|
|
2394
|
+
if dlg.OK:
|
|
2395
|
+
# Add to the actual routine
|
|
2396
|
+
routine.addComponent(comp)
|
|
2397
|
+
namespace = self.parent.frame.exp.namespace
|
|
2398
|
+
desiredName = comp.params['name'].val
|
|
2399
|
+
name = comp.params['name'].val = namespace.makeValid(desiredName)
|
|
2400
|
+
namespace.add(name)
|
|
2401
|
+
# update the routine's view with the new component too
|
|
2402
|
+
page.redrawRoutine()
|
|
2403
|
+
self.parent.frame.addToUndoStack(
|
|
2404
|
+
"ADD `%s` to `%s`" % (name, routine.name))
|
|
2405
|
+
return True
|
|
2406
|
+
|
|
2407
|
+
def onRightClick(self, evt):
|
|
2408
|
+
"""
|
|
2409
|
+
Defines rightclick behavior within builder view's
|
|
2410
|
+
components panel
|
|
2411
|
+
"""
|
|
2412
|
+
# Make menu
|
|
2413
|
+
menu = wx.Menu()
|
|
2414
|
+
if self.component.__name__ in self.parent.favorites:
|
|
2415
|
+
# If is in favs
|
|
2416
|
+
msg = "Remove from favorites"
|
|
2417
|
+
fun = self.removeFromFavorites
|
|
2418
|
+
else:
|
|
2419
|
+
# If is not in favs
|
|
2420
|
+
msg = "Add to favorites"
|
|
2421
|
+
fun = self.addToFavorites
|
|
2422
|
+
btn = menu.Append(wx.ID_ANY, _localized[msg])
|
|
2423
|
+
menu.Bind(wx.EVT_MENU, fun, btn)
|
|
2424
|
+
# Show as popup
|
|
2425
|
+
self.PopupMenu(menu, evt.GetPosition())
|
|
2426
|
+
# Destroy to avoid mem leak
|
|
2427
|
+
menu.Destroy()
|
|
2428
|
+
|
|
2429
|
+
def addToFavorites(self, evt):
|
|
2430
|
+
self.parent.addToFavorites(self.component)
|
|
2431
|
+
|
|
2432
|
+
def removeFromFavorites(self, evt):
|
|
2433
|
+
self.parent.removeFromFavorites(self)
|
|
2434
|
+
|
|
2435
|
+
def _applyAppTheme(self):
|
|
2436
|
+
# Set colors
|
|
2437
|
+
self.SetForegroundColour(colors.app['text'])
|
|
2438
|
+
self.SetBackgroundColour(colors.app['panel_bg'])
|
|
2439
|
+
# Set bitmap
|
|
2440
|
+
icon = icons.ComponentIcon(self.component, size=48)
|
|
2441
|
+
if hasattr(self.component, "beta") and self.component.beta:
|
|
2442
|
+
icon = icon.beta
|
|
2443
|
+
else:
|
|
2444
|
+
icon = icon.bitmap
|
|
2445
|
+
self.SetBitmap(icon)
|
|
2446
|
+
self.SetBitmapCurrent(icon)
|
|
2447
|
+
self.SetBitmapPressed(icon)
|
|
2448
|
+
self.SetBitmapFocus(icon)
|
|
2449
|
+
self.SetBitmapPosition(wx.TOP)
|
|
2450
|
+
# Refresh
|
|
2451
|
+
self.Refresh()
|
|
2452
|
+
|
|
2453
|
+
class RoutineButton(wx.Button, handlers.ThemeMixin):
|
|
2454
|
+
"""Button to open component parameters dialog"""
|
|
2455
|
+
def __init__(self, parent, name, rt, cat):
|
|
2456
|
+
self.parent = parent
|
|
2457
|
+
self.routine = rt
|
|
2458
|
+
self.category = cat
|
|
2459
|
+
# Get a shorter, title case version of routine name
|
|
2460
|
+
label = name
|
|
2461
|
+
for redundant in ['routine', 'Routine', "ButtonBox"]:
|
|
2462
|
+
label = label.replace(redundant, "")
|
|
2463
|
+
label = prettyname(label, wrap=10)
|
|
2464
|
+
# Make button
|
|
2465
|
+
wx.Button.__init__(self, parent, wx.ID_ANY,
|
|
2466
|
+
label=label, name=name,
|
|
2467
|
+
size=(68, 68+12*label.count("\n")),
|
|
2468
|
+
style=wx.NO_BORDER)
|
|
2469
|
+
self.SetToolTip(wx.ToolTip(rt.tooltip or name))
|
|
2470
|
+
# Style
|
|
2471
|
+
self._applyAppTheme()
|
|
2472
|
+
# Bind to functions
|
|
2473
|
+
self.Bind(wx.EVT_BUTTON, self.onClick)
|
|
2474
|
+
self.Bind(wx.EVT_RIGHT_DOWN, self.onRightClick)
|
|
2475
|
+
|
|
2476
|
+
def onClick(self, evt=None, timeout=None):
|
|
2477
|
+
# Make a routine instance
|
|
2478
|
+
comp = self.routine(exp=self.parent.frame.exp)
|
|
2479
|
+
# Add to the actual routine
|
|
2480
|
+
exp = self.parent.frame.exp
|
|
2481
|
+
namespace = exp.namespace
|
|
2482
|
+
name = comp.params['name'].val = namespace.makeValid(
|
|
2483
|
+
comp.params['name'].val)
|
|
2484
|
+
namespace.add(name)
|
|
2485
|
+
exp.addStandaloneRoutine(name, comp)
|
|
2486
|
+
# update the routine's view with the new routine too
|
|
2487
|
+
self.parent.frame.addToUndoStack(
|
|
2488
|
+
"ADD `%s` to `%s`" % (name, exp.name))
|
|
2489
|
+
# Add a routine page
|
|
2490
|
+
notebook = self.parent.frame.routinePanel
|
|
2491
|
+
notebook.addRoutinePage(name, comp)
|
|
2492
|
+
notebook.setCurrentRoutine(comp)
|
|
2493
|
+
|
|
2494
|
+
def onRightClick(self, evt):
|
|
2495
|
+
"""
|
|
2496
|
+
Defines rightclick behavior within builder view's
|
|
2497
|
+
routines panel
|
|
2498
|
+
"""
|
|
2499
|
+
return
|
|
2500
|
+
|
|
2501
|
+
def addToFavorites(self, evt):
|
|
2502
|
+
self.parent.addToFavorites(self.routine)
|
|
2503
|
+
|
|
2504
|
+
def removeFromFavorites(self, evt):
|
|
2505
|
+
self.parent.removeFromFavorites(self)
|
|
2506
|
+
|
|
2507
|
+
def _applyAppTheme(self):
|
|
2508
|
+
# Set colors
|
|
2509
|
+
self.SetForegroundColour(colors.app['text'])
|
|
2510
|
+
self.SetBackgroundColour(colors.app['panel_bg'])
|
|
2511
|
+
# Set bitmap
|
|
2512
|
+
icon = icons.ComponentIcon(self.routine, size=48)
|
|
2513
|
+
if hasattr(self.routine, "beta") and self.routine.beta:
|
|
2514
|
+
icon = icon.beta
|
|
2515
|
+
else:
|
|
2516
|
+
icon = icon.bitmap
|
|
2517
|
+
self.SetBitmap(icon)
|
|
2518
|
+
self.SetBitmapCurrent(icon)
|
|
2519
|
+
self.SetBitmapPressed(icon)
|
|
2520
|
+
self.SetBitmapFocus(icon)
|
|
2521
|
+
self.SetBitmapPosition(wx.TOP)
|
|
2522
|
+
# Refresh
|
|
2523
|
+
self.Refresh()
|
|
2524
|
+
|
|
2525
|
+
class FilterDialog(wx.Dialog, handlers.ThemeMixin):
|
|
2526
|
+
def __init__(self, parent, size=(200, 300)):
|
|
2527
|
+
wx.Dialog.__init__(self, parent, size=size)
|
|
2528
|
+
self.parent = parent
|
|
2529
|
+
# Setup sizer
|
|
2530
|
+
self.border = wx.BoxSizer(wx.VERTICAL)
|
|
2531
|
+
self.SetSizer(self.border)
|
|
2532
|
+
self.sizer = wx.BoxSizer(wx.VERTICAL)
|
|
2533
|
+
self.border.Add(self.sizer, border=6, proportion=1, flag=wx.ALL | wx.EXPAND)
|
|
2534
|
+
# Label
|
|
2535
|
+
self.label = wx.StaticText(self, label="Show components which \nwork with...")
|
|
2536
|
+
self.sizer.Add(self.label, border=6, flag=wx.ALL | wx.EXPAND)
|
|
2537
|
+
# Control
|
|
2538
|
+
self.viewCtrl = ToggleButtonArray(self,
|
|
2539
|
+
labels=("PsychoPy (local)", "PsychoJS (online)", "Both", "Any"),
|
|
2540
|
+
values=("PsychoPy", "PsychoJS", "Both", "Any"),
|
|
2541
|
+
multi=False, ori=wx.VERTICAL)
|
|
2542
|
+
self.viewCtrl.Bind(wx.EVT_CHOICE, self.onChange)
|
|
2543
|
+
self.sizer.Add(self.viewCtrl, border=6, flag=wx.ALL | wx.EXPAND)
|
|
2544
|
+
self.viewCtrl.SetValue(prefs.builder['componentFilter'])
|
|
2545
|
+
# OK
|
|
2546
|
+
self.OKbtn = wx.Button(self, id=wx.ID_OK, label=_translate("OK"))
|
|
2547
|
+
self.SetAffirmativeId(wx.ID_OK)
|
|
2548
|
+
self.border.Add(self.OKbtn, border=6, flag=wx.ALL | wx.ALIGN_RIGHT)
|
|
2549
|
+
|
|
2550
|
+
self.Layout()
|
|
2551
|
+
self._applyAppTheme()
|
|
2552
|
+
|
|
2553
|
+
def GetValue(self):
|
|
2554
|
+
return self.viewCtrl.GetValue()
|
|
2555
|
+
|
|
2556
|
+
def onChange(self, evt=None):
|
|
2557
|
+
self.parent.filter = prefs.builder['componentFilter'] = self.GetValue()
|
|
2558
|
+
prefs.saveUserPrefs()
|
|
2559
|
+
self.parent.Refresh()
|
|
2560
|
+
|
|
2561
|
+
def __init__(self, frame, id=-1):
|
|
2562
|
+
"""A panel that displays available components.
|
|
2563
|
+
"""
|
|
2564
|
+
self.frame = frame
|
|
2565
|
+
self.app = frame.app
|
|
2566
|
+
self.dpi = self.app.dpi
|
|
2567
|
+
self.prefs = self.app.prefs
|
|
2568
|
+
panelWidth = 3 * (68 + 12) + 12 + 12
|
|
2569
|
+
scrolledpanel.ScrolledPanel.__init__(self,
|
|
2570
|
+
frame,
|
|
2571
|
+
id,
|
|
2572
|
+
size=(panelWidth, 10 * self.dpi),
|
|
2573
|
+
style=wx.BORDER_NONE)
|
|
2574
|
+
# Get filter from prefs
|
|
2575
|
+
self.filter = prefs.builder['componentFilter']
|
|
2576
|
+
# Setup sizer
|
|
2577
|
+
self.sizer = wx.BoxSizer(wx.VERTICAL)
|
|
2578
|
+
self.SetSizer(self.sizer)
|
|
2579
|
+
# Add filter button
|
|
2580
|
+
self.filterBtn = wx.Button(self, size=(24, 24), style=wx.BORDER_NONE)
|
|
2581
|
+
self.sizer.Add(self.filterBtn, border=0, flag=wx.ALL | wx.ALIGN_RIGHT)
|
|
2582
|
+
self.filterBtn.Bind(wx.EVT_BUTTON, self.onFilterBtn)
|
|
2583
|
+
# Get components
|
|
2584
|
+
self.components = experiment.getAllComponents(
|
|
2585
|
+
self.app.prefs.builder['componentsFolders'])
|
|
2586
|
+
del self.components['SettingsComponent']
|
|
2587
|
+
self.routines = experiment.getAllStandaloneRoutines()
|
|
2588
|
+
# Get categories
|
|
2589
|
+
self.categories = getAllCategories(
|
|
2590
|
+
self.app.prefs.builder['componentsFolders'])
|
|
2591
|
+
for name, rt in self.routines.items():
|
|
2592
|
+
for cat in rt.categories:
|
|
2593
|
+
if cat not in self.categories:
|
|
2594
|
+
self.categories.append(cat)
|
|
2595
|
+
# Get favorites
|
|
2596
|
+
self.faveThreshold = 20
|
|
2597
|
+
self.faveLevels = self.prefs.appDataCfg['builder']['favComponents']
|
|
2598
|
+
self.favorites = []
|
|
2599
|
+
for comp in self.components:
|
|
2600
|
+
# Add component to favorite levels with a score of 0 if it's not already present
|
|
2601
|
+
if comp not in self.faveLevels:
|
|
2602
|
+
self.faveLevels[comp] = 0
|
|
2603
|
+
# Mark as a favorite if it exceeds a threshold
|
|
2604
|
+
if self.faveLevels[comp] > self.faveThreshold:
|
|
2605
|
+
self.favorites.append(comp)
|
|
2606
|
+
# Fill in gaps in favorites with defaults
|
|
2607
|
+
faveDefaults = ['ImageComponent', 'KeyboardComponent', 'SoundComponent',
|
|
2608
|
+
'TextComponent', 'MouseComponent', 'SliderComponent']
|
|
2609
|
+
while len(self.favorites) < 6:
|
|
2610
|
+
thisDef = faveDefaults.pop(0)
|
|
2611
|
+
if thisDef not in self.favorites:
|
|
2612
|
+
self.favorites.append(thisDef)
|
|
2613
|
+
# Make a sizer and label for each category
|
|
2614
|
+
self.catSizers = {cat: wx.WrapSizer(orient=wx.HORIZONTAL) for cat in self.categories}
|
|
2615
|
+
self.catLabels = {cat: self.CategoryButton(self, name=_translate(str(cat)), cat=str(cat)) for cat in self.categories}
|
|
2616
|
+
for cat in self.categories:
|
|
2617
|
+
self.sizer.Add(self.catLabels[cat], border=3, flag=wx.BOTTOM | wx.EXPAND)
|
|
2618
|
+
self.sizer.Add(self.catSizers[cat], border=6, flag=wx.ALL | wx.ALIGN_CENTER)
|
|
2619
|
+
# Make a button for each component
|
|
2620
|
+
self.compButtons = []
|
|
2621
|
+
for name, comp in self.components.items():
|
|
2622
|
+
for cat in comp.categories: # make one button for each category
|
|
2623
|
+
self.compButtons.append(
|
|
2624
|
+
self.ComponentButton(self, name, comp, cat)
|
|
2625
|
+
)
|
|
2626
|
+
if name in self.favorites:
|
|
2627
|
+
self.compButtons.append(
|
|
2628
|
+
self.ComponentButton(self, name, comp, "Favorites")
|
|
2629
|
+
)
|
|
2630
|
+
# Add component buttons to category sizers
|
|
2631
|
+
for btn in self.compButtons:
|
|
2632
|
+
self.catSizers[btn.category].Add(btn, border=3, flag=wx.ALL)
|
|
2633
|
+
# Make a button for each routine
|
|
2634
|
+
self.rtButtons = []
|
|
2635
|
+
for name, rt in self.routines.items():
|
|
2636
|
+
for cat in rt.categories: # make one button for each category
|
|
2637
|
+
self.rtButtons.append(
|
|
2638
|
+
self.RoutineButton(self, name, rt, cat)
|
|
2639
|
+
)
|
|
2640
|
+
if name in self.favorites:
|
|
2641
|
+
self.rtButtons.append(
|
|
2642
|
+
self.RoutineButton(self, name, rt, "Favorites")
|
|
2643
|
+
)
|
|
2644
|
+
# Add component buttons to category sizers
|
|
2645
|
+
for btn in self.rtButtons:
|
|
2646
|
+
self.catSizers[btn.category].Add(btn, border=3, flag=wx.ALL)
|
|
2647
|
+
# Show favourites on startup
|
|
2648
|
+
self.catLabels['Favorites'].ToggleMenu(True)
|
|
2649
|
+
# Do sizing
|
|
2650
|
+
self.Fit()
|
|
2651
|
+
# double buffered better rendering except if retina
|
|
2652
|
+
self.SetDoubleBuffered(not self.frame.isRetina)
|
|
2653
|
+
|
|
2654
|
+
def _applyAppTheme(self, target=None):
|
|
2655
|
+
# Style component panel
|
|
2656
|
+
self.SetForegroundColour(colors.app['text'])
|
|
2657
|
+
self.SetBackgroundColour(colors.app['panel_bg'])
|
|
2658
|
+
# Style category labels
|
|
2659
|
+
for lbl in self.catLabels:
|
|
2660
|
+
self.catLabels[lbl].SetForegroundColour(colors.app['text'])
|
|
2661
|
+
# Style filter button
|
|
2662
|
+
self.filterBtn.SetBackgroundColour(colors.app['panel_bg'])
|
|
2663
|
+
icon = icons.ButtonIcon("filter", size=16).bitmap
|
|
2664
|
+
self.filterBtn.SetBitmap(icon)
|
|
2665
|
+
self.filterBtn.SetBitmapCurrent(icon)
|
|
2666
|
+
self.filterBtn.SetBitmapPressed(icon)
|
|
2667
|
+
self.filterBtn.SetBitmapFocus(icon)
|
|
2668
|
+
|
|
2669
|
+
def addToFavorites(self, comp):
|
|
2670
|
+
name = comp.__name__
|
|
2671
|
+
# Mark component as a favorite
|
|
2672
|
+
self.faveLevels[name] = self.faveThreshold + 1
|
|
2673
|
+
self.favorites.append(name)
|
|
2674
|
+
# Add button to favorites menu
|
|
2675
|
+
btn = self.ComponentButton(self, name, comp, "Favorites")
|
|
2676
|
+
self.compButtons.append(btn)
|
|
2677
|
+
self.catSizers['Favorites'].Add(btn, border=3, flag=wx.ALL)
|
|
2678
|
+
# Do sizing
|
|
2679
|
+
self.Layout()
|
|
2680
|
+
|
|
2681
|
+
def removeFromFavorites(self, button):
|
|
2682
|
+
comp = button.component
|
|
2683
|
+
name = comp.__name__
|
|
2684
|
+
# Skip if component isn't in favorites
|
|
2685
|
+
if name not in self.favorites:
|
|
2686
|
+
return
|
|
2687
|
+
# Unmark component as favorite
|
|
2688
|
+
self.faveLevels[name] = 0
|
|
2689
|
+
self.favorites.remove(name)
|
|
2690
|
+
# Remove button from favorites menu
|
|
2691
|
+
button.Hide()
|
|
2692
|
+
# Do sizing
|
|
2693
|
+
self.Layout()
|
|
2694
|
+
|
|
2695
|
+
def enableComponents(self, enable=True):
|
|
2696
|
+
for button in self.compButtons:
|
|
2697
|
+
button.Enable(enable)
|
|
2698
|
+
self.Update()
|
|
2699
|
+
|
|
2700
|
+
def Refresh(self, eraseBackground=True, rect=None):
|
|
2701
|
+
wx.Window.Refresh(self, eraseBackground, rect)
|
|
2702
|
+
# Get view value(s)
|
|
2703
|
+
if prefs.builder['componentFilter'] == "Both":
|
|
2704
|
+
view = ["PsychoPy", "PsychoJS"]
|
|
2705
|
+
elif prefs.builder['componentFilter'] == "Any":
|
|
2706
|
+
view = []
|
|
2707
|
+
else:
|
|
2708
|
+
view = [prefs.builder['componentFilter']]
|
|
2709
|
+
# Toggle all categories so they refresh
|
|
2710
|
+
for btn in self.catLabels.values():
|
|
2711
|
+
btn.ToggleMenu(btn.GetValue())
|
|
2712
|
+
# If every button in a category is hidden, hide the category
|
|
2713
|
+
for cat, btn in self.catLabels.items():
|
|
2714
|
+
empty = True
|
|
2715
|
+
for child in self.catSizers[cat].Children:
|
|
2716
|
+
if isinstance(child.Window, self.ComponentButton):
|
|
2717
|
+
name = child.Window.component.__name__
|
|
2718
|
+
elif isinstance(child.Window, self.RoutineButton):
|
|
2719
|
+
name = child.Window.routine.__name__
|
|
2720
|
+
else:
|
|
2721
|
+
name = ""
|
|
2722
|
+
if name not in prefs.builder['hiddenComponents'] + alwaysHidden:
|
|
2723
|
+
empty = False
|
|
2724
|
+
btn.Show(not empty)
|
|
2725
|
+
# Do sizing
|
|
2726
|
+
self.Layout()
|
|
2727
|
+
self.SetupScrolling()
|
|
2728
|
+
|
|
2729
|
+
def onFilterBtn(self, evt=None):
|
|
2730
|
+
dlg = self.FilterDialog(self)
|
|
2731
|
+
dlg.ShowModal()
|
|
2732
|
+
|
|
2733
|
+
|
|
2734
|
+
class ReadmeFrame(wx.Frame):
|
|
2735
|
+
"""Defines construction of the Readme Frame"""
|
|
2736
|
+
|
|
2737
|
+
def __init__(self, parent):
|
|
2738
|
+
"""
|
|
2739
|
+
A frame for presenting/loading/saving readme files
|
|
2740
|
+
"""
|
|
2741
|
+
self.parent = parent
|
|
2742
|
+
title = "%s readme" % (parent.exp.name)
|
|
2743
|
+
self._fileLastModTime = None
|
|
2744
|
+
pos = wx.Point(parent.Position[0] + 80, parent.Position[1] + 80)
|
|
2745
|
+
_style = wx.DEFAULT_FRAME_STYLE | wx.FRAME_FLOAT_ON_PARENT
|
|
2746
|
+
wx.Frame.__init__(self, parent, title=title,
|
|
2747
|
+
size=(600, 500), pos=pos, style=_style)
|
|
2748
|
+
self.Bind(wx.EVT_CLOSE, self.onClose)
|
|
2749
|
+
self.Hide()
|
|
2750
|
+
# create icon
|
|
2751
|
+
if sys.platform == 'darwin':
|
|
2752
|
+
pass # doesn't work and not necessary - handled by app bundle
|
|
2753
|
+
else:
|
|
2754
|
+
iconFile = os.path.join(parent.paths['resources'], 'coder.ico')
|
|
2755
|
+
if os.path.isfile(iconFile):
|
|
2756
|
+
self.SetIcon(wx.Icon(iconFile, wx.BITMAP_TYPE_ICO))
|
|
2757
|
+
self.makeMenus()
|
|
2758
|
+
self.rawText = ""
|
|
2759
|
+
self.ctrl = HtmlWindow(self, wx.ID_ANY)
|
|
2760
|
+
self.ctrl.Bind(wx.html.EVT_HTML_LINK_CLICKED, self.onUrl)
|
|
2761
|
+
# Style
|
|
2762
|
+
self.ctrl.SetFonts(normal_face="Open Sans", fixed_face="JetBrains Mono", sizes=[8, 10, 12, 14, 16, 18, 20])
|
|
2763
|
+
|
|
2764
|
+
def onUrl(self, evt=None):
|
|
2765
|
+
webbrowser.open(evt.LinkInfo.Href)
|
|
2766
|
+
|
|
2767
|
+
def onClose(self, evt=None):
|
|
2768
|
+
"""
|
|
2769
|
+
Defines behavior on close of the Readme Frame
|
|
2770
|
+
"""
|
|
2771
|
+
self.parent.readmeFrame = None
|
|
2772
|
+
self.Destroy()
|
|
2773
|
+
|
|
2774
|
+
def makeMenus(self):
|
|
2775
|
+
"""Produces menus for the Readme Frame"""
|
|
2776
|
+
|
|
2777
|
+
# ---Menus---#000000#FFFFFF-------------------------------------------
|
|
2778
|
+
menuBar = wx.MenuBar()
|
|
2779
|
+
# ---_file---#000000#FFFFFF-------------------------------------------
|
|
2780
|
+
self.fileMenu = wx.Menu()
|
|
2781
|
+
menuBar.Append(self.fileMenu, _translate('&File'))
|
|
2782
|
+
menu = self.fileMenu
|
|
2783
|
+
keys = self.parent.app.keys
|
|
2784
|
+
menu.Append(wx.ID_EDIT, _translate("Edit"))
|
|
2785
|
+
self.Bind(wx.EVT_MENU, self.fileEdit, id=wx.ID_EDIT)
|
|
2786
|
+
menu.Append(wx.ID_CLOSE,
|
|
2787
|
+
_translate("&Close readme\t%s") % keys['close'])
|
|
2788
|
+
item = self.Bind(wx.EVT_MENU, self.toggleVisible, id=wx.ID_CLOSE)
|
|
2789
|
+
item = menu.Append(-1,
|
|
2790
|
+
_translate("&Toggle readme\t%s") % keys[
|
|
2791
|
+
'toggleReadme'],
|
|
2792
|
+
_translate("Toggle Readme"))
|
|
2793
|
+
self.Bind(wx.EVT_MENU, self.toggleVisible, item)
|
|
2794
|
+
self.SetMenuBar(menuBar)
|
|
2795
|
+
|
|
2796
|
+
def setFile(self, filename):
|
|
2797
|
+
"""Sets the readme file found with current builder experiment"""
|
|
2798
|
+
self.filename = filename
|
|
2799
|
+
self.expName = self.parent.exp.getExpName()
|
|
2800
|
+
# check we can read
|
|
2801
|
+
if filename is None: # check if we can write to the directory
|
|
2802
|
+
return False
|
|
2803
|
+
elif not os.path.exists(filename):
|
|
2804
|
+
with open(filename, "w") as f:
|
|
2805
|
+
f.write("")
|
|
2806
|
+
self.filename = filename
|
|
2807
|
+
return False
|
|
2808
|
+
elif not os.access(filename, os.R_OK):
|
|
2809
|
+
msg = "Found readme file (%s) no read permissions"
|
|
2810
|
+
logging.warning(msg % filename)
|
|
2811
|
+
return False
|
|
2812
|
+
# attempt to open
|
|
2813
|
+
try:
|
|
2814
|
+
f = codecs.open(filename, 'r', 'utf-8-sig')
|
|
2815
|
+
except IOError as err:
|
|
2816
|
+
msg = ("Found readme file for %s and appear to have"
|
|
2817
|
+
" permissions, but can't open")
|
|
2818
|
+
logging.warning(msg % self.expName)
|
|
2819
|
+
logging.warning(err)
|
|
2820
|
+
return False
|
|
2821
|
+
# attempt to read
|
|
2822
|
+
try:
|
|
2823
|
+
readmeText = f.read().replace("\r\n", "\n")
|
|
2824
|
+
except Exception:
|
|
2825
|
+
msg = ("Opened readme file for %s it but failed to read it "
|
|
2826
|
+
"(not text/unicode?)")
|
|
2827
|
+
logging.error(msg % self.expName)
|
|
2828
|
+
return False
|
|
2829
|
+
f.close()
|
|
2830
|
+
self._fileLastModTime = os.path.getmtime(filename)
|
|
2831
|
+
self.rawText = readmeText
|
|
2832
|
+
if md:
|
|
2833
|
+
renderedText = md.MarkdownIt().render(readmeText)
|
|
2834
|
+
else:
|
|
2835
|
+
renderedText = readmeText.replace("\n", "<br>")
|
|
2836
|
+
self.ctrl.SetPage(renderedText)
|
|
2837
|
+
self.SetTitle("%s readme (%s)" % (self.expName, filename))
|
|
2838
|
+
|
|
2839
|
+
def refresh(self, evt=None):
|
|
2840
|
+
if hasattr(self, 'filename'):
|
|
2841
|
+
self.setFile(self.filename)
|
|
2842
|
+
|
|
2843
|
+
def fileEdit(self, evt=None):
|
|
2844
|
+
self.parent.app.showCoder()
|
|
2845
|
+
coder = self.parent.app.coder
|
|
2846
|
+
if not self.filename:
|
|
2847
|
+
self.parent.updateReadme()
|
|
2848
|
+
coder.fileOpen(filename=self.filename)
|
|
2849
|
+
# Close README window
|
|
2850
|
+
self.Close()
|
|
2851
|
+
|
|
2852
|
+
def fileSave(self, evt=None):
|
|
2853
|
+
"""Defines save behavior for readme frame"""
|
|
2854
|
+
mtime = os.path.getmtime(self.filename)
|
|
2855
|
+
if self._fileLastModTime and mtime > self._fileLastModTime:
|
|
2856
|
+
logging.warning(
|
|
2857
|
+
'readme file has been changed by another program?')
|
|
2858
|
+
txt = self.rawText
|
|
2859
|
+
with codecs.open(self.filename, 'w', 'utf-8-sig') as f:
|
|
2860
|
+
f.write(txt)
|
|
2861
|
+
|
|
2862
|
+
def toggleVisible(self, evt=None):
|
|
2863
|
+
"""Defines visibility toggle for readme frame"""
|
|
2864
|
+
if self.IsShown():
|
|
2865
|
+
self.Hide()
|
|
2866
|
+
else:
|
|
2867
|
+
self.Show()
|
|
2868
|
+
|
|
2869
|
+
|
|
2870
|
+
class FlowPanel(wx.ScrolledWindow, handlers.ThemeMixin):
|
|
2871
|
+
|
|
2872
|
+
def __init__(self, frame, id=-1):
|
|
2873
|
+
"""A panel that shows how the routines will fit together
|
|
2874
|
+
"""
|
|
2875
|
+
self.frame = frame
|
|
2876
|
+
self.app = frame.app
|
|
2877
|
+
self.dpi = self.app.dpi
|
|
2878
|
+
wx.ScrolledWindow.__init__(self, frame, id, (0, 0),
|
|
2879
|
+
size=wx.Size(8 * self.dpi, 3 * self.dpi),
|
|
2880
|
+
style=wx.HSCROLL | wx.VSCROLL | wx.BORDER_NONE)
|
|
2881
|
+
self.needUpdate = True
|
|
2882
|
+
self.maxWidth = 50 * self.dpi
|
|
2883
|
+
self.maxHeight = 2 * self.dpi
|
|
2884
|
+
self.mousePos = None
|
|
2885
|
+
# if we're adding a loop or routine then add spots to timeline
|
|
2886
|
+
# self.drawNearestRoutinePoint = True
|
|
2887
|
+
# self.drawNearestLoopPoint = False
|
|
2888
|
+
# lists the x-vals of points to draw, eg loop locations:
|
|
2889
|
+
self.pointsToDraw = []
|
|
2890
|
+
# for flowSize, showLoopInfoInFlow:
|
|
2891
|
+
self.appData = self.app.prefs.appData
|
|
2892
|
+
|
|
2893
|
+
# self.SetAutoLayout(True)
|
|
2894
|
+
self.SetScrollRate(self.dpi / 4, self.dpi / 4)
|
|
2895
|
+
|
|
2896
|
+
# create a PseudoDC to record our drawing
|
|
2897
|
+
self.pdc = PseudoDC()
|
|
2898
|
+
if parse_version(wx.__version__) < parse_version('4.0.0a1'):
|
|
2899
|
+
self.pdc.DrawRoundedRectangle = self.pdc.DrawRoundedRectangleRect
|
|
2900
|
+
self.pen_cache = {}
|
|
2901
|
+
self.brush_cache = {}
|
|
2902
|
+
# vars for handling mouse clicks
|
|
2903
|
+
self.hitradius = 5
|
|
2904
|
+
self.dragid = -1
|
|
2905
|
+
self.entryPointPosList = []
|
|
2906
|
+
self.entryPointIDlist = []
|
|
2907
|
+
self.gapsExcluded = []
|
|
2908
|
+
# mode can also be 'loopPoint1','loopPoint2','routinePoint'
|
|
2909
|
+
self.mode = 'normal'
|
|
2910
|
+
self.insertingRoutine = ""
|
|
2911
|
+
|
|
2912
|
+
# for the context menu use the ID of the drawn icon to retrieve
|
|
2913
|
+
# the component (loop or routine)
|
|
2914
|
+
self.componentFromID = {}
|
|
2915
|
+
self.contextMenuLabels = {
|
|
2916
|
+
'remove': _translate('remove'),
|
|
2917
|
+
'rename': _translate('rename')}
|
|
2918
|
+
self.contextMenuItems = ['remove', 'rename']
|
|
2919
|
+
self.contextItemFromID = {}
|
|
2920
|
+
self.contextIDFromItem = {}
|
|
2921
|
+
for item in self.contextMenuItems:
|
|
2922
|
+
id = wx.NewIdRef()
|
|
2923
|
+
self.contextItemFromID[id] = item
|
|
2924
|
+
self.contextIDFromItem[item] = id
|
|
2925
|
+
|
|
2926
|
+
# self.btnInsertRoutine = wx.Button(self,-1,
|
|
2927
|
+
# 'Insert Routine', pos=(10,10))
|
|
2928
|
+
# self.btnInsertLoop = wx.Button(self,-1,'Insert Loop', pos=(10,30))
|
|
2929
|
+
labelRoutine = _translate('Insert Routine ')
|
|
2930
|
+
labelLoop = _translate('Insert Loop ')
|
|
2931
|
+
btnHeight = 50
|
|
2932
|
+
# Create add routine button
|
|
2933
|
+
self.btnInsertRoutine = PsychopyPlateBtn(
|
|
2934
|
+
self, -1, labelRoutine, pos=(10, 10), size=(120, btnHeight),
|
|
2935
|
+
style=platebtn.PB_STYLE_SQUARE
|
|
2936
|
+
)
|
|
2937
|
+
# Create add loop button
|
|
2938
|
+
self.btnInsertLoop = PsychopyPlateBtn(
|
|
2939
|
+
self, -1, labelLoop, pos=(10, btnHeight+20),
|
|
2940
|
+
size=(120, btnHeight),
|
|
2941
|
+
style=platebtn.PB_STYLE_SQUARE
|
|
2942
|
+
) # spaces give size for CANCEL
|
|
2943
|
+
|
|
2944
|
+
# use self.appData['flowSize'] to index a tuple to get a specific
|
|
2945
|
+
# value, eg: (4,6,8)[self.appData['flowSize']]
|
|
2946
|
+
self.flowMaxSize = 2 # upper limit on increaseSize
|
|
2947
|
+
|
|
2948
|
+
# bind events
|
|
2949
|
+
self.Bind(wx.EVT_MOUSE_EVENTS, self.OnMouse)
|
|
2950
|
+
self.Bind(wx.EVT_BUTTON, self.onInsertRoutine, self.btnInsertRoutine)
|
|
2951
|
+
self.Bind(wx.EVT_BUTTON, self.setLoopPoint1, self.btnInsertLoop)
|
|
2952
|
+
self.Bind(wx.EVT_PAINT, self.OnPaint)
|
|
2953
|
+
|
|
2954
|
+
idClear = wx.NewIdRef()
|
|
2955
|
+
self.Bind(wx.EVT_MENU, self.clearMode, id=idClear)
|
|
2956
|
+
aTable = wx.AcceleratorTable([
|
|
2957
|
+
(wx.ACCEL_NORMAL, wx.WXK_ESCAPE, idClear)
|
|
2958
|
+
])
|
|
2959
|
+
self.SetAcceleratorTable(aTable)
|
|
2960
|
+
|
|
2961
|
+
# double buffered better rendering except if retina
|
|
2962
|
+
self.SetDoubleBuffered(not self.frame.isRetina)
|
|
2963
|
+
|
|
2964
|
+
def _applyAppTheme(self, target=None):
|
|
2965
|
+
"""Apply any changes which have been made to the theme since panel was last loaded"""
|
|
2966
|
+
# Style loop/routine buttons
|
|
2967
|
+
self.btnInsertLoop.SetBackgroundColour(colors.app['frame_bg'])
|
|
2968
|
+
self.btnInsertLoop.SetForegroundColour(colors.app['text'])
|
|
2969
|
+
self.btnInsertLoop.Update()
|
|
2970
|
+
self.btnInsertRoutine.SetBackgroundColour(colors.app['frame_bg'])
|
|
2971
|
+
self.btnInsertRoutine.SetForegroundColour(colors.app['text'])
|
|
2972
|
+
self.btnInsertRoutine.Update()
|
|
2973
|
+
# Set background
|
|
2974
|
+
self.SetBackgroundColour(colors.app['panel_bg'])
|
|
2975
|
+
|
|
2976
|
+
self.draw()
|
|
2977
|
+
|
|
2978
|
+
def clearMode(self, event=None):
|
|
2979
|
+
"""If we were in middle of doing something (like inserting routine)
|
|
2980
|
+
then end it, allowing user to cancel
|
|
2981
|
+
"""
|
|
2982
|
+
self.mode = 'normal'
|
|
2983
|
+
self.insertingRoutine = None
|
|
2984
|
+
for id in self.entryPointIDlist:
|
|
2985
|
+
self.pdc.RemoveId(id)
|
|
2986
|
+
self.entryPointPosList = []
|
|
2987
|
+
self.entryPointIDlist = []
|
|
2988
|
+
self.gapsExcluded = []
|
|
2989
|
+
self.draw()
|
|
2990
|
+
self.frame.SetStatusText("")
|
|
2991
|
+
self.btnInsertRoutine.SetLabel(_translate('Insert Routine'))
|
|
2992
|
+
self.btnInsertRoutine.Update()
|
|
2993
|
+
self.btnInsertLoop.SetLabel(_translate('Insert Loop'))
|
|
2994
|
+
self.btnInsertRoutine.Update()
|
|
2995
|
+
|
|
2996
|
+
def ConvertEventCoords(self, event):
|
|
2997
|
+
xView, yView = self.GetViewStart()
|
|
2998
|
+
xDelta, yDelta = self.GetScrollPixelsPerUnit()
|
|
2999
|
+
return (event.GetX() + (xView * xDelta),
|
|
3000
|
+
event.GetY() + (yView * yDelta))
|
|
3001
|
+
|
|
3002
|
+
def OffsetRect(self, r):
|
|
3003
|
+
"""Offset the rectangle, r, to appear in the given position
|
|
3004
|
+
in the window
|
|
3005
|
+
"""
|
|
3006
|
+
xView, yView = self.GetViewStart()
|
|
3007
|
+
xDelta, yDelta = self.GetScrollPixelsPerUnit()
|
|
3008
|
+
r.Offset((-(xView * xDelta), -(yView * yDelta)))
|
|
3009
|
+
|
|
3010
|
+
def onInsertRoutine(self, evt):
|
|
3011
|
+
"""For when the insert Routine button is pressed - bring up
|
|
3012
|
+
dialog and present insertion point on flow line.
|
|
3013
|
+
see self.insertRoutine() for further info
|
|
3014
|
+
"""
|
|
3015
|
+
if self.mode.startswith('loopPoint'):
|
|
3016
|
+
self.clearMode()
|
|
3017
|
+
elif self.mode == 'routine':
|
|
3018
|
+
# clicked again with label now being "Cancel..."
|
|
3019
|
+
self.clearMode()
|
|
3020
|
+
return
|
|
3021
|
+
self.frame.SetStatusText(_translate(
|
|
3022
|
+
"Select a Routine to insert (Esc to exit)"))
|
|
3023
|
+
menu = wx.Menu()
|
|
3024
|
+
self.routinesFromID = {}
|
|
3025
|
+
id = wx.NewIdRef()
|
|
3026
|
+
menu.Append(id, '(new)')
|
|
3027
|
+
self.routinesFromID[id] = '(new)'
|
|
3028
|
+
menu.Bind(wx.EVT_MENU, self.insertNewRoutine, id=id)
|
|
3029
|
+
flow = self.frame.exp.flow
|
|
3030
|
+
for name, routine in self.frame.exp.routines.items():
|
|
3031
|
+
id = wx.NewIdRef()
|
|
3032
|
+
item = menu.Append(id, name)
|
|
3033
|
+
# Enable / disable each routine's button according to limits
|
|
3034
|
+
if hasattr(routine, "limit"):
|
|
3035
|
+
limitProgress = 0
|
|
3036
|
+
for rt in flow:
|
|
3037
|
+
limitProgress += int(isinstance(rt, type(routine)))
|
|
3038
|
+
item.Enable(limitProgress < routine.limit or routine in flow)
|
|
3039
|
+
self.routinesFromID[id] = name
|
|
3040
|
+
menu.Bind(wx.EVT_MENU, self.onInsertRoutineSelect, id=id)
|
|
3041
|
+
btnPos = self.btnInsertRoutine.GetRect()
|
|
3042
|
+
menuPos = (btnPos[0], btnPos[1] + btnPos[3])
|
|
3043
|
+
self.PopupMenu(menu, menuPos)
|
|
3044
|
+
menu.Bind(wx.EVT_MENU_CLOSE, self.clearMode)
|
|
3045
|
+
menu.Destroy() # destroy to avoid mem leak
|
|
3046
|
+
|
|
3047
|
+
def insertNewRoutine(self, event):
|
|
3048
|
+
"""selecting (new) is a short-cut for:
|
|
3049
|
+
make new routine, insert it into the flow
|
|
3050
|
+
"""
|
|
3051
|
+
newRoutine = self.frame.routinePanel.createNewRoutine()
|
|
3052
|
+
if newRoutine:
|
|
3053
|
+
self.routinesFromID[event.GetId()] = newRoutine
|
|
3054
|
+
self.onInsertRoutineSelect(event)
|
|
3055
|
+
else:
|
|
3056
|
+
self.clearMode()
|
|
3057
|
+
|
|
3058
|
+
def onInsertRoutineSelect(self, event):
|
|
3059
|
+
"""User has selected a routine to be entered so bring up the
|
|
3060
|
+
entrypoint marker and await mouse button press.
|
|
3061
|
+
see self.insertRoutine() for further info
|
|
3062
|
+
"""
|
|
3063
|
+
self.mode = 'routine'
|
|
3064
|
+
self.btnInsertRoutine.SetLabel(_translate('CANCEL Insert'))
|
|
3065
|
+
self.frame.SetStatusText(_translate(
|
|
3066
|
+
'Click where you want to insert the Routine, or CANCEL insert.'))
|
|
3067
|
+
self.insertingRoutine = self.routinesFromID[event.GetId()]
|
|
3068
|
+
x = self.getNearestGapPoint(0)
|
|
3069
|
+
self.drawEntryPoints([x])
|
|
3070
|
+
|
|
3071
|
+
def insertRoutine(self, ii):
|
|
3072
|
+
"""Insert a routine into the Flow knowing its name and location
|
|
3073
|
+
|
|
3074
|
+
onInsertRoutine() the button has been pressed so present menu
|
|
3075
|
+
onInsertRoutineSelect() user selected the name so present entry points
|
|
3076
|
+
OnMouse() user has selected a point on the timeline to insert entry
|
|
3077
|
+
|
|
3078
|
+
"""
|
|
3079
|
+
rtn = self.frame.exp.routines[self.insertingRoutine]
|
|
3080
|
+
self.frame.exp.flow.addRoutine(rtn, ii)
|
|
3081
|
+
self.frame.addToUndoStack("ADD Routine `%s`" % rtn.name)
|
|
3082
|
+
# reset flow drawing (remove entry point)
|
|
3083
|
+
self.clearMode()
|
|
3084
|
+
# enable/disable add loop button
|
|
3085
|
+
self.btnInsertLoop.Enable(bool(len(self.frame.exp.flow)))
|
|
3086
|
+
|
|
3087
|
+
def setLoopPoint1(self, evt=None):
|
|
3088
|
+
"""Someone pushed the insert loop button.
|
|
3089
|
+
Fetch the dialog
|
|
3090
|
+
"""
|
|
3091
|
+
if self.mode == 'routine':
|
|
3092
|
+
self.clearMode()
|
|
3093
|
+
# clicked again, label is "Cancel..."
|
|
3094
|
+
elif self.mode.startswith('loopPoint'):
|
|
3095
|
+
self.clearMode()
|
|
3096
|
+
return
|
|
3097
|
+
self.btnInsertLoop.SetLabel(_translate('CANCEL insert'))
|
|
3098
|
+
self.mode = 'loopPoint1'
|
|
3099
|
+
self.frame.SetStatusText(_translate(
|
|
3100
|
+
'Click where you want the loop to start/end, or CANCEL insert.'))
|
|
3101
|
+
x = self.getNearestGapPoint(0)
|
|
3102
|
+
self.drawEntryPoints([x])
|
|
3103
|
+
|
|
3104
|
+
def setLoopPoint2(self, evt=None):
|
|
3105
|
+
"""We have the location of the first point, waiting to get the second
|
|
3106
|
+
"""
|
|
3107
|
+
self.mode = 'loopPoint2'
|
|
3108
|
+
self.frame.SetStatusText(_translate(
|
|
3109
|
+
'Click the other end for the loop'))
|
|
3110
|
+
thisPos = self.entryPointPosList[0]
|
|
3111
|
+
self.gapsExcluded = [thisPos]
|
|
3112
|
+
self.gapsExcluded.extend(self.getGapPointsCrossingStreams(thisPos))
|
|
3113
|
+
# is there more than one available point
|
|
3114
|
+
diff = wx.GetMousePosition()[0] - self.GetScreenPosition()[0]
|
|
3115
|
+
x = self.getNearestGapPoint(diff, exclude=self.gapsExcluded)
|
|
3116
|
+
self.drawEntryPoints([self.entryPointPosList[0], x])
|
|
3117
|
+
nAvailableGaps = len(self.gapMidPoints) - len(self.gapsExcluded)
|
|
3118
|
+
if nAvailableGaps == 1:
|
|
3119
|
+
self.insertLoop() # there's only one place - use it
|
|
3120
|
+
|
|
3121
|
+
def insertLoop(self, evt=None):
|
|
3122
|
+
# bring up listbox to choose the routine to add, and / or a new one
|
|
3123
|
+
loopDlg = DlgLoopProperties(frame=self.frame,
|
|
3124
|
+
helpUrl=self.app.urls['builder.loops'])
|
|
3125
|
+
startII = self.gapMidPoints.index(min(self.entryPointPosList))
|
|
3126
|
+
endII = self.gapMidPoints.index(max(self.entryPointPosList))
|
|
3127
|
+
if loopDlg.OK:
|
|
3128
|
+
handler = loopDlg.currentHandler
|
|
3129
|
+
self.frame.exp.flow.addLoop(handler,
|
|
3130
|
+
startPos=startII, endPos=endII)
|
|
3131
|
+
action = "ADD Loop `%s` to Flow" % handler.params['name'].val
|
|
3132
|
+
self.frame.addToUndoStack(action)
|
|
3133
|
+
self.clearMode()
|
|
3134
|
+
self.draw()
|
|
3135
|
+
|
|
3136
|
+
def increaseSize(self, event=None):
|
|
3137
|
+
if self.appData['flowSize'] == self.flowMaxSize:
|
|
3138
|
+
self.appData['showLoopInfoInFlow'] = True
|
|
3139
|
+
self.appData['flowSize'] = min(
|
|
3140
|
+
self.flowMaxSize, self.appData['flowSize'] + 1)
|
|
3141
|
+
self.clearMode() # redraws
|
|
3142
|
+
|
|
3143
|
+
def decreaseSize(self, event=None):
|
|
3144
|
+
if self.appData['flowSize'] == 0:
|
|
3145
|
+
self.appData['showLoopInfoInFlow'] = False
|
|
3146
|
+
self.appData['flowSize'] = max(0, self.appData['flowSize'] - 1)
|
|
3147
|
+
self.clearMode() # redraws
|
|
3148
|
+
|
|
3149
|
+
def editLoopProperties(self, event=None, loop=None):
|
|
3150
|
+
# add routine points to the timeline
|
|
3151
|
+
self.setDrawPoints('loops')
|
|
3152
|
+
self.draw()
|
|
3153
|
+
if 'conditions' in loop.params:
|
|
3154
|
+
condOrig = loop.params['conditions'].val
|
|
3155
|
+
condFileOrig = loop.params['conditionsFile'].val
|
|
3156
|
+
title = loop.params['name'].val + ' Properties'
|
|
3157
|
+
loopDlg = DlgLoopProperties(frame=self.frame,
|
|
3158
|
+
helpUrl=self.app.urls['builder.loops'],
|
|
3159
|
+
title=title, loop=loop)
|
|
3160
|
+
if loopDlg.OK:
|
|
3161
|
+
prevLoop = loop
|
|
3162
|
+
if loopDlg.params['loopType'].val == 'staircase':
|
|
3163
|
+
loop = loopDlg.stairHandler
|
|
3164
|
+
elif loopDlg.params['loopType'].val == 'interleaved staircases':
|
|
3165
|
+
loop = loopDlg.multiStairHandler
|
|
3166
|
+
else:
|
|
3167
|
+
# ['random','sequential', 'fullRandom', ]
|
|
3168
|
+
loop = loopDlg.trialHandler
|
|
3169
|
+
# if the loop is a whole new class then we can't just update the
|
|
3170
|
+
# params
|
|
3171
|
+
if loop.getType() != prevLoop.getType():
|
|
3172
|
+
# get indices for start and stop points of prev loop
|
|
3173
|
+
flow = self.frame.exp.flow
|
|
3174
|
+
# find the index of the initiator
|
|
3175
|
+
startII = flow.index(prevLoop.initiator)
|
|
3176
|
+
# minus one because initiator will have been deleted
|
|
3177
|
+
endII = flow.index(prevLoop.terminator) - 1
|
|
3178
|
+
# remove old loop completely
|
|
3179
|
+
flow.removeComponent(prevLoop)
|
|
3180
|
+
# finally insert the new loop
|
|
3181
|
+
flow.addLoop(loop, startII, endII)
|
|
3182
|
+
self.frame.addToUndoStack("EDIT Loop `%s`" %
|
|
3183
|
+
(loop.params['name'].val))
|
|
3184
|
+
elif 'conditions' in loop.params:
|
|
3185
|
+
loop.params['conditions'].val = condOrig
|
|
3186
|
+
loop.params['conditionsFile'].val = condFileOrig
|
|
3187
|
+
# remove the points from the timeline
|
|
3188
|
+
self.setDrawPoints(None)
|
|
3189
|
+
self.draw()
|
|
3190
|
+
|
|
3191
|
+
def OnMouse(self, event):
|
|
3192
|
+
x, y = self.ConvertEventCoords(event)
|
|
3193
|
+
handlerTypes = ('StairHandler', 'TrialHandler', 'MultiStairHandler')
|
|
3194
|
+
if self.mode == 'normal':
|
|
3195
|
+
if event.LeftDown():
|
|
3196
|
+
icons = self.pdc.FindObjectsByBBox(x, y)
|
|
3197
|
+
for thisIcon in icons:
|
|
3198
|
+
# might intersect several and only one has a callback
|
|
3199
|
+
if thisIcon in self.componentFromID:
|
|
3200
|
+
comp = self.componentFromID[thisIcon]
|
|
3201
|
+
if comp.getType() in handlerTypes:
|
|
3202
|
+
self.editLoopProperties(loop=comp)
|
|
3203
|
+
if comp.getType() in ['Routine'] + list(getAllStandaloneRoutines()):
|
|
3204
|
+
self.frame.routinePanel.setCurrentRoutine(
|
|
3205
|
+
routine=comp)
|
|
3206
|
+
elif event.RightDown():
|
|
3207
|
+
icons = self.pdc.FindObjectsByBBox(x, y)
|
|
3208
|
+
# todo: clean-up remove `comp`, its unused
|
|
3209
|
+
comp = None
|
|
3210
|
+
for thisIcon in icons:
|
|
3211
|
+
# might intersect several and only one has a callback
|
|
3212
|
+
if thisIcon in self.componentFromID:
|
|
3213
|
+
# loop through comps looking for Routine, or a Loop if
|
|
3214
|
+
# no routine
|
|
3215
|
+
thisComp = self.componentFromID[thisIcon]
|
|
3216
|
+
if thisComp.getType() in handlerTypes:
|
|
3217
|
+
comp = thisComp # unused
|
|
3218
|
+
icon = thisIcon
|
|
3219
|
+
if thisComp.getType() in ['Routine'] + list(getAllStandaloneRoutines()):
|
|
3220
|
+
comp = thisComp
|
|
3221
|
+
icon = thisIcon
|
|
3222
|
+
break # we've found a Routine so stop looking
|
|
3223
|
+
self.frame.routinePanel.setCurrentRoutine(comp)
|
|
3224
|
+
try:
|
|
3225
|
+
self._menuComponentID = icon
|
|
3226
|
+
xy = wx.Point(event.X + self.GetPosition()[0],
|
|
3227
|
+
event.Y + self.GetPosition()[1])
|
|
3228
|
+
self.showContextMenu(self._menuComponentID, xy=xy)
|
|
3229
|
+
except UnboundLocalError:
|
|
3230
|
+
# right click but not on an icon
|
|
3231
|
+
# might as well do something
|
|
3232
|
+
self.Refresh()
|
|
3233
|
+
elif self.mode == 'routine':
|
|
3234
|
+
if event.LeftDown():
|
|
3235
|
+
pt = self.entryPointPosList[0]
|
|
3236
|
+
self.insertRoutine(ii=self.gapMidPoints.index(pt))
|
|
3237
|
+
else: # move spot if needed
|
|
3238
|
+
point = self.getNearestGapPoint(mouseX=x)
|
|
3239
|
+
self.drawEntryPoints([point])
|
|
3240
|
+
elif self.mode == 'loopPoint1':
|
|
3241
|
+
if event.LeftDown():
|
|
3242
|
+
self.setLoopPoint2()
|
|
3243
|
+
else: # move spot if needed
|
|
3244
|
+
point = self.getNearestGapPoint(mouseX=x)
|
|
3245
|
+
self.drawEntryPoints([point])
|
|
3246
|
+
elif self.mode == 'loopPoint2':
|
|
3247
|
+
if event.LeftDown():
|
|
3248
|
+
self.insertLoop()
|
|
3249
|
+
else: # move spot if needed
|
|
3250
|
+
point = self.getNearestGapPoint(mouseX=x,
|
|
3251
|
+
exclude=self.gapsExcluded)
|
|
3252
|
+
self.drawEntryPoints([self.entryPointPosList[0], point])
|
|
3253
|
+
|
|
3254
|
+
def getNearestGapPoint(self, mouseX, exclude=()):
|
|
3255
|
+
"""Get gap that is nearest to a particular mouse location
|
|
3256
|
+
"""
|
|
3257
|
+
d = 1000000000
|
|
3258
|
+
nearest = None
|
|
3259
|
+
for point in self.gapMidPoints:
|
|
3260
|
+
if point in exclude:
|
|
3261
|
+
continue
|
|
3262
|
+
if (point - mouseX) ** 2 < d:
|
|
3263
|
+
d = (point - mouseX) ** 2
|
|
3264
|
+
nearest = point
|
|
3265
|
+
return nearest
|
|
3266
|
+
|
|
3267
|
+
def getGapPointsCrossingStreams(self, gapPoint):
|
|
3268
|
+
"""For a given gap point, identify the gap points that are
|
|
3269
|
+
excluded by crossing a loop line
|
|
3270
|
+
"""
|
|
3271
|
+
gapArray = numpy.array(self.gapMidPoints)
|
|
3272
|
+
nestLevels = numpy.array(self.gapNestLevels)
|
|
3273
|
+
thisLevel = nestLevels[gapArray == gapPoint]
|
|
3274
|
+
invalidGaps = (gapArray[nestLevels != thisLevel]).tolist()
|
|
3275
|
+
return invalidGaps
|
|
3276
|
+
|
|
3277
|
+
def showContextMenu(self, component, xy):
|
|
3278
|
+
menu = wx.Menu()
|
|
3279
|
+
# get ID
|
|
3280
|
+
# the ID is also the index to the element in the flow list
|
|
3281
|
+
compID = self._menuComponentID
|
|
3282
|
+
flow = self.frame.exp.flow
|
|
3283
|
+
component = flow[compID]
|
|
3284
|
+
compType = component.getType()
|
|
3285
|
+
if compType == 'Routine':
|
|
3286
|
+
for item in self.contextMenuItems:
|
|
3287
|
+
id = self.contextIDFromItem[item]
|
|
3288
|
+
menu.Append(id, self.contextMenuLabels[item])
|
|
3289
|
+
menu.Bind(wx.EVT_MENU, self.onContextSelect, id=id)
|
|
3290
|
+
self.frame.PopupMenu(menu, xy)
|
|
3291
|
+
# destroy to avoid mem leak:
|
|
3292
|
+
menu.Destroy()
|
|
3293
|
+
else:
|
|
3294
|
+
for item in self.contextMenuItems:
|
|
3295
|
+
if item == 'rename':
|
|
3296
|
+
continue
|
|
3297
|
+
id = self.contextIDFromItem[item]
|
|
3298
|
+
menu.Append(id, self.contextMenuLabels[item])
|
|
3299
|
+
menu.Bind(wx.EVT_MENU, self.onContextSelect, id=id)
|
|
3300
|
+
self.frame.PopupMenu(menu, xy)
|
|
3301
|
+
# destroy to avoid mem leak:
|
|
3302
|
+
menu.Destroy()
|
|
3303
|
+
|
|
3304
|
+
def onContextSelect(self, event):
|
|
3305
|
+
"""Perform a given action on the component chosen
|
|
3306
|
+
"""
|
|
3307
|
+
# get ID
|
|
3308
|
+
op = self.contextItemFromID[event.GetId()]
|
|
3309
|
+
# the ID is also the index to the element in the flow list
|
|
3310
|
+
compID = self._menuComponentID
|
|
3311
|
+
flow = self.frame.exp.flow
|
|
3312
|
+
component = flow[compID]
|
|
3313
|
+
# if we have a Loop Initiator, remove the whole loop
|
|
3314
|
+
if component.getType() == 'LoopInitiator':
|
|
3315
|
+
component = component.loop
|
|
3316
|
+
if op == 'remove':
|
|
3317
|
+
self.removeComponent(component, compID)
|
|
3318
|
+
self.frame.addToUndoStack(
|
|
3319
|
+
"REMOVE `%s` from Flow" % component.params['name'])
|
|
3320
|
+
if op == 'rename':
|
|
3321
|
+
self.frame.renameRoutine(component)
|
|
3322
|
+
|
|
3323
|
+
def removeComponent(self, component, compID):
|
|
3324
|
+
"""Remove either a Routine or a Loop from the Flow
|
|
3325
|
+
"""
|
|
3326
|
+
flow = self.frame.exp.flow
|
|
3327
|
+
if component.getType() in ['Routine'] + list(getAllStandaloneRoutines()):
|
|
3328
|
+
# check whether this will cause a collapsed loop
|
|
3329
|
+
# prev and next elements on flow are a loop init/end
|
|
3330
|
+
prevIsLoop = nextIsLoop = False
|
|
3331
|
+
if compID > 0: # there is at least one preceding
|
|
3332
|
+
prevIsLoop = (flow[compID - 1]).getType() == 'LoopInitiator'
|
|
3333
|
+
if len(flow) > (compID + 1): # there is at least one more compon
|
|
3334
|
+
nextIsLoop = (flow[compID + 1]).getType() == 'LoopTerminator'
|
|
3335
|
+
if prevIsLoop and nextIsLoop:
|
|
3336
|
+
# because flow[compID+1] is a terminator
|
|
3337
|
+
loop = flow[compID + 1].loop
|
|
3338
|
+
msg = _translate('The "%s" Loop is about to be deleted as '
|
|
3339
|
+
'well (by collapsing). OK to proceed?')
|
|
3340
|
+
title = _translate('Impending Loop collapse')
|
|
3341
|
+
warnDlg = dialogs.MessageDialog(
|
|
3342
|
+
parent=self.frame, message=msg % loop.params['name'],
|
|
3343
|
+
type='Warning', title=title)
|
|
3344
|
+
resp = warnDlg.ShowModal()
|
|
3345
|
+
if resp in [wx.ID_CANCEL, wx.ID_NO]:
|
|
3346
|
+
return # abort
|
|
3347
|
+
elif resp == wx.ID_YES:
|
|
3348
|
+
# make recursive calls to this same method until success
|
|
3349
|
+
# remove the loop first
|
|
3350
|
+
self.removeComponent(loop, compID)
|
|
3351
|
+
# because the loop has been removed ID is now one less
|
|
3352
|
+
self.removeComponent(component, compID - 1)
|
|
3353
|
+
return # have done the removal in final successful call
|
|
3354
|
+
# remove name from namespace only if it's a loop;
|
|
3355
|
+
# loops exist only in the flow
|
|
3356
|
+
elif 'conditionsFile' in component.params:
|
|
3357
|
+
conditionsFile = component.params['conditionsFile'].val
|
|
3358
|
+
if conditionsFile and conditionsFile not in ['None', '']:
|
|
3359
|
+
try:
|
|
3360
|
+
trialList, fieldNames = data.importConditions(
|
|
3361
|
+
conditionsFile, returnFieldNames=True)
|
|
3362
|
+
for fname in fieldNames:
|
|
3363
|
+
self.frame.exp.namespace.remove(fname)
|
|
3364
|
+
except Exception:
|
|
3365
|
+
msg = ("Conditions file %s couldn't be found so names not"
|
|
3366
|
+
" removed from namespace")
|
|
3367
|
+
logging.debug(msg % conditionsFile)
|
|
3368
|
+
self.frame.exp.namespace.remove(component.params['name'].val)
|
|
3369
|
+
# perform the actual removal
|
|
3370
|
+
flow.removeComponent(component, id=compID)
|
|
3371
|
+
self.draw()
|
|
3372
|
+
# enable/disable add loop button
|
|
3373
|
+
self.btnInsertLoop.Enable(bool(len(flow)))
|
|
3374
|
+
|
|
3375
|
+
def OnPaint(self, event):
|
|
3376
|
+
# Create a buffered paint DC. It will create the real
|
|
3377
|
+
# wx.PaintDC and then blit the bitmap to it when dc is
|
|
3378
|
+
# deleted.
|
|
3379
|
+
dc = wx.GCDC(wx.BufferedPaintDC(self))
|
|
3380
|
+
# use PrepareDC to set position correctly
|
|
3381
|
+
self.PrepareDC(dc)
|
|
3382
|
+
# we need to clear the dc BEFORE calling PrepareDC
|
|
3383
|
+
bg = wx.Brush(self.GetBackgroundColour())
|
|
3384
|
+
dc.SetBackground(bg)
|
|
3385
|
+
dc.Clear()
|
|
3386
|
+
# create a clipping rect from our position and size
|
|
3387
|
+
# and the Update Region
|
|
3388
|
+
xv, yv = self.GetViewStart()
|
|
3389
|
+
dx, dy = self.GetScrollPixelsPerUnit()
|
|
3390
|
+
x, y = (xv * dx, yv * dy)
|
|
3391
|
+
rgn = self.GetUpdateRegion()
|
|
3392
|
+
rgn.Offset(x, y)
|
|
3393
|
+
r = rgn.GetBox()
|
|
3394
|
+
# draw to the dc using the calculated clipping rect
|
|
3395
|
+
self.pdc.DrawToDCClipped(dc, r)
|
|
3396
|
+
|
|
3397
|
+
def draw(self, evt=None):
|
|
3398
|
+
"""This is the main function for drawing the Flow panel.
|
|
3399
|
+
It should be called whenever something changes in the exp.
|
|
3400
|
+
|
|
3401
|
+
This then makes calls to other drawing functions,
|
|
3402
|
+
like drawEntryPoints...
|
|
3403
|
+
"""
|
|
3404
|
+
if not hasattr(self.frame, 'exp'):
|
|
3405
|
+
# we haven't yet added an exp
|
|
3406
|
+
return
|
|
3407
|
+
# retrieve the current flow from the experiment
|
|
3408
|
+
expFlow = self.frame.exp.flow
|
|
3409
|
+
pdc = self.pdc
|
|
3410
|
+
|
|
3411
|
+
# use the ID of the drawn icon to retrieve component (loop or routine)
|
|
3412
|
+
self.componentFromID = {}
|
|
3413
|
+
|
|
3414
|
+
pdc.Clear() # clear the screen
|
|
3415
|
+
pdc.RemoveAll() # clear all objects (icon buttons)
|
|
3416
|
+
|
|
3417
|
+
font = self.GetFont()
|
|
3418
|
+
|
|
3419
|
+
# draw the main time line
|
|
3420
|
+
self.linePos = (2.5 * self.dpi, 0.5 * self.dpi) # x,y of start
|
|
3421
|
+
gap = self.dpi / (6, 4, 2)[self.appData['flowSize']]
|
|
3422
|
+
dLoopToBaseLine = (15, 25, 43)[self.appData['flowSize']]
|
|
3423
|
+
dBetweenLoops = (20, 24, 30)[self.appData['flowSize']]
|
|
3424
|
+
|
|
3425
|
+
# guess virtual size; nRoutines wide by nLoops high
|
|
3426
|
+
# make bigger than needed and shrink later
|
|
3427
|
+
nRoutines = len(expFlow)
|
|
3428
|
+
nLoops = 0
|
|
3429
|
+
for entry in expFlow:
|
|
3430
|
+
if entry.getType() == 'LoopInitiator':
|
|
3431
|
+
nLoops += 1
|
|
3432
|
+
sizeX = nRoutines * self.dpi * 2
|
|
3433
|
+
sizeY = nLoops * dBetweenLoops + dLoopToBaseLine * 3
|
|
3434
|
+
self.SetVirtualSize(size=(sizeX, sizeY))
|
|
3435
|
+
|
|
3436
|
+
# step through components in flow, get spacing from text size, etc
|
|
3437
|
+
currX = self.linePos[0]
|
|
3438
|
+
lineId = wx.NewIdRef()
|
|
3439
|
+
pdc.SetPen(wx.Pen(colour=colors.app['fl_flowline_bg']))
|
|
3440
|
+
pdc.DrawLine(x1=self.linePos[0] - gap, y1=self.linePos[1],
|
|
3441
|
+
x2=self.linePos[0], y2=self.linePos[1])
|
|
3442
|
+
# NB the loop is itself the key, value is further info about it
|
|
3443
|
+
self.loops = {}
|
|
3444
|
+
nestLevel = 0
|
|
3445
|
+
maxNestLevel = 0
|
|
3446
|
+
self.gapMidPoints = [currX - gap / 2]
|
|
3447
|
+
self.gapNestLevels = [0]
|
|
3448
|
+
for ii, entry in enumerate(expFlow):
|
|
3449
|
+
if entry.getType() == 'LoopInitiator':
|
|
3450
|
+
# NB the loop is itself the dict key!?
|
|
3451
|
+
self.loops[entry.loop] = {
|
|
3452
|
+
'init': currX, 'nest': nestLevel, 'id': ii}
|
|
3453
|
+
nestLevel += 1 # start of loop so increment level of nesting
|
|
3454
|
+
maxNestLevel = max(nestLevel, maxNestLevel)
|
|
3455
|
+
elif entry.getType() == 'LoopTerminator':
|
|
3456
|
+
# NB the loop is itself the dict key!
|
|
3457
|
+
self.loops[entry.loop]['term'] = currX
|
|
3458
|
+
nestLevel -= 1 # end of loop so decrement level of nesting
|
|
3459
|
+
elif entry.getType() == 'Routine' or entry.getType() in getAllStandaloneRoutines():
|
|
3460
|
+
# just get currX based on text size, don't draw anything yet:
|
|
3461
|
+
currX = self.drawFlowRoutine(pdc, entry, id=ii,
|
|
3462
|
+
pos=[currX, self.linePos[1] - 10],
|
|
3463
|
+
draw=False)
|
|
3464
|
+
self.gapMidPoints.append(currX + gap / 2)
|
|
3465
|
+
self.gapNestLevels.append(nestLevel)
|
|
3466
|
+
pdc.SetId(lineId)
|
|
3467
|
+
pdc.SetPen(wx.Pen(colour=colors.app['fl_flowline_bg']))
|
|
3468
|
+
pdc.DrawLine(x1=currX, y1=self.linePos[1],
|
|
3469
|
+
x2=currX + gap, y2=self.linePos[1])
|
|
3470
|
+
currX += gap
|
|
3471
|
+
lineRect = wx.Rect(self.linePos[0] - 2, self.linePos[1] - 2,
|
|
3472
|
+
currX - self.linePos[0] + 2, 4)
|
|
3473
|
+
pdc.SetIdBounds(lineId, lineRect)
|
|
3474
|
+
|
|
3475
|
+
# draw the loops first:
|
|
3476
|
+
maxHeight = 0
|
|
3477
|
+
for thisLoop in self.loops:
|
|
3478
|
+
thisInit = self.loops[thisLoop]['init']
|
|
3479
|
+
thisTerm = self.loops[thisLoop]['term']
|
|
3480
|
+
thisNest = maxNestLevel - self.loops[thisLoop]['nest'] - 1
|
|
3481
|
+
thisId = self.loops[thisLoop]['id']
|
|
3482
|
+
height = (self.linePos[1] + dLoopToBaseLine +
|
|
3483
|
+
thisNest * dBetweenLoops)
|
|
3484
|
+
self.drawLoop(pdc, thisLoop, id=thisId,
|
|
3485
|
+
startX=thisInit, endX=thisTerm,
|
|
3486
|
+
base=self.linePos[1], height=height)
|
|
3487
|
+
self.drawLoopStart(pdc, pos=[thisInit, self.linePos[1]])
|
|
3488
|
+
self.drawLoopEnd(pdc, pos=[thisTerm, self.linePos[1]])
|
|
3489
|
+
if height > maxHeight:
|
|
3490
|
+
maxHeight = height
|
|
3491
|
+
|
|
3492
|
+
# draw routines second (over loop lines):
|
|
3493
|
+
currX = self.linePos[0]
|
|
3494
|
+
for ii, entry in enumerate(expFlow):
|
|
3495
|
+
if entry.getType() == 'Routine' or entry.getType() in getAllStandaloneRoutines():
|
|
3496
|
+
currX = self.drawFlowRoutine(pdc, entry, id=ii,
|
|
3497
|
+
pos=[currX, self.linePos[1] - 10])
|
|
3498
|
+
pdc.SetPen(wx.Pen(wx.Pen(colour=colors.app['fl_flowline_bg'])))
|
|
3499
|
+
pdc.DrawLine(x1=currX, y1=self.linePos[1],
|
|
3500
|
+
x2=currX + gap, y2=self.linePos[1])
|
|
3501
|
+
currX += gap
|
|
3502
|
+
|
|
3503
|
+
self.SetVirtualSize(size=(currX + 100, maxHeight + 50))
|
|
3504
|
+
|
|
3505
|
+
self.drawLineStart(pdc, (self.linePos[0] - gap, self.linePos[1]))
|
|
3506
|
+
self.drawLineEnd(pdc, (currX, self.linePos[1]))
|
|
3507
|
+
|
|
3508
|
+
# refresh the visible window after drawing (using OnPaint)
|
|
3509
|
+
self.Refresh()
|
|
3510
|
+
|
|
3511
|
+
def drawEntryPoints(self, posList):
|
|
3512
|
+
ptSize = (3, 4, 5)[self.appData['flowSize']]
|
|
3513
|
+
for n, pos in enumerate(posList):
|
|
3514
|
+
if n >= len(self.entryPointPosList):
|
|
3515
|
+
# draw for first time
|
|
3516
|
+
id = wx.NewIdRef()
|
|
3517
|
+
self.entryPointIDlist.append(id)
|
|
3518
|
+
self.pdc.SetId(id)
|
|
3519
|
+
self.pdc.SetBrush(wx.Brush(colors.app['fl_flowline_bg']))
|
|
3520
|
+
self.pdc.DrawCircle(pos, self.linePos[1], ptSize)
|
|
3521
|
+
r = self.pdc.GetIdBounds(id)
|
|
3522
|
+
self.OffsetRect(r)
|
|
3523
|
+
self.RefreshRect(r, False)
|
|
3524
|
+
elif pos == self.entryPointPosList[n]:
|
|
3525
|
+
pass # nothing to see here, move along please :-)
|
|
3526
|
+
else:
|
|
3527
|
+
# move to new position
|
|
3528
|
+
dx = pos - self.entryPointPosList[n]
|
|
3529
|
+
dy = 0
|
|
3530
|
+
r = self.pdc.GetIdBounds(self.entryPointIDlist[n])
|
|
3531
|
+
self.pdc.TranslateId(self.entryPointIDlist[n], dx, dy)
|
|
3532
|
+
r2 = self.pdc.GetIdBounds(self.entryPointIDlist[n])
|
|
3533
|
+
# combine old and new locations to get redraw area
|
|
3534
|
+
rectToRedraw = r.Union(r2)
|
|
3535
|
+
rectToRedraw.Inflate(4, 4)
|
|
3536
|
+
self.OffsetRect(rectToRedraw)
|
|
3537
|
+
self.RefreshRect(rectToRedraw, False)
|
|
3538
|
+
|
|
3539
|
+
self.entryPointPosList = posList
|
|
3540
|
+
# refresh the visible window after drawing (using OnPaint)
|
|
3541
|
+
self.Refresh()
|
|
3542
|
+
|
|
3543
|
+
def setDrawPoints(self, ptType, startPoint=None):
|
|
3544
|
+
"""Set the points of 'routines', 'loops', or None
|
|
3545
|
+
"""
|
|
3546
|
+
if ptType == 'routines':
|
|
3547
|
+
self.pointsToDraw = self.gapMidPoints
|
|
3548
|
+
elif ptType == 'loops':
|
|
3549
|
+
self.pointsToDraw = self.gapMidPoints
|
|
3550
|
+
else:
|
|
3551
|
+
self.pointsToDraw = []
|
|
3552
|
+
|
|
3553
|
+
def drawLineStart(self, dc, pos):
|
|
3554
|
+
# draw bar at start of timeline; circle looked bad, offset vertically
|
|
3555
|
+
ptSize = (9, 9, 12)[self.appData['flowSize']]
|
|
3556
|
+
thic = (1, 1, 2)[self.appData['flowSize']]
|
|
3557
|
+
dc.SetBrush(wx.Brush(colors.app['fl_flowline_bg']))
|
|
3558
|
+
dc.SetPen(wx.Pen(colors.app['fl_flowline_bg']))
|
|
3559
|
+
dc.DrawPolygon([[0, -ptSize], [thic, -ptSize],
|
|
3560
|
+
[thic, ptSize], [0, ptSize]], pos[0], pos[1])
|
|
3561
|
+
|
|
3562
|
+
def drawLineEnd(self, dc, pos):
|
|
3563
|
+
# draws arrow at end of timeline
|
|
3564
|
+
# tmpId = wx.NewIdRef()
|
|
3565
|
+
# dc.SetId(tmpId)
|
|
3566
|
+
dc.SetBrush(wx.Brush(colors.app['fl_flowline_bg']))
|
|
3567
|
+
dc.SetPen(wx.Pen(colors.app['fl_flowline_bg']))
|
|
3568
|
+
dc.DrawPolygon([[0, -3], [5, 0], [0, 3]], pos[0], pos[1])
|
|
3569
|
+
# dc.SetIdBounds(tmpId,wx.Rect(pos[0],pos[1]+3,5,6))
|
|
3570
|
+
|
|
3571
|
+
def drawLoopEnd(self, dc, pos, downwards=True):
|
|
3572
|
+
# define the right side of a loop but draw nothing
|
|
3573
|
+
# idea: might want an ID for grabbing and relocating the loop endpoint
|
|
3574
|
+
tmpId = wx.NewIdRef()
|
|
3575
|
+
dc.SetId(tmpId)
|
|
3576
|
+
# dc.SetBrush(wx.Brush(wx.Colour(0,0,0, 250)))
|
|
3577
|
+
# dc.SetPen(wx.Pen(wx.Colour(0,0,0, 255)))
|
|
3578
|
+
size = (3, 4, 5)[self.appData['flowSize']]
|
|
3579
|
+
# if downwards:
|
|
3580
|
+
# dc.DrawPolygon([[size, 0], [0, size], [-size, 0]],
|
|
3581
|
+
# pos[0], pos[1] + 2 * size) # points down
|
|
3582
|
+
# else:
|
|
3583
|
+
# dc.DrawPolygon([[size, size], [0, 0], [-size, size]],
|
|
3584
|
+
# pos[0], pos[1]-3*size) # points up
|
|
3585
|
+
dc.SetIdBounds(tmpId, wx.Rect(
|
|
3586
|
+
pos[0] - size, pos[1] - size, 2 * size, 2 * size))
|
|
3587
|
+
return
|
|
3588
|
+
|
|
3589
|
+
def drawLoopStart(self, dc, pos, downwards=True):
|
|
3590
|
+
# draws direction arrow on left side of a loop
|
|
3591
|
+
tmpId = wx.NewIdRef()
|
|
3592
|
+
dc.SetId(tmpId)
|
|
3593
|
+
dc.SetBrush(wx.Brush(colors.app['fl_flowline_bg']))
|
|
3594
|
+
dc.SetPen(wx.Pen(colors.app['fl_flowline_bg']))
|
|
3595
|
+
size = (3, 4, 5)[self.appData['flowSize']]
|
|
3596
|
+
offset = (3, 2, 0)[self.appData['flowSize']]
|
|
3597
|
+
if downwards:
|
|
3598
|
+
dc.DrawPolygon([[size, size], [0, 0], [-size, size]],
|
|
3599
|
+
pos[0], pos[1] + 3 * size - offset) # points up
|
|
3600
|
+
else:
|
|
3601
|
+
dc.DrawPolygon([[size, 0], [0, size], [-size, 0]],
|
|
3602
|
+
pos[0], pos[1] - 4 * size) # points down
|
|
3603
|
+
dc.SetIdBounds(tmpId, wx.Rect(
|
|
3604
|
+
pos[0] - size, pos[1] - size, 2 * size, 2 * size))
|
|
3605
|
+
|
|
3606
|
+
def drawFlowRoutine(self, dc, routine, id, pos=(0, 0), draw=True):
|
|
3607
|
+
"""Draw a box to show a routine on the timeline
|
|
3608
|
+
draw=False is for a dry-run, esp to compute and return size
|
|
3609
|
+
without drawing or setting a pdc ID
|
|
3610
|
+
"""
|
|
3611
|
+
name = routine.name
|
|
3612
|
+
if self.appData['flowSize'] == 0 and len(name) > 5:
|
|
3613
|
+
name = ' ' + name[:4] + '..'
|
|
3614
|
+
else:
|
|
3615
|
+
name = ' ' + name + ' '
|
|
3616
|
+
if draw:
|
|
3617
|
+
dc.SetId(id)
|
|
3618
|
+
font = self.GetFont()
|
|
3619
|
+
if sys.platform == 'darwin':
|
|
3620
|
+
fontSizeDelta = (9, 6, 0)[self.appData['flowSize']]
|
|
3621
|
+
font.SetPointSize(1400 / self.dpi - fontSizeDelta)
|
|
3622
|
+
elif sys.platform.startswith('linux'):
|
|
3623
|
+
fontSizeDelta = (6, 4, 0)[self.appData['flowSize']]
|
|
3624
|
+
font.SetPointSize(1400 / self.dpi - fontSizeDelta)
|
|
3625
|
+
else:
|
|
3626
|
+
fontSizeDelta = (8, 4, 0)[self.appData['flowSize']]
|
|
3627
|
+
font.SetPointSize(1000 / self.dpi - fontSizeDelta)
|
|
3628
|
+
|
|
3629
|
+
maxTime, nonSlip = routine.getMaxTime()
|
|
3630
|
+
if hasattr(routine, "disabled") and routine.disabled:
|
|
3631
|
+
rtFill = colors.app['rt_comp_disabled']
|
|
3632
|
+
rtEdge = colors.app['rt_comp_disabled']
|
|
3633
|
+
rtText = colors.app['fl_routine_fg']
|
|
3634
|
+
elif nonSlip:
|
|
3635
|
+
rtFill = colors.app['fl_routine_bg_nonslip']
|
|
3636
|
+
rtEdge = colors.app['fl_routine_bg_nonslip']
|
|
3637
|
+
rtText = colors.app['fl_routine_fg']
|
|
3638
|
+
else:
|
|
3639
|
+
rtFill = colors.app['fl_routine_bg_slip']
|
|
3640
|
+
rtEdge = colors.app['fl_routine_bg_slip']
|
|
3641
|
+
rtText = colors.app['fl_routine_fg']
|
|
3642
|
+
|
|
3643
|
+
# get size based on text
|
|
3644
|
+
self.SetFont(font)
|
|
3645
|
+
if draw:
|
|
3646
|
+
dc.SetFont(font)
|
|
3647
|
+
w, h = self.GetFullTextExtent(name)[0:2]
|
|
3648
|
+
pad = (5, 10, 20)[self.appData['flowSize']]
|
|
3649
|
+
# draw box
|
|
3650
|
+
rect = wx.Rect(pos[0], pos[1] + 2 - self.appData['flowSize'],
|
|
3651
|
+
w + pad, h + pad)
|
|
3652
|
+
endX = pos[0] + w + pad
|
|
3653
|
+
# the edge should match the text
|
|
3654
|
+
if draw:
|
|
3655
|
+
dc.SetPen(wx.Pen(wx.Colour(rtEdge[0], rtEdge[1],
|
|
3656
|
+
rtEdge[2], wx.ALPHA_OPAQUE)))
|
|
3657
|
+
dc.SetBrush(wx.Brush(rtFill))
|
|
3658
|
+
dc.DrawRoundedRectangle(
|
|
3659
|
+
rect, (4, 6, 8)[self.appData['flowSize']])
|
|
3660
|
+
# draw text
|
|
3661
|
+
dc.SetTextForeground(rtText)
|
|
3662
|
+
dc.DrawLabel(name, rect, alignment=wx.ALIGN_CENTRE)
|
|
3663
|
+
if nonSlip and self.appData['flowSize'] != 0:
|
|
3664
|
+
font.SetPointSize(font.GetPointSize() * 0.6)
|
|
3665
|
+
dc.SetFont(font)
|
|
3666
|
+
_align = wx.ALIGN_CENTRE | wx.ALIGN_BOTTOM
|
|
3667
|
+
dc.DrawLabel("(%.2fs)" % maxTime, rect, alignment=_align)
|
|
3668
|
+
|
|
3669
|
+
self.componentFromID[id] = routine
|
|
3670
|
+
# set the area for this component
|
|
3671
|
+
dc.SetIdBounds(id, rect)
|
|
3672
|
+
|
|
3673
|
+
return endX
|
|
3674
|
+
|
|
3675
|
+
def drawLoop(self, dc, loop, id, startX, endX,
|
|
3676
|
+
base, height, downwards=True):
|
|
3677
|
+
if downwards:
|
|
3678
|
+
up = -1
|
|
3679
|
+
else:
|
|
3680
|
+
up = +1
|
|
3681
|
+
|
|
3682
|
+
# draw loop itself, as transparent rect with curved corners
|
|
3683
|
+
tmpId = wx.NewIdRef()
|
|
3684
|
+
dc.SetId(tmpId)
|
|
3685
|
+
# extra distance, in both h and w for curve
|
|
3686
|
+
curve = (6, 11, 15)[self.appData['flowSize']]
|
|
3687
|
+
yy = [base, height + curve * up, height +
|
|
3688
|
+
curve * up / 2, height] # for area
|
|
3689
|
+
dc.SetPen(wx.Pen(colors.app['fl_flowline_bg']))
|
|
3690
|
+
vertOffset = 0 # 1 is interesting too
|
|
3691
|
+
area = wx.Rect(startX, base + vertOffset,
|
|
3692
|
+
endX - startX, max(yy) - min(yy))
|
|
3693
|
+
dc.SetBrush(wx.Brush(wx.Colour(0, 0, 0, 0), style=wx.TRANSPARENT))
|
|
3694
|
+
# draws outline:
|
|
3695
|
+
dc.DrawRoundedRectangle(area, curve)
|
|
3696
|
+
dc.SetIdBounds(tmpId, area)
|
|
3697
|
+
|
|
3698
|
+
flowsize = self.appData['flowSize'] # 0, 1, or 2
|
|
3699
|
+
|
|
3700
|
+
# add a name label, loop info, except at smallest size
|
|
3701
|
+
name = loop.params['name'].val
|
|
3702
|
+
_show = self.appData['showLoopInfoInFlow']
|
|
3703
|
+
if _show and flowsize:
|
|
3704
|
+
_cond = 'conditions' in list(loop.params)
|
|
3705
|
+
if _cond and loop.params['conditions'].val:
|
|
3706
|
+
xnumTrials = 'x' + str(len(loop.params['conditions'].val))
|
|
3707
|
+
else:
|
|
3708
|
+
xnumTrials = ''
|
|
3709
|
+
name += ' (' + str(loop.params['nReps'].val) + xnumTrials
|
|
3710
|
+
abbrev = ['', # for flowsize == 0
|
|
3711
|
+
{'random': 'rand.',
|
|
3712
|
+
'sequential': 'sequ.',
|
|
3713
|
+
'fullRandom': 'f-ran.',
|
|
3714
|
+
'staircase': 'stair.',
|
|
3715
|
+
'interleaved staircases': "int-str."},
|
|
3716
|
+
{'random': 'random',
|
|
3717
|
+
'sequential': 'sequential',
|
|
3718
|
+
'fullRandom': 'fullRandom',
|
|
3719
|
+
'staircase': 'staircase',
|
|
3720
|
+
'interleaved staircases': "interl'vd stairs"}]
|
|
3721
|
+
name += ' ' + abbrev[flowsize][loop.params['loopType'].val] + ')'
|
|
3722
|
+
if flowsize == 0:
|
|
3723
|
+
if len(name) > 9:
|
|
3724
|
+
name = ' ' + name[:8] + '..'
|
|
3725
|
+
else:
|
|
3726
|
+
name = ' ' + name[:9]
|
|
3727
|
+
else:
|
|
3728
|
+
name = ' ' + name + ' '
|
|
3729
|
+
|
|
3730
|
+
dc.SetId(id)
|
|
3731
|
+
font = self.GetFont()
|
|
3732
|
+
if sys.platform == 'darwin':
|
|
3733
|
+
basePtSize = (650, 750, 900)[flowsize]
|
|
3734
|
+
elif sys.platform.startswith('linux'):
|
|
3735
|
+
basePtSize = (750, 850, 1000)[flowsize]
|
|
3736
|
+
else:
|
|
3737
|
+
basePtSize = (700, 750, 800)[flowsize]
|
|
3738
|
+
font.SetPointSize(basePtSize / self.dpi)
|
|
3739
|
+
self.SetFont(font)
|
|
3740
|
+
dc.SetFont(font)
|
|
3741
|
+
|
|
3742
|
+
# get size based on text
|
|
3743
|
+
pad = (5, 8, 10)[self.appData['flowSize']]
|
|
3744
|
+
w, h = self.GetFullTextExtent(name)[0:2]
|
|
3745
|
+
x = startX + (endX - startX) / 2 - w / 2 - pad / 2
|
|
3746
|
+
y = (height - h / 2)
|
|
3747
|
+
|
|
3748
|
+
# draw box
|
|
3749
|
+
rect = wx.Rect(x, y, w + pad, h + pad)
|
|
3750
|
+
# the edge should match the text
|
|
3751
|
+
dc.SetPen(wx.Pen(colors.app['fl_flowline_bg']))
|
|
3752
|
+
# try to make the loop fill brighter than the background canvas:
|
|
3753
|
+
dc.SetBrush(wx.Brush(colors.app['fl_flowline_bg']))
|
|
3754
|
+
|
|
3755
|
+
dc.DrawRoundedRectangle(rect, (4, 6, 8)[flowsize])
|
|
3756
|
+
# draw text
|
|
3757
|
+
dc.SetTextForeground(colors.app['fl_flowline_fg'])
|
|
3758
|
+
dc.DrawText(name, x + pad / 2, y + pad / 2)
|
|
3759
|
+
|
|
3760
|
+
self.componentFromID[id] = loop
|
|
3761
|
+
# set the area for this component
|
|
3762
|
+
dc.SetIdBounds(id, rect)
|
|
3763
|
+
|
|
3764
|
+
|
|
3765
|
+
class BuilderToolbar(BasePsychopyToolbar):
|
|
3766
|
+
def makeTools(self):
|
|
3767
|
+
# Clear any existing tools
|
|
3768
|
+
self.ClearTools()
|
|
3769
|
+
self.buttons = {}
|
|
3770
|
+
|
|
3771
|
+
# New
|
|
3772
|
+
self.buttons['filenew'] = self.makeTool(
|
|
3773
|
+
name='filenew',
|
|
3774
|
+
label=_translate('New'),
|
|
3775
|
+
shortcut='new',
|
|
3776
|
+
tooltip=_translate("Create new experiment file"),
|
|
3777
|
+
func=self.frame.app.newBuilderFrame
|
|
3778
|
+
)
|
|
3779
|
+
# Open
|
|
3780
|
+
self.buttons['fileopen'] = self.makeTool(
|
|
3781
|
+
name='fileopen',
|
|
3782
|
+
label=_translate('Open'),
|
|
3783
|
+
shortcut='open',
|
|
3784
|
+
tooltip=_translate("Open an existing experiment file"),
|
|
3785
|
+
func=self.frame.fileOpen)
|
|
3786
|
+
# Save
|
|
3787
|
+
self.buttons['filesave'] = self.makeTool(
|
|
3788
|
+
name='filesave',
|
|
3789
|
+
label=_translate('Save'),
|
|
3790
|
+
shortcut='save',
|
|
3791
|
+
tooltip=_translate("Save current experiment file"),
|
|
3792
|
+
func=self.frame.fileSave)
|
|
3793
|
+
self.frame.bldrBtnSave = self.buttons['filesave']
|
|
3794
|
+
# SaveAs
|
|
3795
|
+
self.buttons['filesaveas'] = self.makeTool(
|
|
3796
|
+
name='filesaveas',
|
|
3797
|
+
label=_translate('Save As...'),
|
|
3798
|
+
shortcut='saveAs',
|
|
3799
|
+
tooltip=_translate("Save current experiment file as..."),
|
|
3800
|
+
func=self.frame.fileSaveAs)
|
|
3801
|
+
# Undo
|
|
3802
|
+
self.buttons['undo'] = self.makeTool(
|
|
3803
|
+
name='undo',
|
|
3804
|
+
label=_translate('Undo'),
|
|
3805
|
+
shortcut='undo',
|
|
3806
|
+
tooltip=_translate("Undo last action"),
|
|
3807
|
+
func=self.frame.undo)
|
|
3808
|
+
self.frame.bldrBtnUndo = self.buttons['undo']
|
|
3809
|
+
# Redo
|
|
3810
|
+
self.buttons['redo'] = self.makeTool(
|
|
3811
|
+
name='redo',
|
|
3812
|
+
label=_translate('Redo'),
|
|
3813
|
+
shortcut='redo',
|
|
3814
|
+
tooltip=_translate("Redo last action"),
|
|
3815
|
+
func=self.frame.redo)
|
|
3816
|
+
self.frame.bldrBtnRedo = self.buttons['redo']
|
|
3817
|
+
|
|
3818
|
+
self.AddSeparator()
|
|
3819
|
+
|
|
3820
|
+
# Monitor Center
|
|
3821
|
+
self.buttons['monitors'] = self.makeTool(
|
|
3822
|
+
name='monitors',
|
|
3823
|
+
label=_translate('Monitor Center'),
|
|
3824
|
+
shortcut='none',
|
|
3825
|
+
tooltip=_translate("Monitor settings and calibration"),
|
|
3826
|
+
func=self.frame.app.openMonitorCenter)
|
|
3827
|
+
# Settings
|
|
3828
|
+
self.buttons['cogwindow'] = self.makeTool(
|
|
3829
|
+
name='cogwindow',
|
|
3830
|
+
label=_translate('Experiment Settings'),
|
|
3831
|
+
shortcut='none',
|
|
3832
|
+
tooltip=_translate("Edit experiment settings"),
|
|
3833
|
+
func=self.frame.setExperimentSettings)
|
|
3834
|
+
|
|
3835
|
+
self.AddSeparator()
|
|
3836
|
+
|
|
3837
|
+
# Compile Py
|
|
3838
|
+
self.buttons['compile_py'] = self.makeTool(
|
|
3839
|
+
name='compile_py',
|
|
3840
|
+
label=_translate('Compile Python Script'),
|
|
3841
|
+
shortcut='compileScript',
|
|
3842
|
+
tooltip=_translate("Compile to Python script"),
|
|
3843
|
+
func=self.frame.compileScript)
|
|
3844
|
+
# Compile JS
|
|
3845
|
+
self.buttons['compile_js'] = self.makeTool(
|
|
3846
|
+
name='compile_js',
|
|
3847
|
+
label=_translate('Compile JS Script'),
|
|
3848
|
+
shortcut='compileScript',
|
|
3849
|
+
tooltip=_translate("Compile to JS script"),
|
|
3850
|
+
func=self.frame.fileExport)
|
|
3851
|
+
# Send to runner
|
|
3852
|
+
self.buttons['runner'] = self.makeTool(
|
|
3853
|
+
name='runner',
|
|
3854
|
+
label=_translate('Runner'),
|
|
3855
|
+
shortcut='runnerScript',
|
|
3856
|
+
tooltip=_translate("Send experiment to Runner"),
|
|
3857
|
+
func=self.frame.runFile)
|
|
3858
|
+
self.frame.bldrBtnRunner = self.buttons['runner']
|
|
3859
|
+
# Run
|
|
3860
|
+
self.buttons['run'] = self.makeTool(
|
|
3861
|
+
name='run',
|
|
3862
|
+
label=_translate('Run'),
|
|
3863
|
+
shortcut='runScript',
|
|
3864
|
+
tooltip=_translate("Run experiment"),
|
|
3865
|
+
func=self.frame.runFile)
|
|
3866
|
+
self.frame.bldrBtnRun = self.buttons['run']
|
|
3867
|
+
|
|
3868
|
+
self.AddSeparator()
|
|
3869
|
+
|
|
3870
|
+
# Pavlovia run
|
|
3871
|
+
self.buttons['pavloviaRun'] = self.makeTool(
|
|
3872
|
+
name='globe_run',
|
|
3873
|
+
label=_translate("Run online"),
|
|
3874
|
+
tooltip=_translate("Run the study online (with pavlovia.org)"),
|
|
3875
|
+
func=self.frame.onPavloviaRun)
|
|
3876
|
+
# Pavlovia sync
|
|
3877
|
+
self.buttons['pavloviaSync'] = self.makeTool(
|
|
3878
|
+
name='globe_greensync',
|
|
3879
|
+
label=_translate("Sync online"),
|
|
3880
|
+
tooltip=_translate("Sync with web project (at pavlovia.org)"),
|
|
3881
|
+
func=self.frame.onPavloviaSync)
|
|
3882
|
+
# Pavlovia search
|
|
3883
|
+
self.buttons['pavloviaSearch'] = self.makeTool(
|
|
3884
|
+
name='globe_magnifier',
|
|
3885
|
+
label=_translate("Search Pavlovia.org"),
|
|
3886
|
+
tooltip=_translate("Find existing studies online (at pavlovia.org)"),
|
|
3887
|
+
func=self.onPavloviaSearch)
|
|
3888
|
+
# Pavlovia user
|
|
3889
|
+
self.buttons['pavloviaUser'] = self.makeTool(
|
|
3890
|
+
name='globe_user',
|
|
3891
|
+
label=_translate("Current Pavlovia user"),
|
|
3892
|
+
tooltip=_translate("Log in/out of Pavlovia.org, view your user profile."),
|
|
3893
|
+
func=self.onPavloviaUser)
|
|
3894
|
+
# Pavlovia user
|
|
3895
|
+
self.buttons['pavloviaProject'] = self.makeTool(
|
|
3896
|
+
name='globe_info',
|
|
3897
|
+
label=_translate("View project"),
|
|
3898
|
+
tooltip=_translate("View details of this project"),
|
|
3899
|
+
func=self.onPavloviaProject)
|
|
3900
|
+
|
|
3901
|
+
# Disable compile buttons until an experiment is present
|
|
3902
|
+
self.EnableTool(self.buttons['compile_py'].GetId(), Path(str(self.frame.filename)).is_file())
|
|
3903
|
+
self.EnableTool(self.buttons['compile_js'].GetId(), Path(str(self.frame.filename)).is_file())
|
|
3904
|
+
|
|
3905
|
+
self.frame.btnHandles = self.buttons
|
|
3906
|
+
|
|
3907
|
+
def onPavloviaSearch(self, evt=None):
|
|
3908
|
+
searchDlg = SearchFrame(
|
|
3909
|
+
app=self.frame.app, parent=self.frame,
|
|
3910
|
+
pos=self.frame.GetPosition())
|
|
3911
|
+
searchDlg.Show()
|
|
3912
|
+
|
|
3913
|
+
def onPavloviaUser(self, evt=None):
|
|
3914
|
+
userDlg = UserFrame(self.frame)
|
|
3915
|
+
userDlg.ShowModal()
|
|
3916
|
+
|
|
3917
|
+
def onPavloviaProject(self, evt=None):
|
|
3918
|
+
if self.frame.project is not None:
|
|
3919
|
+
dlg = ProjectFrame(app=self.frame.app,
|
|
3920
|
+
project=self.frame.project)
|
|
3921
|
+
else:
|
|
3922
|
+
dlg = ProjectFrame(app=self.frame.app)
|
|
3923
|
+
dlg.Show()
|
|
3924
|
+
|
|
3925
|
+
|
|
3926
|
+
def extractText(stream):
|
|
3927
|
+
"""Take a byte stream (or any file object of type b?) and return
|
|
3928
|
+
|
|
3929
|
+
:param stream: stream from wx.Process or any byte stream from a file
|
|
3930
|
+
:return: text converted to unicode ready for appending to wx text view
|
|
3931
|
+
"""
|
|
3932
|
+
return stream.read().decode('utf-8')
|