psychopy 2025.1.1__py3-none-any.whl → 2025.2.1__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.

Files changed (220) hide show
  1. psychopy/VERSION +1 -1
  2. psychopy/alerts/alertsCatalogue/4810.yaml +19 -0
  3. psychopy/alerts/alertsCatalogue/alertCategories.yaml +4 -0
  4. psychopy/alerts/alertsCatalogue/alertmsg.py +15 -1
  5. psychopy/alerts/alertsCatalogue/generateAlertmsg.py +2 -2
  6. psychopy/app/Resources/classic/add_many.png +0 -0
  7. psychopy/app/Resources/classic/add_many@2x.png +0 -0
  8. psychopy/app/Resources/classic/devices.png +0 -0
  9. psychopy/app/Resources/classic/devices@2x.png +0 -0
  10. psychopy/app/Resources/classic/photometer.png +0 -0
  11. psychopy/app/Resources/classic/photometer@2x.png +0 -0
  12. psychopy/app/Resources/dark/add_many.png +0 -0
  13. psychopy/app/Resources/dark/add_many@2x.png +0 -0
  14. psychopy/app/Resources/dark/devices.png +0 -0
  15. psychopy/app/Resources/dark/devices@2x.png +0 -0
  16. psychopy/app/Resources/dark/photometer.png +0 -0
  17. psychopy/app/Resources/dark/photometer@2x.png +0 -0
  18. psychopy/app/Resources/light/add_many.png +0 -0
  19. psychopy/app/Resources/light/add_many@2x.png +0 -0
  20. psychopy/app/Resources/light/devices.png +0 -0
  21. psychopy/app/Resources/light/devices@2x.png +0 -0
  22. psychopy/app/Resources/light/photometer.png +0 -0
  23. psychopy/app/Resources/light/photometer@2x.png +0 -0
  24. psychopy/app/_psychopyApp.py +35 -13
  25. psychopy/app/builder/builder.py +88 -35
  26. psychopy/app/builder/dialogs/__init__.py +69 -220
  27. psychopy/app/builder/dialogs/dlgsCode.py +29 -8
  28. psychopy/app/builder/dialogs/paramCtrls.py +1468 -904
  29. psychopy/app/builder/validators.py +25 -17
  30. psychopy/app/coder/coder.py +12 -1
  31. psychopy/app/coder/repl.py +5 -2
  32. psychopy/app/colorpicker/__init__.py +1 -1
  33. psychopy/app/deviceManager/__init__.py +1 -0
  34. psychopy/app/deviceManager/addDialog.py +218 -0
  35. psychopy/app/deviceManager/dialog.py +185 -0
  36. psychopy/app/deviceManager/panel.py +191 -0
  37. psychopy/app/deviceManager/utils.py +60 -0
  38. psychopy/app/idle.py +7 -0
  39. psychopy/app/locale/ar_001/LC_MESSAGE/messages.mo +0 -0
  40. psychopy/app/locale/ar_001/LC_MESSAGE/messages.po +12695 -10592
  41. psychopy/app/locale/cs_CZ/LC_MESSAGE/messages.mo +0 -0
  42. psychopy/app/locale/cs_CZ/LC_MESSAGE/messages.po +10199 -24
  43. psychopy/app/locale/da_DK/LC_MESSAGE/messages.mo +0 -0
  44. psychopy/app/locale/da_DK/LC_MESSAGE/messages.po +10199 -24
  45. psychopy/app/locale/de_DE/LC_MESSAGE/messages.mo +0 -0
  46. psychopy/app/locale/de_DE/LC_MESSAGE/messages.po +11221 -9712
  47. psychopy/app/locale/el_GR/LC_MESSAGE/messages.mo +0 -0
  48. psychopy/app/locale/el_GR/LC_MESSAGE/messages.po +10200 -25
  49. psychopy/app/locale/en_NZ/LC_MESSAGE/messages.mo +0 -0
  50. psychopy/app/locale/en_NZ/LC_MESSAGE/messages.po +10200 -25
  51. psychopy/app/locale/en_US/LC_MESSAGE/messages.mo +0 -0
  52. psychopy/app/locale/en_US/LC_MESSAGE/messages.po +10195 -18
  53. psychopy/app/locale/es_CO/LC_MESSAGE/messages.mo +0 -0
  54. psychopy/app/locale/es_CO/LC_MESSAGE/messages.po +11917 -9101
  55. psychopy/app/locale/es_ES/LC_MESSAGE/messages.mo +0 -0
  56. psychopy/app/locale/es_ES/LC_MESSAGE/messages.po +11924 -9103
  57. psychopy/app/locale/es_US/LC_MESSAGE/messages.mo +0 -0
  58. psychopy/app/locale/es_US/LC_MESSAGE/messages.po +11917 -9101
  59. psychopy/app/locale/et_EE/LC_MESSAGE/messages.mo +0 -0
  60. psychopy/app/locale/et_EE/LC_MESSAGE/messages.po +11084 -9569
  61. psychopy/app/locale/fa_IR/LC_MESSAGE/messages.mo +0 -0
  62. psychopy/app/locale/fa_IR/LC_MESSAGE/messages.po +11590 -5806
  63. psychopy/app/locale/fi_FI/LC_MESSAGE/messages.mo +0 -0
  64. psychopy/app/locale/fi_FI/LC_MESSAGE/messages.po +10199 -24
  65. psychopy/app/locale/fr_FR/LC_MESSAGE/messages.mo +0 -0
  66. psychopy/app/locale/fr_FR/LC_MESSAGE/messages.po +11091 -9577
  67. psychopy/app/locale/he_IL/LC_MESSAGE/messages.mo +0 -0
  68. psychopy/app/locale/he_IL/LC_MESSAGE/messages.po +11072 -9549
  69. psychopy/app/locale/hi_IN/LC_MESSAGE/messages.mo +0 -0
  70. psychopy/app/locale/hi_IN/LC_MESSAGE/messages.po +11071 -9559
  71. psychopy/app/locale/hu_HU/LC_MESSAGE/messages.mo +0 -0
  72. psychopy/app/locale/hu_HU/LC_MESSAGE/messages.po +10200 -25
  73. psychopy/app/locale/it_IT/LC_MESSAGE/messages.mo +0 -0
  74. psychopy/app/locale/it_IT/LC_MESSAGE/messages.po +11072 -9560
  75. psychopy/app/locale/ja_JP/LC_MESSAGE/messages.mo +0 -0
  76. psychopy/app/locale/ja_JP/LC_MESSAGE/messages.po +1485 -1137
  77. psychopy/app/locale/ko_KR/LC_MESSAGE/messages.mo +0 -0
  78. psychopy/app/locale/ko_KR/LC_MESSAGE/messages.po +10199 -24
  79. psychopy/app/locale/ms_MY/LC_MESSAGE/messages.mo +0 -0
  80. psychopy/app/locale/ms_MY/LC_MESSAGE/messages.po +11463 -8757
  81. psychopy/app/locale/nl_NL/LC_MESSAGE/messages.mo +0 -0
  82. psychopy/app/locale/nl_NL/LC_MESSAGE/messages.po +10200 -25
  83. psychopy/app/locale/nn_NO/LC_MESSAGE/messages.mo +0 -0
  84. psychopy/app/locale/nn_NO/LC_MESSAGE/messages.po +10200 -25
  85. psychopy/app/locale/pl_PL/LC_MESSAGE/messages.mo +0 -0
  86. psychopy/app/locale/pl_PL/LC_MESSAGE/messages.po +10200 -25
  87. psychopy/app/locale/pt_PT/LC_MESSAGE/messages.mo +0 -0
  88. psychopy/app/locale/pt_PT/LC_MESSAGE/messages.po +11288 -9434
  89. psychopy/app/locale/ro_RO/LC_MESSAGE/messages.mo +0 -0
  90. psychopy/app/locale/ro_RO/LC_MESSAGE/messages.po +10200 -25
  91. psychopy/app/locale/ru_RU/LC_MESSAGE/messages.mo +0 -0
  92. psychopy/app/locale/ru_RU/LC_MESSAGE/messages.po +10199 -24
  93. psychopy/app/locale/sv_SE/LC_MESSAGE/messages.mo +0 -0
  94. psychopy/app/locale/sv_SE/LC_MESSAGE/messages.po +11441 -8747
  95. psychopy/app/locale/tr_TR/LC_MESSAGE/messages.mo +0 -0
  96. psychopy/app/locale/tr_TR/LC_MESSAGE/messages.po +11069 -9545
  97. psychopy/app/locale/zh_CN/LC_MESSAGE/messages.mo +0 -0
  98. psychopy/app/locale/zh_CN/LC_MESSAGE/messages.po +12085 -8268
  99. psychopy/app/locale/zh_TW/LC_MESSAGE/messages.mo +0 -0
  100. psychopy/app/locale/zh_TW/LC_MESSAGE/messages.po +11929 -8022
  101. psychopy/app/plugin_manager/dialog.py +12 -3
  102. psychopy/app/plugin_manager/packageIndex.py +303 -0
  103. psychopy/app/plugin_manager/packages.py +203 -63
  104. psychopy/app/plugin_manager/plugins.py +120 -240
  105. psychopy/app/preferencesDlg.py +6 -1
  106. psychopy/app/psychopyApp.py +16 -4
  107. psychopy/app/runner/runner.py +10 -2
  108. psychopy/app/runner/scriptProcess.py +8 -3
  109. psychopy/app/stdout/stdOutRich.py +11 -4
  110. psychopy/app/themes/icons.py +3 -0
  111. psychopy/app/utils.py +61 -0
  112. psychopy/data/experiment.py +133 -23
  113. psychopy/data/routine.py +12 -0
  114. psychopy/data/staircase.py +42 -20
  115. psychopy/data/trial.py +20 -12
  116. psychopy/data/utils.py +42 -2
  117. psychopy/demos/builder/Experiments/dragAndDrop/drag_and_drop.psyexp +22 -5
  118. psychopy/demos/builder/Experiments/dragAndDrop/stimuli/solutions.xlsx +0 -0
  119. psychopy/demos/builder/Experiments/stroopVoice/stroopVoice.psyexp +2 -12
  120. psychopy/demos/builder/Feature Demos/buttonBox/buttonBoxDemo.psyexp +3 -8
  121. psychopy/demos/builder/Feature Demos/movies/movie.psyexp +220 -0
  122. psychopy/demos/builder/Feature Demos/movies/readme.md +3 -0
  123. psychopy/demos/builder/Feature Demos/visualValidator/visualValidator.psyexp +1 -2
  124. psychopy/demos/builder/Hardware/camera/camera.psyexp +3 -16
  125. psychopy/demos/builder/Hardware/microphone/microphone.psyexp +3 -16
  126. psychopy/demos/coder/hardware/hdf5_extract.py +133 -0
  127. psychopy/event.py +20 -15
  128. psychopy/experiment/_experiment.py +86 -10
  129. psychopy/experiment/components/__init__.py +3 -10
  130. psychopy/experiment/components/_base.py +9 -20
  131. psychopy/experiment/components/button/__init__.py +1 -1
  132. psychopy/experiment/components/buttonBox/__init__.py +50 -54
  133. psychopy/experiment/components/camera/__init__.py +137 -359
  134. psychopy/experiment/components/keyboard/__init__.py +17 -24
  135. psychopy/experiment/components/microphone/__init__.py +61 -110
  136. psychopy/experiment/components/movie/__init__.py +2 -3
  137. psychopy/experiment/components/serialOut/__init__.py +192 -93
  138. psychopy/experiment/components/settings/__init__.py +45 -27
  139. psychopy/experiment/components/sound/__init__.py +82 -73
  140. psychopy/experiment/components/soundsensor/__init__.py +43 -80
  141. psychopy/experiment/devices.py +303 -0
  142. psychopy/experiment/exports.py +20 -18
  143. psychopy/experiment/flow.py +7 -0
  144. psychopy/experiment/loops.py +47 -29
  145. psychopy/experiment/monitor.py +74 -0
  146. psychopy/experiment/params.py +48 -10
  147. psychopy/experiment/plugins.py +28 -108
  148. psychopy/experiment/py2js_transpiler.py +1 -1
  149. psychopy/experiment/routines/__init__.py +1 -1
  150. psychopy/experiment/routines/_base.py +59 -24
  151. psychopy/experiment/routines/audioValidator/__init__.py +19 -155
  152. psychopy/experiment/routines/visualValidator/__init__.py +25 -25
  153. psychopy/hardware/__init__.py +20 -57
  154. psychopy/hardware/button.py +15 -2
  155. psychopy/hardware/camera/__init__.py +2237 -1394
  156. psychopy/hardware/joystick/__init__.py +1 -1
  157. psychopy/hardware/keyboard.py +5 -8
  158. psychopy/hardware/listener.py +4 -1
  159. psychopy/hardware/manager.py +75 -35
  160. psychopy/hardware/microphone.py +52 -6
  161. psychopy/hardware/monitor.py +144 -0
  162. psychopy/hardware/photometer/__init__.py +156 -117
  163. psychopy/hardware/serialdevice.py +16 -2
  164. psychopy/hardware/soundsensor.py +4 -1
  165. psychopy/iohub/devices/deviceConfigValidation.py +2 -1
  166. psychopy/iohub/devices/keyboard/darwin.py +8 -5
  167. psychopy/iohub/util/__init__.py +7 -8
  168. psychopy/localization/generateTranslationTemplate.py +208 -116
  169. psychopy/localization/messages.pot +4305 -3502
  170. psychopy/monitors/MonitorCenter.py +174 -74
  171. psychopy/plugins/__init__.py +6 -4
  172. psychopy/preferences/devices.py +80 -0
  173. psychopy/preferences/generateHints.py +2 -1
  174. psychopy/preferences/preferences.py +35 -11
  175. psychopy/scripts/psychopy-pkgutil.py +969 -0
  176. psychopy/scripts/psyexpCompile.py +1 -1
  177. psychopy/session.py +34 -38
  178. psychopy/sound/__init__.py +6 -260
  179. psychopy/sound/audioclip.py +164 -0
  180. psychopy/sound/backend_ptb.py +8 -0
  181. psychopy/sound/backend_pygame.py +10 -0
  182. psychopy/sound/backend_pysound.py +9 -0
  183. psychopy/sound/backends/__init__.py +0 -0
  184. psychopy/sound/microphone.py +3 -0
  185. psychopy/sound/sound.py +58 -0
  186. psychopy/tests/data/correctScript/python/correctNoiseStimComponent.py +1 -1
  187. psychopy/tests/data/duplicateHeaders.csv +2 -0
  188. psychopy/tests/test_app/test_builder/test_BuilderFrame.py +22 -7
  189. psychopy/tests/test_app/test_builder/test_CompileFromBuilder.py +0 -2
  190. psychopy/tests/test_data/test_utils.py +5 -1
  191. psychopy/tests/test_experiment/test_components/test_ButtonBoxComponent.py +22 -2
  192. psychopy/tests/test_hardware/test_ports.py +0 -12
  193. psychopy/tests/test_tools/test_stringtools.py +1 -1
  194. psychopy/tools/attributetools.py +12 -5
  195. psychopy/tools/fontmanager.py +17 -14
  196. psychopy/tools/movietools.py +43 -2
  197. psychopy/tools/stringtools.py +33 -8
  198. psychopy/tools/versionchooser.py +1 -1
  199. psychopy/validation/audio.py +5 -1
  200. psychopy/validation/visual.py +5 -1
  201. psychopy/visual/basevisual.py +8 -7
  202. psychopy/visual/circle.py +2 -2
  203. psychopy/visual/image.py +29 -109
  204. psychopy/visual/movies/__init__.py +1800 -313
  205. psychopy/visual/polygon.py +4 -0
  206. psychopy/visual/shape.py +2 -2
  207. psychopy/visual/window.py +34 -11
  208. psychopy/voicekey/__init__.py +41 -669
  209. psychopy/voicekey/labjack_vks.py +7 -48
  210. psychopy/voicekey/parallel_vks.py +7 -42
  211. psychopy/voicekey/vk_tools.py +114 -263
  212. {psychopy-2025.1.1.dist-info → psychopy-2025.2.1.dist-info}/METADATA +17 -11
  213. {psychopy-2025.1.1.dist-info → psychopy-2025.2.1.dist-info}/RECORD +216 -184
  214. {psychopy-2025.1.1.dist-info → psychopy-2025.2.1.dist-info}/WHEEL +1 -1
  215. psychopy/visual/movies/players/__init__.py +0 -62
  216. psychopy/visual/movies/players/ffpyplayer_player.py +0 -1401
  217. psychopy/voicekey/demo_vks.py +0 -12
  218. psychopy/voicekey/signal.py +0 -42
  219. {psychopy-2025.1.1.dist-info → psychopy-2025.2.1.dist-info}/entry_points.txt +0 -0
  220. {psychopy-2025.1.1.dist-info → psychopy-2025.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -15,12 +15,17 @@ import wx.stc
15
15
 
16
16
  from psychopy.app.colorpicker import PsychoColorPicker
17
17
  from psychopy.app.dialogs import ListWidget
18
+ from psychopy.app.themes import fonts, colors
18
19
  from psychopy.colors import Color
20
+ from psychopy.experiment.exports import NameSpace
21
+ from psychopy.experiment.params import Param, toList
19
22
  from psychopy.localization import _translate
20
- from psychopy import data, prefs, experiment
23
+ from psychopy import data, exceptions, logging, prefs, experiment
21
24
  import re
22
25
  from pathlib import Path
23
26
 
27
+ from psychopy.tools import stringtools
28
+
24
29
  from . import CodeBox
25
30
  from ...coder import BaseCodeEditor
26
31
  from ...themes import icons, handlers
@@ -28,231 +33,443 @@ from ... import utils
28
33
  from ...themes import icons
29
34
 
30
35
 
31
- class _FrameMixin:
32
- @property
33
- def frame(self):
36
+ inputTypes = {}
37
+
38
+
39
+ EVT_PARAM_CHANGED = wx.PyEventBinder(wx.IdManager.ReserveId())
40
+ emptyNamespace = NameSpace(experiment.Experiment())
41
+
42
+
43
+ class ParamValueChangedEvent(wx.CommandEvent):
44
+ def __init__(self, obj, param, trigger=None):
45
+ wx.CommandEvent.__init__(self, EVT_PARAM_CHANGED.typeId)
46
+ # set object
47
+ self.SetEventObject(obj)
48
+ # store param
49
+ self.param = param
50
+ # store triggering event
51
+ self.trigger = trigger
52
+
53
+ def getParam(self):
54
+ return self.param
55
+
56
+
57
+ class BaseParamCtrl(wx.Panel):
58
+ """
59
+ Base class for all ParamCtrls, defines the minimum functions needed for a ParamCtrl to work.
60
+
61
+ Attributes
62
+ ----------
63
+ inputType : str
64
+ Input type which this ctrl corresponds to
65
+
66
+ Parameters
67
+ ----------
68
+ parent : wx.Window
69
+ Parent window for this ctrl
70
+ field : str
71
+ Name of the param which this ctrl represents
72
+ param : psychopy.experiment.Param
73
+ Parameter which this ctrl represents
74
+ element
75
+ Builder element (Component, Routine, Loop, etc.) to which this parameter belongs, if any
76
+ """
77
+ # what inputType does a Param need to have to get this ctrl?
78
+ inputType = None
79
+
80
+ # additional styles for the ctrl (used by overloaded makeCtrls)
81
+ ctrlStyle = wx.DEFAULT
82
+
83
+ def __init__(self, parent, field, param, element=None, warnings=None):
84
+ # initialise
85
+ wx.Panel.__init__(self, parent)
86
+ # store details
87
+ self.parent = parent
88
+ self.field = field
89
+ self.param = param.copy()
90
+ self.element = element
91
+ self.warnings = warnings
92
+ # setup namespace
93
+ if hasattr(element, "exp"):
94
+ self.namespace = self.element.exp.namespace
95
+ else:
96
+ self.namespace = emptyNamespace
97
+ # setup sizer
98
+ self.sizer = wx.BoxSizer(wx.HORIZONTAL)
99
+ self.SetSizer(self.sizer)
100
+ # call method which subclasses override to make controls
101
+ self.makeCtrls()
102
+ # set tooltip
103
+ self.setTooltip(param.hint)
104
+
105
+ def __init_subclass__(cls):
106
+ # index subclasses of BaseParamCtrl by the inputType they represent
107
+ if cls.inputType is not None and cls.inputType not in inputTypes:
108
+ inputTypes[cls.inputType] = cls
109
+
110
+ def makeCtrls(self):
34
111
  """
35
- Top level frame associated with this ctrl
112
+ Makes the actual control object.
36
113
  """
37
- topParent = self.GetTopLevelParent()
38
- if hasattr(topParent, "frame"):
39
- return topParent.frame
40
- else:
41
- return topParent
114
+ raise NotImplementedError(
115
+ "All subclasses of BaseParamCtrl should implement `makeCtrls`"
116
+ )
117
+
118
+ def getValue(self):
119
+ """
120
+ Returns the value of this ctrl
121
+ """
122
+ raise NotImplementedError(
123
+ "All subclasses of BaseParamCtrl should implement `getValue`"
124
+ )
42
125
 
126
+ def setValue(self, value):
127
+ """
128
+ Returns the value of this ctrl
129
+ """
130
+ raise NotImplementedError(
131
+ "All subclasses of BaseParamCtrl should implement `setValue`"
132
+ )
43
133
 
44
- class _ValidatorMixin:
45
- def validate(self, evt=None):
46
- """Redirect validate calls to global validate method, assigning
47
- appropriate `valType`.
134
+ def setTooltip(self, text):
48
135
  """
49
- validate(self, self.valType)
136
+ Set the tooltip on this control.
50
137
 
51
- if evt is not None:
52
- evt.Skip()
138
+ Parameters
139
+ ----------
140
+ text : str
141
+ Text to show in tooltip
142
+ """
143
+ # set tooltip on panel
144
+ self.SetToolTip(wx.ToolTip(text))
145
+ # set on ctrl if possible
146
+ if hasattr(self.ctrl, 'SetToolTip'):
147
+ self.ctrl.SetToolTip(wx.ToolTip(text))
148
+
149
+ def getWarning(self):
150
+ """
151
+ Get the warning associated with this ctrl, if any
152
+ """
153
+ if self.warnings is not None:
154
+ return self.warnings.getWarning(self)
53
155
 
54
- def showValid(self, valid):
55
- """Style input box according to valid"""
56
- if not hasattr(self, "SetForegroundColour"):
57
- return
156
+ def setWarning(self, warning, allowed=True):
157
+ """
158
+ Set a warning on the warnings handler attached to this ctrl, if any.
58
159
 
59
- if valid:
60
- self.SetForegroundColour(wx.SystemSettings.GetColour(wx.SYS_COLOUR_BTNTEXT))
61
- else:
62
- self.SetForegroundColour(wx.Colour(1, 0, 0))
160
+ Parameters
161
+ ----------
162
+ warning : str
163
+ Warning to display
164
+ """
165
+ if self.warnings is not None:
166
+ self.warnings.setWarning(self, warning, allowed=allowed)
167
+
168
+ def clearWarning(self):
169
+ """
170
+ Remove the warning handler attached to this ctrl, if any.
171
+ """
172
+ if self.warnings is not None:
173
+ self.warnings.clearWarning(self)
174
+
175
+ @property
176
+ def isValid(self):
177
+ """
178
+ Returns True or False based on whether the current ctrl has generated any warnings
179
+ """
180
+ if self.warnings is not None:
181
+ return self.warnings.getWarning(self) is None
63
182
 
64
- def updateCodeFont(self, valType):
65
- """Style input box according to code wanted"""
66
- if not hasattr(self, "SetStyle"):
67
- # Skip if font not applicable to object type
68
- return
69
- if self.GetName() == "name":
70
- # Name is never code
71
- valType = "str"
183
+ def validate(self):
184
+ """
185
+ Update warnings based on the value of this ctrl
186
+ """
187
+ # always start off with no warning
188
+ self.clearWarning()
72
189
 
73
- # get font
74
- if valType == "code" or hasattr(self, "dollarLbl"):
75
- font = self.GetTopLevelParent().app._codeFont.Bold()
76
- else:
77
- font = self.GetTopLevelParent().app._mainFont
190
+ def styleValid(self):
191
+ """
192
+ Style this ctrl according to whether its value is valid (`.isValid`)
193
+ """
194
+ # if not implemented, do nothing
195
+ return
196
+
197
+ @property
198
+ def isCode(self):
199
+ """
200
+ Returns True if the contents of this ctrl should be styled as code.
201
+ """
202
+ # if needed, figure out from $
203
+ if self.param.valType in ("extendedStr","str", "file", "table", "color"):
204
+ return str(self.getValue()).startswith("$")
205
+
206
+ return True
207
+
208
+ def styleCode(self):
209
+ """
210
+ Style this ctrl according to whether it contains code (`.isCode`)
211
+ """
212
+ # if not implemented, do nothing
213
+ return
214
+
215
+ def onChange(self, evt=None):
216
+ """
217
+ Callback which updates the control and param when the value changes.
78
218
 
79
- # set font
80
- if sys.platform == "linux":
81
- # have to go via SetStyle on Linux
82
- style = wx.TextAttr(self.GetForegroundColour(), font=font)
83
- self.SetStyle(0, len(self.GetValue()), style)
219
+ Parameters
220
+ ----------
221
+ evt : wx.Event
222
+ Whatever event triggered this function
223
+ """
224
+ # validate ctrl
225
+ self.validate()
226
+ # style according to whether value is code and valid
227
+ self.styleCode()
228
+ self.styleValid()
229
+ # update
230
+ self.Update()
231
+ self.Refresh()
232
+ # update param value
233
+ self.param.val = self.getValue()
234
+ # show any warnings
235
+ if self.warnings is not None:
236
+ self.warnings.showWarning()
237
+ # process dependent params
238
+ if hasattr(self.parent, "checkDepends"):
239
+ self.parent.checkDepends()
240
+ # emit a custom event
241
+ evt = ParamValueChangedEvent(self, param=self.param, trigger=evt)
242
+ wx.PostEvent(self, evt)
243
+
244
+ def onElementOk(self, evt=None):
245
+ """
246
+ Method which is called when OK is pressed on the element containing this param, if any.
247
+ """
248
+ # assume no action
249
+ return
250
+
251
+
252
+ class ParamCtrl:
253
+ """
254
+ Constructor which looks for the appropriate subclass of BaseParamCtrl and initialises that.
255
+ """
256
+ def __new__(cls, parent, field, param, element=None, warnings=None):
257
+ if param.inputType in inputTypes:
258
+ # if a known type, get associated control
259
+ ctrlCls = inputTypes[param.inputType]
84
260
  else:
85
- # otherwise SetFont is fine
86
- self.SetFont(font)
261
+ # otherwise, make a single line text ctrl
262
+ ctrlCls = SingleLineCtrl
263
+
264
+ return ctrlCls(parent, field, param, element, warnings)
87
265
 
266
+ class SingleLineCtrl(BaseParamCtrl):
267
+ inputType = "single"
88
268
 
89
- class _FileMixin(_FrameMixin):
90
- @property
91
- def rootDir(self):
92
- if not hasattr(self, "_rootDir"):
93
- # Store location of root directory if not defined
94
- self._rootDir = Path(self.frame.exp.filename)
95
- if self._rootDir.is_file():
96
- # Move up a dir if root is a file
97
- self._rootDir = self._rootDir.parent
98
- # Return stored rootDir
99
- return self._rootDir
100
- @rootDir.setter
101
- def rootDir(self, value):
102
- self._rootDir = value
103
-
104
- def getFile(self, msg="Specify file ...", wildcard="All Files (*.*)|*.*"):
105
- dlg = wx.FileDialog(self, message=_translate(msg), defaultDir=str(self.rootDir),
106
- style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST,
107
- wildcard=_translate(wildcard))
108
- if dlg.ShowModal() != wx.ID_OK:
269
+ # overload this in subclasses to control style
270
+ ctrlStyle = wx.TE_LEFT
271
+
272
+ def makeCtrls(self):
273
+ # add dollar label
274
+ self.dollarLbl = wx.StaticText(
275
+ self, label="$", style=wx.ALIGN_RIGHT
276
+ )
277
+ self.dollarLbl.SetToolTip(_translate(
278
+ "This parameter will be treated as code - we have already put in the $, so you don't "
279
+ "have to."
280
+ ))
281
+ self.sizer.Add(
282
+ self.dollarLbl, border=6, flag=wx.CENTER | wx.RIGHT
283
+ )
284
+ # show/hide dollar according to valType
285
+ self.dollarLbl.Show(
286
+ self.param.valType in ("code", "extendedCode")
287
+ )
288
+ # add value ctrl
289
+ self.ctrl = wx.TextCtrl(
290
+ self, value=str(self.param.val), name=self.field, style=self.ctrlStyle
291
+ )
292
+ self.sizer.Add(
293
+ self.ctrl, proportion=1, flag=wx.EXPAND
294
+ )
295
+ # enforce a minimum height on multiline ctrls
296
+ if self.ctrlStyle | wx.TE_MULTILINE == self.ctrlStyle:
297
+ self.ctrl.SetMinSize((-1, 128))
298
+ # map change event
299
+ self.ctrl.Bind(
300
+ wx.EVT_TEXT, self.onChange
301
+ )
302
+ # also do styling once now
303
+ self.onChange()
304
+
305
+ def getValue(self):
306
+ return self.ctrl.GetValue()
307
+
308
+ def setValue(self, value, silent=False):
309
+ # get insertion point if possible
310
+ pt = self.ctrl.GetInsertionPoint()
311
+ # set value
312
+ if silent:
313
+ self.ctrl.ChangeValue(str(value))
314
+ else:
315
+ self.ctrl.SetValue(str(value))
316
+ # restore insertion point if possible
317
+ try:
318
+ self.ctrl.SetInsertionPoint(pt)
319
+ except:
320
+ pass
321
+
322
+ def validateCode(self):
323
+ # get value without any dollar syntax
324
+ value = experiment.getCodeFromParamStr(
325
+ self.getValue(),
326
+ target="PsychoPy"
327
+ )
328
+ # if blank, there's no code yet to be invalid
329
+ if not value:
109
330
  return
110
- file = dlg.GetPath()
111
331
  try:
112
- filename = Path(file).relative_to(self.rootDir)
113
- except ValueError:
114
- filename = Path(file).absolute()
115
- return str(filename).replace("\\", "/")
116
-
117
- def getFiles(self, msg="Specify file or files...", wildcard="All Files (*.*)|*.*"):
118
- dlg = wx.FileDialog(self, message=_translate(msg), defaultDir=str(self.rootDir),
119
- style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST | wx.FD_MULTIPLE,
120
- wildcard=_translate(wildcard))
121
- if dlg.ShowModal() != wx.ID_OK:
332
+ variableDefs = stringtools.getVariableDefs(value)
333
+ variables = stringtools.getVariables(value)
334
+ except (SyntaxError, TypeError) as e:
335
+ # if failed to get variables, add warning and mark invalid
336
+ self.setWarning(_translate(
337
+ "Python syntax error in field `{}`: {}"
338
+ ).format(self.param.label, e))
122
339
  return
123
- inList = dlg.GetPaths()
124
- outList = []
125
- for file in inList:
126
- try:
127
- filename = Path(file).relative_to(self.rootDir)
128
- except ValueError:
129
- filename = Path(file).absolute()
130
- outList.append(str(filename).replace("\\", "/"))
131
- return outList
132
-
340
+ # for multiline code, check that any variable defs don't break the namespace
341
+ if self.param.valType == "extendedCode":
342
+ # check that nothing important is being overwritten
343
+ if self.element:
344
+ # iterate through variable defs in code (if any)
345
+ for name in variableDefs:
346
+ # is it overwriting something?
347
+ used = self.namespace.exists(name)
348
+ if used:
349
+ # warn but allow
350
+ self.setWarning(_translate(
351
+ "Setting the variable `{}` will overwrite an existing variable ({})"
352
+ ).format(name, used), allowed=True)
353
+ else:
354
+ # check any dynamic parameters
355
+ if self.param.updates == "constant":
356
+ # if references a name, is it one defined before experiment start?
357
+ for name in variables:
358
+ if name not in NameSpace.nonUserBuilder:
359
+ # if not, warn but allow
360
+ self.setWarning(_translate(
361
+ "Looks like your variable '{}' in '{}' should be set to "
362
+ "update."
363
+ ).format(name, self.param.label), allowed=True)
364
+
365
+ def validateStr(self):
366
+ # warn for unescaped "
367
+ if re.findall(r"(?<!\\)[\"\']", self.getValue()):
368
+ self.setWarning(_translate(
369
+ "Quotation marks (\" or ') need to be escaped (\\\" or \\')"
370
+ ))
133
371
 
134
- class _HideMixin:
135
- def ShowAll(self, visible):
136
- # Get sizer, if present
137
- if hasattr(self, "_szr"):
138
- sizer = self._szr
139
- elif isinstance(self, DictCtrl):
140
- sizer = self
372
+ def validate(self):
373
+ # start off valid
374
+ BaseParamCtrl.validate(self)
375
+ # use different method for code vs string
376
+ if self.isCode:
377
+ return self.validateCode()
141
378
  else:
142
- sizer = self.GetSizer()
143
- # If there is a sizer, recursively hide children
144
- if sizer is not None:
145
- self.tunnelShow(sizer, visible)
379
+ return self.validateStr()
380
+
381
+ def styleValid(self):
382
+ # text turns red if invalid
383
+ if self.isValid:
384
+ self.ctrl.SetForegroundColour(
385
+ colors.scheme['black']
386
+ )
146
387
  else:
147
- self.Show(visible)
148
-
149
- def HideAll(self):
150
- self.Show(False)
151
-
152
- def tunnelShow(self, sizer, visible):
153
- if sizer is not None:
154
- # Show/hide everything in the sizer
155
- for child in sizer.Children:
156
- if child.Window is not None:
157
- child.Window.Show(visible)
158
- if child.Sizer is not None:
159
- # If child is a sizer, recur
160
- self.tunnelShow(child.Sizer, visible)
161
-
162
-
163
- class SingleLineCtrl(wx.TextCtrl, _ValidatorMixin, _HideMixin):
164
- def __init__(self, parent, valType,
165
- val="", fieldName="",
166
- size=wx.Size(-1, 24), style=wx.TE_LEFT):
167
- # Create self
168
- wx.TextCtrl.__init__(self)
169
- self.Create(parent, -1, val, name=fieldName, size=size, style=style)
170
- self.valType = valType
171
-
172
- # On MacOS, we need to disable smart quotes
173
- if sys.platform == 'darwin':
174
- self.OSXDisableAllSmartSubstitutions()
175
-
176
- # Add sizer
177
- self._szr = wx.BoxSizer(wx.HORIZONTAL)
178
- if not valType == "str" and not fieldName == "name":
179
- # Add $ for anything to be interpreted verbatim
180
- self.dollarLbl = wx.StaticText(parent, -1, "$", size=wx.Size(-1, -1), style=wx.ALIGN_RIGHT)
181
- self.dollarLbl.SetToolTip(_translate("This parameter will be treated as code - we have already put in the $, so you don't have to."))
182
- self._szr.Add(self.dollarLbl, border=5, flag=wx.ALIGN_CENTER_VERTICAL | wx.RIGHT | wx.LEFT)
183
- # Add self to sizer
184
- self._szr.Add(self, proportion=1, border=5, flag=wx.EXPAND)
185
- # Bind to validation
186
- self.Bind(wx.EVT_TEXT, self.validate)
187
- self.validate()
388
+ self.ctrl.SetForegroundColour(
389
+ colors.scheme['red']
390
+ )
391
+ self.ctrl.Refresh()
392
+
393
+ def styleCode(self):
394
+ # text becomes monospace if code
395
+ if self.isCode:
396
+ self.ctrl.SetFont(
397
+ fonts.CodeFont(bold=True).obj
398
+ )
399
+ else:
400
+ self.ctrl.SetFont(
401
+ fonts.AppFont().obj
402
+ )
403
+ self.ctrl.Refresh()
404
+
405
+ def onChange(self, evt=None):
406
+ # do some sanitization before usual onchange behaviour
407
+ if self.isCode:
408
+ # replace unescaped curly quotes
409
+ if re.findall(r"(?<!\\)[\u201c\u201d]", self.getValue()):
410
+ self.setValue(
411
+ re.sub(r"(?<!\\)[\u201c\u201d]", "\"", self.getValue())
412
+ )
413
+ if re.findall(r"(?<!\\)[\u2018\u2019]", self.getValue()):
414
+ self.setValue(
415
+ re.sub(r"(?<!\\)[\u2018\u2019]", "\'", self.getValue())
416
+ )
417
+ else:
418
+ pass
419
+
420
+ BaseParamCtrl.onChange(self, evt)
421
+
422
+
423
+ class NameCtrl(SingleLineCtrl):
424
+ inputType = "name"
425
+
426
+ def styleCode(self):
427
+ # a name is always code, we don't need to remind the user, so style as normal
428
+ self.dollarLbl.Hide()
429
+ self.ctrl.Refresh()
430
+ self.ctrl.Layout()
431
+
432
+ def validate(self):
433
+ # start off valid
434
+ BaseParamCtrl.validate(self)
435
+ # is name a valid name?
436
+ if self.getValue() == "":
437
+ # prompt to enter a name if blank
438
+ self.setWarning(_translate(
439
+ "Please enter a name"
440
+ ), allowed=False)
441
+ elif NameSpace.isValid(self.getValue()):
442
+ # if we have an experiment, is the name used already?
443
+ if self.element:
444
+ # if unchanged from original name, it does exist but is valid
445
+ if self.getValue() == self.element.name:
446
+ return
447
+ # otherwise, check against extant names
448
+ exists = self.namespace.exists(self.getValue())
449
+ if exists:
450
+ self.setWarning(_translate(
451
+ "Name is already in use ({})"
452
+ ).format(exists), allowed=False)
453
+ else:
454
+ self.setWarning(_translate(
455
+ "Name is not valid"
456
+ ), allowed=False)
188
457
 
189
- def Show(self, value=True):
190
- wx.TextCtrl.Show(self, value)
191
- if hasattr(self, "dollarLbl"):
192
- self.dollarLbl.Show(value)
193
- if hasattr(self, "deleteBtn"):
194
- self.deleteBtn.Show(value)
195
-
196
-
197
- class MultiLineCtrl(SingleLineCtrl, _ValidatorMixin, _HideMixin):
198
- def __init__(self, parent, valType,
199
- val="", fieldName="",
200
- size=wx.Size(-1, 144)):
201
- SingleLineCtrl.__init__(self, parent, valType,
202
- val=val, fieldName=fieldName,
203
- size=size, style=wx.TE_MULTILINE)
204
-
205
-
206
- class CodeCtrl(BaseCodeEditor, handlers.ThemeMixin, _ValidatorMixin):
207
- def __init__(self, parent, valType,
208
- val="", fieldName="",
209
- size=wx.Size(-1, 144)):
210
- BaseCodeEditor.__init__(self, parent,
211
- ID=wx.ID_ANY, pos=wx.DefaultPosition, size=size,
212
- style=0)
213
- self.valType = valType
214
- self.SetValue(val)
215
- self.fieldName = fieldName
216
- self.params = fieldName
217
- # Setup lexer to style text
218
- self.SetLexer(wx.stc.STC_LEX_PYTHON)
219
- self._applyAppTheme()
220
- # Hide margin
221
- self.SetMarginWidth(0, 0)
222
- # Setup auto indent behaviour as in Code component
223
- self.Bind(wx.EVT_KEY_DOWN, self.onKey)
224
458
 
225
- def getValue(self, evt=None):
226
- return self.GetValue()
459
+ class MultiLineCtrl(SingleLineCtrl):
460
+ inputType = "multi"
227
461
 
228
- def setValue(self, value):
229
- self.SetValue(value)
462
+ ctrlStyle = wx.TE_LEFT | wx.TE_MULTILINE
230
463
 
231
- @property
232
- def val(self):
233
- """
234
- Alias for Set/GetValue, as .val is used elsewhere
235
- """
236
- return self.getValue()
237
-
238
- @val.setter
239
- def val(self, value):
240
- self.setValue(value)
241
-
242
- def onKey(self, evt=None):
243
- CodeBox.OnKeyPressed(self, evt)
244
-
245
-
246
- class InvalidCtrl(SingleLineCtrl, _ValidatorMixin, _HideMixin):
247
- def __init__(self, parent, valType,
248
- val="", fieldName="",
249
- size=wx.Size(-1, 24), style=wx.DEFAULT):
250
- SingleLineCtrl.__init__(self, parent, valType,
251
- val=val, fieldName=fieldName,
252
- size=size, style=style)
253
- self.Disable()
254
- # Add delete button
255
- self.deleteBtn = wx.Button(parent, label="×", size=(24, 24))
464
+
465
+ class InvalidCtrl(SingleLineCtrl):
466
+ inputType = "inv"
467
+
468
+ def makeCtrls(self):
469
+ SingleLineCtrl.makeCtrls(self)
470
+ self.ctrl.Disable()
471
+ # add delete button
472
+ self.deleteBtn = wx.Button(self, label="×", size=(24, 24))
256
473
  self.deleteBtn.SetForegroundColour("red")
257
474
  self.deleteBtn.Bind(wx.EVT_BUTTON, self.deleteParam)
258
475
  self.deleteBtn.SetToolTip(_translate(
@@ -260,463 +477,396 @@ class InvalidCtrl(SingleLineCtrl, _ValidatorMixin, _HideMixin):
260
477
  "In the latest version of PsychoPy, it is not used. Click this "
261
478
  "button to delete it. WARNING: This may affect how this experiment "
262
479
  "works in older versions!"))
263
- self._szr.Add(self.deleteBtn, border=6, flag=wx.LEFT | wx.RIGHT)
264
- # Add deleted label
265
- self.deleteLbl = wx.StaticText(parent, label=_translate("DELETED"))
480
+ self.sizer.Add(self.deleteBtn, border=6, flag=wx.LEFT | wx.RIGHT)
481
+ # add deleted label
482
+ self.deleteLbl = wx.StaticText(self, label=_translate("DELETED"))
266
483
  self.deleteLbl.SetForegroundColour("red")
267
484
  self.deleteLbl.Hide()
268
- self._szr.Add(self.deleteLbl, border=6, proportion=1, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL)
269
- # Add undo delete button
270
- self.undoBtn = wx.Button(parent, label="⟲", size=(24, 24))
485
+ self.sizer.Add(self.deleteLbl, border=6, proportion=1, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL)
486
+ # add undo delete button
487
+ self.undoBtn = wx.Button(self, label="⟲", size=(24, 24))
271
488
  self.undoBtn.SetToolTip(_translate(
272
489
  "This parameter will not be deleted until you click Okay. "
273
490
  "Click this button to revert the deletion and keep the parameter."))
274
491
  self.undoBtn.Hide()
275
492
  self.undoBtn.Bind(wx.EVT_BUTTON, self.undoDelete)
276
- self._szr.Add(self.undoBtn, border=6, flag=wx.LEFT | wx.RIGHT)
493
+ self.sizer.Add(self.undoBtn, border=6, flag=wx.LEFT | wx.RIGHT)
277
494
 
278
- # Set deletion flag
495
+ # set deletion flag
279
496
  self.forDeletion = False
280
497
 
281
498
  def deleteParam(self, evt=None):
282
499
  """
283
500
  When the remove button is pressed, mark this param as for deletion
284
501
  """
285
- # Mark for deletion
502
+ # mark for deletion
286
503
  self.forDeletion = True
287
- # Hide value ctrl and delete button
288
- self.Hide()
504
+ # hide value ctrl and delete button
505
+ self.ctrl.Hide()
289
506
  self.deleteBtn.Hide()
290
- # Show delete label and
507
+ # show delete label and
291
508
  self.undoBtn.Show()
292
509
  self.deleteLbl.Show()
293
510
 
294
- self._szr.Layout()
511
+ self.sizer.Layout()
295
512
 
296
513
  def undoDelete(self, evt=None):
297
- # Mark not for deletion
514
+ # mark not for deletion
298
515
  self.forDeletion = False
299
- # Show value ctrl and delete button
300
- self.Show()
516
+ # show value ctrl and delete button
517
+ self.ctrl.Show()
301
518
  self.deleteBtn.Show()
302
- # Hide delete label and
519
+ # hide delete label and
303
520
  self.undoBtn.Hide()
304
521
  self.deleteLbl.Hide()
305
522
 
306
- self._szr.Layout()
523
+ self.sizer.Layout()
307
524
 
308
525
 
309
- class IntCtrl(wx.SpinCtrl, _ValidatorMixin, _HideMixin):
310
- def __init__(self, parent, valType,
311
- val="", fieldName="",
312
- size=wx.Size(-1, 24), limits=None):
313
- wx.SpinCtrl.__init__(self)
314
- limits = limits or (-100,100)
315
- self.Create(parent, -1, str(val), name=fieldName, size=size, min=min(limits), max=max(limits))
316
- self.valType = valType
317
- self.Bind(wx.EVT_SPINCTRL, self.spin)
526
+ class BoolCtrl(BaseParamCtrl):
527
+ inputType = "bool"
318
528
 
319
- def spin(self, evt):
320
- """Redirect validate calls to global validate method, assigning appropriate valType"""
321
- if evt.EventType == wx.EVT_SPIN_UP.evtType[0]:
322
- self.SetValue(str(int(self.GetValue())+1))
323
- elif evt.EventType == wx.EVT_SPIN_DOWN.evtType[0]:
324
- self.SetValue(str(int(self.GetValue()) - 1))
325
- validate(self, "int")
529
+ def makeCtrls(self):
530
+ # add checkbox
531
+ self.ctrl = wx.CheckBox(self)
532
+ self.ctrl.SetValue(bool(self.param))
533
+ self.sizer.Add(
534
+ self.ctrl, border=6, flag=wx.EXPAND | wx.ALL
535
+ )
536
+ # connect onChange
537
+ self.ctrl.Bind(
538
+ wx.EVT_CHECKBOX, self.onChange
539
+ )
540
+
541
+ def getValue(self):
542
+ return self.ctrl.IsChecked()
326
543
 
544
+ def setValue(self, value):
545
+ self.ctrl.SetValue(bool(value))
327
546
 
328
- BoolCtrl = wx.CheckBox
329
547
 
548
+ class ChoiceCtrl(BaseParamCtrl):
549
+ inputType = "choice"
330
550
 
331
- class ChoiceCtrl(wx.Choice, _ValidatorMixin, _HideMixin):
332
- def __init__(self, parent, valType,
333
- val="", choices=[], labels=[], fieldName="",
334
- size=wx.Size(-1, -1)):
335
- self._choices = choices
336
- self._labels = labels
337
- # Create choice ctrl from labels
338
- wx.Choice.__init__(self)
339
- self.Create(parent, -1, name=fieldName)
551
+ def makeCtrls(self):
552
+ # add choice ctrl
553
+ self.ctrl = wx.Choice(self)
554
+ self.sizer.Add(
555
+ self.ctrl, proportion=1, flag=wx.EXPAND | wx.ALL
556
+ )
557
+ # connect onChange
558
+ self.ctrl.Bind(
559
+ wx.EVT_CHOICE, self.onChange
560
+ )
561
+ # set initial choices
340
562
  self.populate()
341
- self.valType = valType
342
- self.SetStringSelection(val)
343
563
 
344
564
  def populate(self):
345
- if callable(self._choices):
346
- # if choices are given as a partial, execute it now to get values
347
- choices = self._choices()
565
+ # convert values to a list (by executing method of just converting value)
566
+ if callable(self.param.allowedVals):
567
+ choices = [str(val) for val in self.param.allowedVals()]
348
568
  else:
349
- # otherwise, treat it as a list
350
- choices = list(self._choices)
351
-
352
- if callable(self._labels):
353
- # if labels are given as a partial, execute it now to get values
354
- labels = self._labels()
355
- elif self._labels:
356
- # otherwise, treat it as a list
357
- labels = list(self._labels)
569
+ choices = [str(val) for val in self.param.allowedVals]
570
+ # convert labels to a list (by executing method of just converting value)
571
+ if callable(self.param.allowedLabels):
572
+ labels = self.param.allowedLabels()
573
+ elif self.param.allowedLabels:
574
+ labels = list(self.param.allowedLabels)
358
575
  else:
359
576
  # if not given any labels, alias values
360
577
  labels = choices
361
- # Map labels to values
362
- _labels = {}
363
- for i, value in enumerate(choices):
578
+ # make arrays the same length
579
+ self.choices = []
580
+ self.labels = []
581
+ for i in range(max(len(choices), len(labels))):
582
+ # fill in missing choices with label
583
+ if i < len(choices):
584
+ self.choices.append(choices[i])
585
+ else:
586
+ self.choices.append(labels[i])
587
+ # fill in missing labels with choices
364
588
  if i < len(labels):
365
- _labels[value] = _translate(labels[i]) if labels[i] != '' else ''
589
+ self.labels.append(str(labels[i]))
366
590
  else:
367
- _labels[value] = _translate(value) if value != '' else ''
368
- labels = _labels
369
- # store labels and choices
370
- self.labels = labels
371
- self.choices = choices
372
-
591
+ self.labels.append(str(choices[i]))
592
+ # translate labels
593
+ for i in range(len(self.labels)):
594
+ # An empty string must not be translated
595
+ # because it returns meta information of
596
+ # .mo file (due to specification of gettext)
597
+ if self.labels[i] != '':
598
+ self.labels[i] = _translate(self.labels[i])
373
599
  # apply to ctrl
374
- self.SetItems([str(self.labels[c]) for c in self.choices])
375
-
376
- def SetStringSelection(self, string):
377
- strChoices = [str(choice) for choice in self.choices]
378
- if string not in self.choices:
379
- if string in strChoices:
380
- # If string is a stringified version of a value in choices, stringify the value in choices
381
- i = strChoices.index(string)
382
- self.labels[string] = self.labels.pop(self.choices[i])
383
- self.choices[i] = string
600
+ self.ctrl.SetItems(self.labels)
601
+ # disable if param is readonly
602
+ self.ctrl.Enable(not self.param.readOnly)
603
+ # apply (or re-apply) selection
604
+ self.setValue(self.param.val)
605
+
606
+ def getValue(self):
607
+ return self.choices[self.ctrl.GetSelection()]
608
+
609
+ def setValue(self, value):
610
+ if str(value) not in self.choices:
611
+ # if not known, add it to possible choices
612
+ self.choices.append(str(value))
613
+ # translate label if the value is not ''
614
+ if str(value) != '':
615
+ self.labels.append(_translate(str(value)))
384
616
  else:
385
- # Otherwise it is a genuinely new value, so add it to options
386
- self.choices.append(string)
387
- self.labels[string] = string
388
- # Refresh items
389
- self.SetItems(
390
- [str(self.labels[c]) for c in self.choices]
391
- )
392
- # Don't use wx.Choice.SetStringSelection here because label string is localized.
393
- wx.Choice.SetSelection(self, self.choices.index(string))
617
+ self.labels.append(str(value))
618
+ self.ctrl.SetItems(self.labels)
619
+ # set
620
+ self.ctrl.SetSelection(
621
+ self.choices.index(str(value))
622
+ )
394
623
 
395
- def getValue(self):
396
- # Don't use wx.Choice.GetStringSelection here because label string is localized.
397
- return self.choices[self.GetSelection()]
398
-
399
-
400
- class MultiChoiceCtrl(wx.CheckListBox, _ValidatorMixin, _HideMixin):
401
- def __init__(self, parent, valType,
402
- vals="", choices=[], fieldName="",
403
- size=wx.Size(-1, -1)):
404
- wx.CheckListBox.__init__(self)
405
- self.Create(parent, id=wx.ID_ANY, size=size, choices=choices, name=fieldName, style=wx.LB_MULTIPLE)
406
- self.valType = valType
407
- self._choices = choices
408
- # Make initial selection
409
- if isinstance(vals, str):
410
- # Convert to list if needed
411
- vals = data.utils.listFromString(vals, excludeEmpties=True)
412
- self.SetCheckedStrings(vals)
413
- self.validate()
414
624
 
415
- def SetCheckedStrings(self, strings):
416
- if not isinstance(strings, (list, tuple)):
417
- strings = [strings]
418
- for s in strings:
419
- if s not in self._choices:
420
- self._choices.append(s)
421
- self.SetItems(self._choices)
422
- wx.CheckListBox.SetCheckedStrings(self, strings)
625
+ class MultiChoiceCtrl(ChoiceCtrl):
626
+ inputType = "multiChoice"
423
627
 
424
- def GetValue(self, evt=None):
425
- return self.GetCheckedStrings()
628
+ def makeCtrls(self):
629
+ self.ctrl = wx.CheckListBox(self, style=wx.LB_MULTIPLE)
630
+ self.sizer.Add(
631
+ self.ctrl, proportion=1, flag=wx.EXPAND | wx.ALL
632
+ )
633
+ # connect onChange
634
+ self.ctrl.Bind(
635
+ wx.EVT_CHECKLISTBOX, self.onChange
636
+ )
426
637
 
638
+ self.populate()
639
+
640
+ def getValue(self):
641
+ return [
642
+ self.choices[i] for i in self.ctrl.GetCheckedItems()
643
+ ]
644
+
645
+ def setValue(self, value):
646
+ # coerce to list
647
+ value = data.utils.listFromString(value)
648
+ # iterate through values
649
+ selected = []
650
+ for val in value:
651
+ # if not known, add it to possible choices
652
+ if val not in self.choices:
653
+ self.choices.append(val)
654
+ self.labels.append(str(val))
655
+ self.ctrl.SetItems(self.labels)
656
+ # add index
657
+ selected.append(
658
+ self.choices.index(val)
659
+ )
660
+ # set
661
+ self.ctrl.SetCheckedItems(selected)
427
662
 
428
- class RichChoiceCtrl(wx.Panel, _ValidatorMixin, _HideMixin):
429
- class RichChoiceItem(wx.Panel):
430
- def __init__(self, parent, value, label, body="", linkText="", link="", startShown="always", viewToggle=True):
431
- # Initialise
432
- wx.Panel.__init__(self, parent, style=wx.BORDER_THEME)
433
- self.parent = parent
434
- self.value = value
435
- self.startShown = startShown
436
- # Setup sizer
437
- self.border = wx.BoxSizer()
438
- self.SetSizer(self.border)
439
- self.sizer = wx.FlexGridSizer(cols=3)
440
- self.sizer.AddGrowableCol(idx=1, proportion=1)
441
- self.border.Add(self.sizer, proportion=1, border=6, flag=wx.ALL | wx.EXPAND)
442
- # Check
443
- self.check = wx.CheckBox(self, label=" ")
444
- self.check.Bind(wx.EVT_CHECKBOX, self.onCheck)
445
- self.check.Bind(wx.EVT_KEY_UP, self.onToggle)
446
- self.sizer.Add(self.check, border=3, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL)
447
- # Title
448
- self.title = wx.StaticText(self, label=label)
449
- self.title.SetFont(self.title.GetFont().Bold())
450
- self.sizer.Add(self.title, border=3, flag=wx.ALL | wx.EXPAND)
451
- # Toggle
452
- self.toggleView = wx.ToggleButton(self, style=wx.BU_EXACTFIT)
453
- self.toggleView.Bind(wx.EVT_TOGGLEBUTTON, self.onToggleView)
454
- self.toggleView.Show(viewToggle)
455
- self.sizer.Add(self.toggleView, border=3, flag=wx.ALL | wx.EXPAND)
456
- # Body
457
- self.body = utils.WrappedStaticText(self, label=body)
458
- self.sizer.AddStretchSpacer(1)
459
- self.sizer.Add(self.body, border=3, proportion=1, flag=wx.ALL | wx.EXPAND)
460
- self.sizer.AddStretchSpacer(1)
461
- # Link
462
- self.link = utils.HyperLinkCtrl(self, label=linkText, URL=link)
463
- self.link.SetBackgroundColour(self.GetBackgroundColour())
464
- self.sizer.AddStretchSpacer(1)
465
- self.sizer.Add(self.link, border=3, flag=wx.ALL | wx.ALIGN_LEFT)
466
- self.sizer.AddStretchSpacer(1)
467
663
 
468
- # Style
469
- self.SetBackgroundColour("white")
470
- self.body.SetBackgroundColour("white")
471
- self.link.SetBackgroundColour("white")
664
+ class FileCtrl(SingleLineCtrl):
665
+ inputType = "file"
472
666
 
473
- self.Layout()
667
+ dlgWildcard = "All Files (*.*)|*.*"
668
+ icon = "folder"
669
+ dlgStyle = wx.FD_FILE_MUST_EXIST
474
670
 
475
- def getChecked(self):
476
- return self.check.GetValue()
671
+ def makeCtrls(self):
672
+ SingleLineCtrl.makeCtrls(self)
673
+ # add a file browse button
674
+ self.fileBtn = wx.Button(self, style=wx.BU_EXACTFIT)
675
+ self.fileBtn.SetBitmap(
676
+ icons.ButtonIcon(stem=self.icon, size=16, theme="light").bitmap
677
+ )
678
+ self.fileBtn.SetToolTip(
679
+ _translate("Browse for a file")
680
+ )
681
+ self.fileBtn.Bind(wx.EVT_BUTTON, self.openFileBrowser)
682
+ self.sizer.Add(
683
+ self.fileBtn, border=6, flag=wx.EXPAND | wx.LEFT
684
+ )
685
+
686
+ def styleValid(self):
687
+ # style as normal
688
+ SingleLineCtrl.styleValid(self)
689
+ # if not code, check for a link
690
+ if not self.isCode:
691
+ if stringtools.is_url(self.getValue()):
692
+ self.ctrl.SetForegroundColour(
693
+ colors.scheme['blue']
694
+ )
695
+ self.ctrl.Refresh()
696
+
697
+ @property
698
+ def rootDir(self):
699
+ # if no element, use system root
700
+ if self.element is None or not hasattr(self.element, "exp"):
701
+ return Path()
702
+ # otherwise, get from experiment
703
+ root = Path(self.element.exp.filename)
704
+ # move up a dir if root is a file
705
+ if root.is_file():
706
+ root = root.parent
707
+
708
+ return root
709
+
710
+ def openFileBrowser(self, evt=None):
711
+ # open a file browser dialog
712
+ dlg = wx.FileDialog(
713
+ self,
714
+ message=_translate("Specify file..."),
715
+ defaultDir=str(self.rootDir),
716
+ style=wx.FD_OPEN | self.dlgStyle,
717
+ wildcard=self.dlgWildcard
718
+ )
719
+ if dlg.ShowModal() != wx.ID_OK:
720
+ return
721
+ # get path
722
+ file = dlg.GetPath()
723
+ # relativise
724
+ try:
725
+ filename = Path(file).relative_to(self.rootDir)
726
+ except ValueError:
727
+ filename = Path(file).absolute()
728
+ # set value
729
+ self.setValue(
730
+ str(filename).replace("\\", "/")
731
+ )
732
+
733
+ def validate(self):
734
+ from psychopy.tools.filetools import defaultStim
735
+ # start off valid
736
+ BaseParamCtrl.validate(self)
737
+ # if given as code, use regular code checking
738
+ if self.isCode:
739
+ return SingleLineCtrl.validateCode(self)
740
+ # if given a link, it's valid
741
+ if stringtools.is_url(self.getValue()):
742
+ self.clearWarning()
743
+ return
744
+ # if blank, don't worry about it
745
+ if self.getValue() == "":
746
+ self.clearWarning()
747
+ return
748
+ # if it's a string, convert to file
749
+ try:
750
+ file = Path(self.getValue())
751
+ except:
752
+ # if it can't be a file at all, show warning
753
+ self.setWarning(_translate(
754
+ "Not a valid file path: {}"
755
+ ).format(self.getValue()))
756
+ return
757
+ # make path absolute
758
+ if not file.is_absolute():
759
+ file = self.rootDir / file
760
+ # valid only if file exists
761
+ if all((
762
+ not file.is_file(),
763
+ file.name not in defaultStim
764
+ )):
765
+ self.setWarning(_translate(
766
+ "No file named {}"
767
+ ).format(self.getValue()))
768
+
769
+
770
+ class SoundCtrl(FileCtrl):
771
+ inputType = "soundFile"
772
+
773
+ def validate(self):
774
+ from psychopy.tools.audiotools import knownNoteNames
775
+ # validate like a normal file
776
+ FileCtrl.validate(self)
777
+ # if given a note, this is fine
778
+ if str(self.getValue()).capitalize() in knownNoteNames:
779
+ self.clearWarning()
780
+
781
+
782
+ class TableCtrl(FileCtrl):
783
+ inputType = "table"
784
+
785
+ validExt = [
786
+ ".csv", ".tsv", ".txt", ".xl", ".xlsx", ".xlsm", ".xlsb", ".xlam", ".xltx", ".xltm",
787
+ ".xls", ".xlt", ".htm", ".html", ".mht", ".mhtml", ".xml", ".xla", ".xlm", ".odc", ".ods",
788
+ ".udl", ".dsn", ".mdb", ".mde", ".accdb", ".accde", ".dbc", ".dbf", ".iqy", ".dqy", ".rqy",
789
+ ".oqy", ".cub", ".atom", ".atomsvc", ".prn", ".slk", ".dif"
790
+ ]
791
+ dlgWildcard = (
792
+ f"All Table Files({'*'+';*'.join(validExt)})"
793
+ f"|{'*'+';*'.join(validExt)}"
794
+ f"|All Files (*.*)"
795
+ f"|*.*"
796
+ )
797
+
798
+ def makeCtrls(self):
799
+ FileCtrl.makeCtrls(self)
800
+ # Add button to open in Excel
801
+ self.xlBtn = wx.Button(self, style=wx.BU_EXACTFIT)
802
+ self.xlBtn.SetBitmap(
803
+ icons.ButtonIcon(stem="filecsv", size=16, theme="light").bitmap
804
+ )
805
+ self.xlBtn.SetToolTip(
806
+ _translate("Open/create in your default table editor")
807
+ )
808
+ self.xlBtn.Bind(wx.EVT_BUTTON, self.openExcel)
809
+ self.sizer.Add(
810
+ self.xlBtn, border=6, flag=wx.EXPAND | wx.LEFT
811
+ )
812
+ # call initial onChange
813
+ self.onChange()
814
+
815
+ def onChange(self, evt=None):
816
+ FileCtrl.onChange(self, evt)
817
+ # if calling before finished initialising, skip
818
+ if not hasattr(self, "xlBtn"):
819
+ return
820
+
821
+ if not self.getValue().strip():
822
+ # if blank, enable/disable according to presence of template
823
+ self.xlBtn.Enable("template" in self.param.ctrlParams)
824
+ else:
825
+ # otherwise, enable/disable according to validity
826
+ self.xlBtn.Enable(self.isValid)
477
827
 
478
- def setChecked(self, state):
479
- if self.parent.multi:
480
- # If multi select is allowed, leave other values unchanged
481
- values = self.parent.getValue()
482
- if not isinstance(values, (list, tuple)):
483
- values = [values]
484
- if state:
485
- # Add this item to list if checked
486
- values.append(self.value)
487
- else:
488
- # Remove this item from list if unchecked
489
- if self.value in values:
490
- values.remove(self.value)
491
- self.parent.setValue(values)
492
- elif state:
493
- # If single only, set at parent level so others are unchecked
494
- self.parent.setValue(self.value)
828
+ def openExcel(self, event):
829
+ """
830
+ Either open the specified excel sheet, or make a new one from a template
831
+ """
832
+ file = Path(self.getValue())
833
+ # make path absolute
834
+ if not file.is_absolute():
835
+ file = self.rootDir / file
836
+ # open a template if not a valid file
837
+ if file == self.rootDir or not (file.is_file() or file.suffix not in self.validExt):
838
+ dlg = wx.MessageDialog(self, _translate(
839
+ "Once you have created and saved your table, remember to add it here."
840
+ ),
841
+ caption=_translate("Reminder")
842
+ )
843
+ dlg.ShowModal()
844
+ # get template
845
+ if "template" in self.param.ctrlParams:
846
+ file = self.param.ctrlParams['template']
847
+ # if template is specified as a method, call it now to get the value live
848
+ if callable(file):
849
+ file = file()
850
+ # convert to Path
851
+ file = Path(file)
852
+ else:
853
+ # use blank template if none given
854
+ file = Path(experiment.__file__).parent / 'blankTemplate.xltx',
855
+ # Open whatever file is used
856
+ try:
857
+ os.startfile(file)
858
+ except AttributeError:
859
+ opener = "open" if sys.platform == "darwin" else "xdg-open"
860
+ subprocess.call([opener, file])
495
861
 
496
- def onCheck(self, evt):
497
- self.setChecked(evt.IsChecked())
498
862
 
499
- def onToggle(self, evt):
500
- if evt.GetUnicodeKey() in (wx.WXK_SPACE, wx.WXK_NUMPAD_SPACE):
501
- self.setChecked(not self.check.IsChecked())
863
+ class ConditionsCtrl(TableCtrl):
864
+ inputType = "conditions"
502
865
 
503
- def onToggleView(self, evt):
504
- # If called with a boolean, use it directly, otherwise get bool from event
505
- if isinstance(evt, bool):
506
- val = evt
507
- else:
508
- val = evt.IsChecked()
509
- # Update toggle ctrl label
510
- if val:
511
- lbl = "⯆"
512
- else:
513
- lbl = "⯇"
514
- self.toggleView.SetLabel(lbl)
515
- # Show/hide body based on value
516
- self.body.Show(val)
517
- self.link.Show(val)
518
- # Layout
519
- self.Layout()
520
- self.parent.parent.Layout() # layout params notebook page
521
866
 
522
- def __init__(self, parent, valType,
523
- vals="", fieldName="",
524
- choices=[], labels=[],
525
- size=wx.Size(-1, -1),
526
- viewToggle=True):
527
- # Initialise
528
- wx.Panel.__init__(self, parent, size=size)
529
- self.parent = parent
530
- self.valType = valType
531
- self.fieldName = fieldName
532
- self.multi = False
533
- self.viewToggle = viewToggle
534
- # Setup sizer
535
- self.border = wx.BoxSizer()
536
- self.SetSizer(self.border)
537
- self.sizer = wx.BoxSizer(wx.VERTICAL)
538
- self.border.Add(self.sizer, proportion=1, border=6, flag=wx.ALL | wx.EXPAND)
539
- self.SetSizer(self.border)
540
- # Store values
541
- self.choices = {}
542
- for i, val in enumerate(choices):
543
- self.choices[val] = labels[i]
544
- # Populate
545
- self.populate()
546
- # Set value
547
- self.setValue(vals)
548
- # Start off showing according to param
549
- for obj in self.items:
550
- # Work out if we should start out shown
551
- if self.viewToggle:
552
- if obj.startShown == "never":
553
- startShown = False
554
- elif obj.startShown == "checked":
555
- startShown = obj.check.IsChecked()
556
- elif obj.startShown == "unchecked":
557
- startShown = not obj.check.IsChecked()
558
- else:
559
- startShown = True
560
- else:
561
- startShown = True
562
- # Apply starting view
563
- obj.toggleView.SetValue(startShown)
564
- obj.onToggleView(startShown)
565
-
566
- self.Layout()
567
-
568
- def populate(self):
569
- self.items = []
570
- for val, label in self.choices.items():
571
- if not isinstance(label, dict):
572
- # Make sure label is dict
573
- label = {"label": label}
574
- # Add item control
575
- self.addItem(val, label=label)
576
- self.Layout()
577
-
578
- def addItem(self, value, label={}):
579
- # Create item object
580
- item = self.RichChoiceItem(self, value=value, viewToggle=self.viewToggle, **label)
581
- self.items.append(item)
582
- # Add to sizer
583
- self.sizer.Add(item, border=3, flag=wx.ALL | wx.EXPAND)
584
-
585
- def getValue(self):
586
- # Get corresponding value for each checked item
587
- values = []
588
- for item in self.items:
589
- if item.getChecked():
590
- # If checked, append value
591
- values.append(item.value)
592
- # Strip list if not multi
593
- if not self.multi:
594
- if len(values):
595
- values = values[0]
596
- else:
597
- values = ""
598
-
599
- return values
867
+ class SurveyCtrl(SingleLineCtrl):
868
+ inputType = "survey"
600
869
 
601
- def setValue(self, value):
602
- # Make sure value is iterable
603
- if not isinstance(value, (list, tuple)):
604
- value = [value]
605
- # Check/uncheck corresponding items
606
- for item in self.items:
607
- state = item.value in value
608
- item.check.SetValue(state)
609
-
610
- # Post event
611
- evt = wx.ListEvent(commandType=wx.EVT_CHOICE.typeId, id=-1)
612
- evt.SetEventObject(self)
613
- wx.PostEvent(self, evt)
614
-
615
- self.Layout()
616
-
617
-
618
- class FileCtrl(wx.TextCtrl, _ValidatorMixin, _HideMixin, _FileMixin):
619
- def __init__(self, parent, valType,
620
- val="", fieldName="",
621
- size=wx.Size(-1, 24)):
622
- # Create self
623
- wx.TextCtrl.__init__(self)
624
- self.Create(parent, -1, val, name=fieldName, size=size)
625
- self.valType = valType
626
- # Add sizer
627
- self._szr = wx.BoxSizer(wx.HORIZONTAL)
628
- self._szr.Add(self, border=5, proportion=1, flag=wx.EXPAND | wx.RIGHT)
629
- # Add button to browse for file
630
- fldr = icons.ButtonIcon(stem="folder", size=16, theme="light").bitmap
631
- self.findBtn = wx.BitmapButton(parent, -1, bitmap=fldr, style=wx.BU_EXACTFIT)
632
- self.findBtn.SetToolTip(_translate("Specify file ..."))
633
- self.findBtn.Bind(wx.EVT_BUTTON, self.findFile)
634
- self._szr.Add(self.findBtn)
635
- # Configure validation
636
- self.Bind(wx.EVT_TEXT, self.validate)
637
- self.validate()
638
-
639
- def findFile(self, evt):
640
- file = self.getFile()
641
- if file:
642
- self.setFile(file)
643
- self.validate(evt)
644
-
645
- def setFile(self, file):
646
- # Set text value
647
- wx.TextCtrl.SetValue(self, file)
648
- # Post event
649
- evt = wx.FileDirPickerEvent(wx.EVT_FILEPICKER_CHANGED.typeId, self, -1, file)
650
- evt.SetEventObject(self)
651
- wx.PostEvent(self, evt)
652
- # Post keypress event to trigger onchange
653
- evt = wx.FileDirPickerEvent(wx.EVT_KEY_UP.typeId, self, -1, file)
654
- evt.SetEventObject(self)
655
- wx.PostEvent(self, evt)
656
-
657
-
658
- class FileListCtrl(wx.ListBox, _ValidatorMixin, _HideMixin, _FileMixin):
659
- def __init__(self, parent, valType,
660
- choices=[], size=None, pathtype="rel"):
661
- wx.ListBox.__init__(self)
662
- self.valType = valType
663
- parent.Bind(wx.EVT_DROP_FILES, self.addItem)
664
- self.app = parent.app
665
- if type(choices) == str:
666
- choices = data.utils.listFromString(choices)
667
- self.Create(id=wx.ID_ANY, parent=parent, choices=choices, size=size, style=wx.LB_EXTENDED | wx.LB_HSCROLL)
668
- self.addCustomBtn = wx.Button(parent, -1, size=(24,24), style=wx.BU_EXACTFIT, label="...")
669
- self.addCustomBtn.Bind(wx.EVT_BUTTON, self.addCustomItem)
670
- self.addBtn = wx.Button(parent, -1, size=(24,24), style=wx.BU_EXACTFIT, label="+")
671
- self.addBtn.Bind(wx.EVT_BUTTON, self.addItem)
672
- self.subBtn = wx.Button(parent, -1, size=(24,24), style=wx.BU_EXACTFIT, label="-")
673
- self.subBtn.Bind(wx.EVT_BUTTON, self.removeItem)
674
- self._szr = wx.BoxSizer(wx.HORIZONTAL)
675
- self.btns = wx.BoxSizer(wx.VERTICAL)
676
- self.btns.AddMany((self.addCustomBtn, self.addBtn, self.subBtn))
677
- self._szr.Add(self, proportion=1, flag=wx.EXPAND)
678
- self._szr.Add(self.btns)
679
-
680
- def addItem(self, event):
681
- # Get files
682
- if event.GetEventObject() == self.addBtn:
683
- fileList = self.getFiles()
684
- else:
685
- fileList = event.GetFiles()
686
- for i, filename in enumerate(fileList):
687
- try:
688
- fileList[i] = Path(filename).relative_to(self.rootDir)
689
- except ValueError:
690
- fileList[i] = Path(filename).absolute()
691
- # Add files to list
692
- if fileList:
693
- self.InsertItems(fileList, 0)
694
-
695
- def removeItem(self, event):
696
- i = self.GetSelections()
697
- if isinstance(i, int):
698
- i = [i]
699
- items = [item for index, item in enumerate(self.Items)
700
- if index not in i]
701
- self.SetItems(items)
702
-
703
- def addCustomItem(self, event):
704
- # Create string dialog
705
- dlg = wx.TextEntryDialog(parent=self, message=_translate("Add custom item"))
706
- # Show dialog
707
- if dlg.ShowModal() != wx.ID_OK:
708
- return
709
- # Get string
710
- stringEntry = dlg.GetValue()
711
- # Add to list
712
- if stringEntry:
713
- self.InsertItems([stringEntry], 0)
714
-
715
- def GetValue(self):
716
- return self.Items
717
-
718
-
719
- class SurveyCtrl(wx.TextCtrl, _ValidatorMixin, _HideMixin):
720
870
  class SurveyFinderDlg(wx.Dialog, utils.ButtonSizerMixin):
721
871
  def __init__(self, parent, session):
722
872
  wx.Dialog.__init__(self, parent=parent, size=(-1, 496), style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER)
@@ -806,23 +956,15 @@ class SurveyCtrl(wx.TextCtrl, _ValidatorMixin, _HideMixin):
806
956
  else:
807
957
  return ""
808
958
 
809
- def __init__(self, parent, valType,
810
- val="", fieldName="",
811
- size=wx.Size(-1, 24)):
812
- # Create self
813
- wx.TextCtrl.__init__(self)
814
- self.Create(parent, -1, val, name=fieldName, size=size)
815
- self.valType = valType
959
+ def makeCtrls(self):
960
+ SingleLineCtrl.makeCtrls(self)
816
961
  # Add CTRL + click behaviour
817
- self.Bind(wx.EVT_RIGHT_DOWN, self.onRightClick)
818
- # Add placeholder
819
- self.SetHint("e.g. e89cd6eb-296e-4960-af14-103026a59c14")
820
- # Add sizer
821
- self._szr = wx.BoxSizer(wx.HORIZONTAL)
822
- self._szr.Add(self, border=5, proportion=1, flag=wx.EXPAND | wx.RIGHT)
962
+ self.ctrl.Bind(wx.EVT_RIGHT_DOWN, self.onRightClick)
963
+ # add placeholder
964
+ self.ctrl.SetHint("e.g. e89cd6eb-296e-4960-af14-103026a59c14")
823
965
  # Add button to browse for survey
824
966
  self.findBtn = wx.Button(
825
- parent, -1,
967
+ self, -1,
826
968
  label=_translate("Find online..."),
827
969
  size=wx.Size(-1, 24)
828
970
  )
@@ -833,10 +975,7 @@ class SurveyCtrl(wx.TextCtrl, _ValidatorMixin, _HideMixin):
833
975
  "Get survey ID from a list of your surveys on Pavlovia"
834
976
  ))
835
977
  self.findBtn.Bind(wx.EVT_BUTTON, self.findSurvey)
836
- self._szr.Add(self.findBtn)
837
- # Configure validation
838
- self.Bind(wx.EVT_TEXT, self.validate)
839
- self.validate()
978
+ self.sizer.Add(self.findBtn, border=6, flag=wx.LEFT)
840
979
 
841
980
  def onRightClick(self, evt=None):
842
981
  menu = wx.Menu()
@@ -864,9 +1003,7 @@ class SurveyCtrl(wx.TextCtrl, _ValidatorMixin, _HideMixin):
864
1003
  dlg = self.SurveyFinderDlg(self, session)
865
1004
  if dlg.ShowModal() == wx.ID_OK:
866
1005
  # If OK, get value
867
- self.SetValue(dlg.getValue())
868
- # Validate
869
- self.validate()
1006
+ self.ctrl.SetValue(dlg.getValue())
870
1007
  # Raise event
871
1008
  evt = wx.ListEvent(wx.EVT_KEY_UP.typeId)
872
1009
  evt.SetEventObject(self)
@@ -878,7 +1015,7 @@ class SurveyCtrl(wx.TextCtrl, _ValidatorMixin, _HideMixin):
878
1015
  we only take the survey ID
879
1016
  """
880
1017
  # Get value by usual wx method
881
- value = self.GetValue()
1018
+ value = self.ctrl.GetValue()
882
1019
  # Strip pavlovia run url
883
1020
  if "run.pavlovia.org/pavlovia/survey/?surveyId=" in value:
884
1021
  # Keep only the values after the URL
@@ -897,298 +1034,46 @@ class SurveyCtrl(wx.TextCtrl, _ValidatorMixin, _HideMixin):
897
1034
  return value
898
1035
 
899
1036
 
900
- class TableCtrl(wx.TextCtrl, _ValidatorMixin, _HideMixin, _FileMixin):
901
- def __init__(self, parent, param, fieldName="",
902
- size=wx.Size(-1, 24)):
903
- # get val and val type
904
- val = param.val
905
- valType = param.valType
906
- # store param
907
- self.param = param
908
- # Create self
909
- wx.TextCtrl.__init__(self)
910
- self.Create(parent, -1, val, name=fieldName, size=size)
911
- self.valType = valType
912
- # Add sizer
913
- self._szr = wx.BoxSizer(wx.HORIZONTAL)
914
- self._szr.Add(self, proportion=1, border=5, flag=wx.EXPAND | wx.RIGHT)
915
- # Add button to browse for file
916
- fldr = icons.ButtonIcon(stem="folder", size=16, theme="light").bitmap
917
- self.findBtn = wx.BitmapButton(parent, -1, bitmap=fldr, style=wx.BU_EXACTFIT)
918
- self.findBtn.SetToolTip(_translate("Specify file ..."))
919
- self.findBtn.Bind(wx.EVT_BUTTON, self.findFile)
920
- self._szr.Add(self.findBtn)
921
- # Add button to open in Excel
922
- xl = icons.ButtonIcon(stem="filecsv", size=16, theme="light").bitmap
923
- self.xlBtn = wx.BitmapButton(parent, -1, bitmap=xl, style=wx.BU_EXACTFIT)
924
- self.xlBtn.SetToolTip(_translate("Open/create in your default table editor"))
925
- self.xlBtn.Bind(wx.EVT_BUTTON, self.openExcel)
926
- self._szr.Add(self.xlBtn)
927
- # Specify valid extensions
928
- self.validExt = [".csv",".tsv",".txt",
929
- ".xl",".xlsx",".xlsm",".xlsb",".xlam",".xltx",".xltm",".xls",".xlt",
930
- ".htm",".html",".mht",".mhtml",
931
- ".xml",".xla",".xlm",
932
- ".odc",".ods",
933
- ".udl",".dsn",".mdb",".mde",".accdb",".accde",".dbc",".dbf",
934
- ".iqy",".dqy",".rqy",".oqy",
935
- ".cub",".atom",".atomsvc",
936
- ".prn",".slk",".dif"]
937
- # Configure validation
938
- self.Bind(wx.EVT_TEXT, self.validate)
939
- self.validate()
940
-
941
- def validate(self, evt=None):
942
- """Redirect validate calls to global validate method, assigning appropriate valType"""
943
- validate(self, "file")
944
- # if field is blank, enable/diable according to whether there's a template
945
- if not self.GetValue().strip():
946
- self.xlBtn.Enable("template" in self.param.ctrlParams)
947
- # otherwise, enable/disable according to validity
948
- else:
949
- self.xlBtn.Enable(self.valid)
950
- # if value isn't known until runtime, always disable Excel button
951
- if "$" in self.GetValue():
952
- self.xlBtn.Disable()
1037
+ class ColorCtrl(SingleLineCtrl):
1038
+ inputType = "color"
953
1039
 
954
- def openExcel(self, event):
955
- """Either open the specified excel sheet, or make a new one from a template"""
956
- file = self.rootDir / self.GetValue()
957
- if not (file.is_file() and file.suffix in self.validExt): # If not a valid file
958
- dlg = wx.MessageDialog(self, _translate(
959
- "Once you have created and saved your table,"
960
- "please remember to add it to {name}").format(name=_translate(self.Name)),
961
- caption=_translate("Reminder"))
962
- dlg.ShowModal()
963
- # get template
964
- if "template" in self.param.ctrlParams:
965
- file = self.param.ctrlParams['template']
966
- # if template is specified as a method, call it now to get the value live
967
- if callable(file):
968
- file = file()
969
- # convert to Path
970
- file = Path(file)
971
- else:
972
- # use blank template if none given
973
- file = Path(experiment.__file__).parent / 'blankTemplate.xltx',
974
- # Open whatever file is used
975
- try:
976
- os.startfile(file)
977
- except AttributeError:
978
- opener = "open" if sys.platform == "darwin" else "xdg-open"
979
- subprocess.call([opener, file])
980
-
981
- def findFile(self, event):
982
- _wld = f"All Table Files({'*'+';*'.join(self.validExt)})|{'*'+';*'.join(self.validExt)}|All Files (*.*)|*.*"
983
- file = self.getFile(msg="Specify table file ...", wildcard=_wld)
984
- if file:
985
- FileCtrl.setFile(self, file)
986
- self.validate(event)
987
-
988
-
989
- class ColorCtrl(wx.TextCtrl, _ValidatorMixin, _HideMixin):
990
- def __init__(self, parent, valType,
991
- val="", fieldName="",
992
- size=wx.Size(-1, 24)):
993
- # Create self
994
- wx.TextCtrl.__init__(self)
995
- self.Create(parent, -1, val, name=fieldName, size=size)
996
- self.valType = valType
997
- # Add sizer
998
- self._szr = wx.BoxSizer(wx.HORIZONTAL)
999
- if valType == "code":
1000
- # Add $ for anything to be interpreted verbatim
1001
- self.dollarLbl = wx.StaticText(parent, -1, "$", size=wx.Size(-1, -1), style=wx.ALIGN_RIGHT)
1002
- self.dollarLbl.SetToolTip(_translate("This parameter will be treated as code - we have already put in the $, so you don't have to."))
1003
- self._szr.Add(self.dollarLbl, border=5, flag=wx.ALIGN_CENTER_VERTICAL | wx.RIGHT | wx.LEFT)
1004
- # Add ctrl to sizer
1005
- self._szr.Add(self, proportion=1, border=5, flag=wx.EXPAND | wx.RIGHT)
1006
- # Add button to activate color picker
1007
- fldr = icons.ButtonIcon(stem="color", size=16, theme="light").bitmap
1008
- self.pickerBtn = wx.BitmapButton(parent, -1, bitmap=fldr, style=wx.BU_EXACTFIT)
1040
+ def makeCtrls(self):
1041
+ SingleLineCtrl.makeCtrls(self)
1042
+ # add button to activate color picker
1043
+ self.pickerBtn = wx.Button(self, style=wx.BU_EXACTFIT)
1044
+ self.pickerBtn.SetBitmap(
1045
+ icons.ButtonIcon(stem="color", size=16, theme="light").bitmap
1046
+ )
1009
1047
  self.pickerBtn.SetToolTip(_translate("Specify color ..."))
1010
1048
  self.pickerBtn.Bind(wx.EVT_BUTTON, self.colorPicker)
1011
- self._szr.Add(self.pickerBtn)
1012
- # Bind to validation
1013
- self.Bind(wx.EVT_CHAR, self.validate)
1014
- self.validate()
1049
+ self.sizer.Add(self.pickerBtn)
1015
1050
 
1016
1051
  def colorPicker(self, evt):
1052
+ # show color picker
1017
1053
  dlg = PsychoColorPicker(self, context=self, allowCopy=False) # open a color picker
1018
- dlg.ShowModal()
1054
+ ret = dlg.ShowModal()
1055
+ if ret == wx.ID_OK:
1056
+ self.setValue(
1057
+ f"$({dlg.getOutputValue()})"
1058
+ )
1059
+ else:
1060
+ pass
1019
1061
  dlg.Destroy()
1020
1062
 
1021
1063
 
1022
- def validate(obj, valType):
1023
- val = str(obj.GetValue())
1024
- valid = True
1025
- if val.startswith("$"):
1026
- # If indicated as code, treat as code
1027
- valType = "code"
1028
- # Validate string
1029
- if valType == "str":
1030
- if re.findall(r"(?<!\\)\"", val):
1031
- # If there are unescaped "
1032
- valid = False
1033
- if re.findall(r"(?<!\\)\'", val):
1034
- # If there are unescaped '
1035
- valid = False
1036
- # Validate code
1037
- if valType == "code":
1038
- # Replace unescaped curly quotes
1039
- if re.findall(r"(?<!\\)[\u201c\u201d]", val):
1040
- pt = obj.GetInsertionPoint()
1041
- obj.SetValue(re.sub(r"(?<!\\)[\u201c\u201d]", "\"", val))
1042
- obj.SetInsertionPoint(pt)
1043
- # For now, ignore
1044
- pass
1045
- # Validate num
1046
- if valType in ["num", "int"]:
1047
- try:
1048
- # Try to convert value to a float
1049
- float(val)
1050
- except ValueError:
1051
- # If conversion fails, value is invalid
1052
- valid = False
1053
- # Validate bool
1054
- if valType == "bool":
1055
- if val not in ["True", "False"]:
1056
- # If value is not True or False, it is invalid
1057
- valid = False
1058
- # Validate list
1059
- if valType == "list":
1060
- empty = not bool(val) # Is value empty?
1061
- fullList = re.fullmatch(r"[\(\[].*[\]\)]", val) # Is value full list with parentheses?
1062
- partList = "," in val and not re.match(r"[\(\[].*[\]\)]", val) # Is value list without parentheses?
1063
- singleVal = not " " in val or re.match(r"[\"\'].*[\"\']", val) # Is value a single value?
1064
- if not any([empty, fullList, partList, singleVal]):
1065
- # If value is not any of valid types, it is invalid
1066
- valid = False
1067
- # Validate color
1068
- if valType == "color":
1069
- # Strip function calls
1070
- if re.fullmatch(r"\$?(Advanced)?Color\(.*\)", val):
1071
- val = re.sub(r"\$?(Advanced)?Color\(", "", val[:-1])
1072
- try:
1073
- # Try to create a Color object from value
1074
- obj.color = Color(val, False)
1075
- if not obj.color:
1076
- # If invalid object is created, input is invalid
1077
- valid = False
1078
- except:
1079
- # If object creation fails, input is invalid
1080
- valid = False
1081
- if valType == "file":
1082
- val = Path(str(val))
1083
- if not val.is_absolute():
1084
- frame = obj.GetTopLevelParent()
1085
- if hasattr(frame, "frame"):
1086
- frame = frame.frame
1087
- # If not an absolute path, append to current directory
1088
- val = Path(frame.filename).parent / val
1089
- if not val.is_file():
1090
- # Is value a valid filepath?
1091
- valid = False
1092
- if hasattr(obj, "validExt"):
1093
- # If control has specified list of ext, does value end in correct ext?
1094
- if val.suffix not in obj.validExt:
1095
- valid = False
1096
-
1097
- # If additional allowed values are defined, override validation
1098
- if hasattr(obj, "allowedVals"):
1099
- if val in obj.allowedVals:
1100
- valid = True
1101
-
1102
- # Apply valid status to object
1103
- obj.valid = valid
1104
- if hasattr(obj, "showValid"):
1105
- obj.showValid(valid)
1106
-
1107
- # Update code font
1108
- obj.updateCodeFont(valType)
1109
-
1110
-
1111
- class DictCtrl(ListWidget, _ValidatorMixin, _HideMixin):
1112
- def __init__(self, parent,
1113
- val={}, labels=(_translate("Field"), _translate("Default")), valType='dict',
1114
- fieldName=""):
1115
- # try to convert to a dict if given a string
1116
- if isinstance(val, str):
1117
- try:
1118
- val = ast.literal_eval(val)
1119
- except:
1120
- raise ValueError(_translate("Could not interpret parameter value as a dict:\n{}").format(val))
1121
- # raise error if still not a dict
1122
- if not isinstance(val, (dict, list)):
1123
- raise ValueError("DictCtrl must be supplied with either a dict or a list of 1-long dicts, value supplied was {}: {}".format(type(val), val))
1124
- # Get labels
1125
- keyLbl, valLbl = labels
1126
- # If supplied with a dict, convert it to a list of dicts
1127
- if isinstance(val, dict):
1128
- newVal = []
1129
- for key, v in val.items():
1130
- if hasattr(v, "val"):
1131
- v = v.val
1132
- newVal.append({keyLbl: key, valLbl: v})
1133
- val = newVal
1134
- # Make sure we have at least 1 value
1135
- if not len(val):
1136
- val = [{keyLbl: "", valLbl: ""}]
1137
- # If any items within the list are not dicts or are dicts longer than 1, throw error
1138
- if not all(isinstance(v, dict) and len(v) == 2 for v in val):
1139
- raise ValueError("DictCtrl must be supplied with either a dict or a list of 1-long dicts, value supplied was {}".format(val))
1140
- # Create ListWidget
1141
- ListWidget.__init__(self, parent, val, order=labels)
1142
-
1143
- def SetForegroundColour(self, color):
1144
- for child in self.Children:
1145
- if hasattr(child, "SetForegroundColour"):
1146
- child.SetForegroundColour(color)
1147
-
1148
- def Enable(self, enable=True):
1149
- """
1150
- Enable or disable all items in the dict ctrl
1151
- """
1152
- # Iterate through all children
1153
- for cell in self.Children:
1154
- # Get the actual child rather than the sizer item
1155
- child = cell.Window
1156
- # If it can be enabled/disabled, enable/disable it
1157
- if hasattr(child, "Enable"):
1158
- child.Enable(enable)
1159
-
1160
- def Disable(self):
1161
- """
1162
- Disable all items in the dict ctrl
1163
- """
1164
- self.Enable(False)
1165
-
1166
- def Show(self, show=True):
1167
- """
1168
- Show or hide all items in the dict ctrl
1169
- """
1170
- # Iterate through all children
1171
- for cell in self.Children:
1172
- # Get the actual child rather than the sizer item
1173
- child = cell.Window
1174
- # If it can be shown/hidden, show/hide it
1175
- if hasattr(child, "Show"):
1176
- child.Show(show)
1177
-
1178
- def Hide(self):
1179
- """
1180
- Hide all items in the dict ctrl
1181
- """
1182
- self.Show(False)
1183
-
1184
-
1185
1064
  class FontCtrl(SingleLineCtrl):
1186
- def onOK(self):
1065
+ inputType = "font"
1066
+
1067
+ def onElementOk(self, evt=None):
1187
1068
  # get a font manager
1188
1069
  from psychopy.tools.fontmanager import FontManager, MissingFontError
1189
1070
  fm = FontManager()
1190
1071
  # check whether the font is installed
1191
- installed = fm.getFontsMatching(self.GetValue(), fallback=False)
1072
+ if self.element and hasattr(self.element, "exp") and self.element.exp.filename:
1073
+ currentDir = Path(self.element.exp.filename).parent
1074
+ else:
1075
+ currentDir = Path(".")
1076
+ installed = fm.getFontsMatching(self.getValue(), fallback=False, currentDir=currentDir)
1192
1077
  # if not installed, ask the user whether to download from Google Fonts
1193
1078
  if not installed:
1194
1079
  # create dialog
@@ -1196,19 +1081,19 @@ class FontCtrl(SingleLineCtrl):
1196
1081
  self.GetTopLevelParent(),
1197
1082
  _translate(
1198
1083
  "Font {} is not installed, would you like to download it from Google Fonts?"
1199
- ).format(self.GetValue()),
1084
+ ).format(self.getValue()),
1200
1085
  style=wx.YES|wx.NO|wx.ICON_QUESTION
1201
1086
  )
1202
1087
  # download if yes
1203
1088
  if dlg.ShowModal() == wx.ID_YES:
1204
1089
  try:
1205
- fm.addGoogleFont(self.GetValue().strip())
1090
+ fm.addGoogleFont(self.getValue().strip())
1206
1091
  except MissingFontError as err:
1207
1092
  dlg = wx.MessageDialog(
1208
1093
  self.GetTopLevelParent(),
1209
1094
  _translate(
1210
1095
  "Could not download font {} from Google Fonts, reason: {}"
1211
- ).format(self.GetValue(), err),
1096
+ ).format(self.getValue(), err),
1212
1097
  style=wx.OK|wx.ICON_ERROR
1213
1098
  )
1214
1099
  dlg.ShowModal()
@@ -1221,3 +1106,682 @@ class FontCtrl(SingleLineCtrl):
1221
1106
  style=wx.OK|wx.ICON_INFORMATION
1222
1107
  )
1223
1108
  dlg.ShowModal()
1109
+
1110
+
1111
+ class CodeCtrl(BaseParamCtrl, handlers.ThemeMixin):
1112
+ inputType = "code"
1113
+
1114
+ def makeCtrls(self):
1115
+ self.ctrl = CodeBox(
1116
+ self, wx.ID_ANY, prefs,
1117
+ pos=wx.DefaultPosition, size=(-1, 128), style=wx.DEFAULT
1118
+ )
1119
+ self.sizer.Add(
1120
+ self.ctrl, proportion=1, flag=wx.EXPAND | wx.ALL
1121
+ )
1122
+ # hide margin
1123
+ self.ctrl.SetMarginWidth(0, 0)
1124
+ # set initial value
1125
+ self.setValue(self.param.val)
1126
+ # setup auto indent behaviour as in Code component
1127
+ self.ctrl.Bind(wx.EVT_KEY_DOWN, self.onChange)
1128
+
1129
+ def getValue(self):
1130
+ return self.ctrl.GetText()
1131
+
1132
+ def setValue(self, value):
1133
+ # get insertion point if possible
1134
+ pt = self.ctrl.GetInsertionPoint()
1135
+ # set value
1136
+ self.ctrl.SetText(str(value))
1137
+ # restore insertion point if possible
1138
+ try:
1139
+ self.ctrl.SetInsertionPoint(pt)
1140
+ except:
1141
+ pass
1142
+
1143
+ def onChange(self, evt=None):
1144
+ CodeBox.OnKeyPressed(self.ctrl, evt)
1145
+ BaseParamCtrl.onChange(self, evt)
1146
+
1147
+ def styleValid(self):
1148
+ # red border if error
1149
+ if self.isValid:
1150
+ self.ctrl.SetFoldMarginColour(0, colors.scheme['red'])
1151
+ else:
1152
+ self.ctrl._applyAppTheme()
1153
+ self.ctrl.Refresh()
1154
+
1155
+ def validate(self):
1156
+ BaseParamCtrl.validate(self)
1157
+ return SingleLineCtrl.validateCode(self)
1158
+
1159
+
1160
+ class RichChoiceCtrl(BaseParamCtrl):
1161
+ inputType = "richChoice"
1162
+
1163
+ viewToggle = True
1164
+ multi = False
1165
+
1166
+ class RichChoiceItem(wx.Panel):
1167
+ def __init__(self, parent, value, label, body="", linkText="", link="", startShown="always", viewToggle=True):
1168
+ # Initialise
1169
+ wx.Panel.__init__(self, parent, style=wx.BORDER_THEME)
1170
+ self.parent = parent
1171
+ self.value = value
1172
+ self.startShown = startShown
1173
+ # Setup sizer
1174
+ self.border = wx.BoxSizer()
1175
+ self.SetSizer(self.border)
1176
+ self.sizer = wx.FlexGridSizer(cols=3)
1177
+ self.sizer.AddGrowableCol(idx=1, proportion=1)
1178
+ self.border.Add(self.sizer, proportion=1, border=6, flag=wx.ALL | wx.EXPAND)
1179
+ # Check
1180
+ self.check = wx.CheckBox(self, label=" ")
1181
+ self.check.Bind(wx.EVT_CHECKBOX, self.onCheck)
1182
+ self.check.Bind(wx.EVT_KEY_UP, self.onToggle)
1183
+ self.sizer.Add(self.check, border=3, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL)
1184
+ # Title
1185
+ self.title = wx.StaticText(self, label=label)
1186
+ self.title.SetFont(self.title.GetFont().Bold())
1187
+ self.sizer.Add(self.title, border=3, flag=wx.ALL | wx.EXPAND)
1188
+ # Toggle
1189
+ self.toggleView = wx.ToggleButton(self, style=wx.BU_EXACTFIT)
1190
+ self.toggleView.Bind(wx.EVT_TOGGLEBUTTON, self.onToggleView)
1191
+ self.toggleView.Show(viewToggle)
1192
+ self.sizer.Add(self.toggleView, border=3, flag=wx.ALL | wx.EXPAND)
1193
+ # Body
1194
+ self.body = utils.WrappedStaticText(self, label=body)
1195
+ self.sizer.AddStretchSpacer(1)
1196
+ self.sizer.Add(self.body, border=3, proportion=1, flag=wx.ALL | wx.EXPAND)
1197
+ self.sizer.AddStretchSpacer(1)
1198
+ # Link
1199
+ self.link = utils.HyperLinkCtrl(self, label=linkText, URL=link)
1200
+ self.link.SetBackgroundColour(self.GetBackgroundColour())
1201
+ self.sizer.AddStretchSpacer(1)
1202
+ self.sizer.Add(self.link, border=3, flag=wx.ALL | wx.ALIGN_LEFT)
1203
+ self.sizer.AddStretchSpacer(1)
1204
+
1205
+ # Style
1206
+ self.SetBackgroundColour("white")
1207
+ self.body.SetBackgroundColour("white")
1208
+ self.link.SetBackgroundColour("white")
1209
+ self.toggleView.SetBackgroundColour("white")
1210
+
1211
+ self.Layout()
1212
+
1213
+ def getChecked(self):
1214
+ return self.check.GetValue()
1215
+
1216
+ def setChecked(self, state):
1217
+ if self.parent.multi:
1218
+ # If multi select is allowed, leave other values unchanged
1219
+ values = self.parent.getValue()
1220
+ if not isinstance(values, (list, tuple)):
1221
+ values = [values]
1222
+ if state:
1223
+ # Add this item to list if checked
1224
+ values.append(self.value)
1225
+ else:
1226
+ # Remove this item from list if unchecked
1227
+ if self.value in values:
1228
+ values.remove(self.value)
1229
+ self.parent.setValue(values)
1230
+ elif state:
1231
+ # If single only, set at parent level so others are unchecked
1232
+ self.parent.setValue(self.value)
1233
+
1234
+ # post event
1235
+ evt = wx.ListEvent(commandType=wx.EVT_CHOICE.typeId, id=-1)
1236
+ evt.SetString(self.value)
1237
+ evt.SetEventObject(self.parent)
1238
+ wx.PostEvent(self.parent, evt)
1239
+
1240
+ def onCheck(self, evt):
1241
+ self.setChecked(evt.IsChecked())
1242
+
1243
+ def onToggle(self, evt):
1244
+ if evt.GetUnicodeKey() in (wx.WXK_SPACE, wx.WXK_NUMPAD_SPACE):
1245
+ self.setChecked(not self.check.IsChecked())
1246
+
1247
+ def onToggleView(self, evt):
1248
+ # If called with a boolean, use it directly, otherwise get bool from event
1249
+ if isinstance(evt, bool):
1250
+ val = evt
1251
+ else:
1252
+ val = evt.IsChecked()
1253
+ # Update toggle ctrl label
1254
+ if val:
1255
+ lbl = "⯆"
1256
+ else:
1257
+ lbl = "⯇"
1258
+ self.toggleView.SetLabel(lbl)
1259
+ # Show/hide body based on value
1260
+ self.body.Show(val)
1261
+ self.link.Show(val)
1262
+ # Layout
1263
+ self.Layout()
1264
+ self.parent.parent.Layout() # layout params notebook page
1265
+
1266
+ def makeCtrls(self):
1267
+ self.ctrl = self
1268
+ # make sizer for options
1269
+ self.optionsSizer = wx.BoxSizer(wx.VERTICAL)
1270
+ self.sizer.Add(
1271
+ self.optionsSizer, proportion=1, flag=wx.EXPAND | wx.ALL
1272
+ )
1273
+ # store values
1274
+ self.choices = {}
1275
+ for i, val in enumerate(self.param.allowedVals):
1276
+ self.choices[val] = self.param.allowedLabels[i]
1277
+ # Populate
1278
+ self.populate()
1279
+ # Set value
1280
+ self.setValue(self.param.val)
1281
+ # Start off showing according to param
1282
+ for obj in self.items:
1283
+ # Work out if we should start out shown
1284
+ if self.viewToggle:
1285
+ if obj.startShown == "never":
1286
+ startShown = False
1287
+ elif obj.startShown == "checked":
1288
+ startShown = obj.check.IsChecked()
1289
+ elif obj.startShown == "unchecked":
1290
+ startShown = not obj.check.IsChecked()
1291
+ else:
1292
+ startShown = True
1293
+ else:
1294
+ startShown = True
1295
+ # Apply starting view
1296
+ obj.toggleView.SetValue(startShown)
1297
+ obj.onToggleView(startShown)
1298
+ # bind onChange
1299
+ self.Bind(wx.EVT_CHOICE, self.onChange)
1300
+
1301
+ self.Layout()
1302
+
1303
+ def populate(self):
1304
+ self.items = []
1305
+ for val, label in self.choices.items():
1306
+ if not isinstance(label, dict):
1307
+ # Make sure label is dict
1308
+ label = {"label": label}
1309
+ # Add item control
1310
+ self.addItem(val, label=label)
1311
+ self.Layout()
1312
+
1313
+ def addItem(self, value, label={}):
1314
+ # Create item object
1315
+ item = self.RichChoiceItem(self, value=value, viewToggle=self.viewToggle, **label)
1316
+ self.items.append(item)
1317
+ # Add to sizer
1318
+ self.optionsSizer.Add(item, border=3, flag=wx.ALL | wx.EXPAND)
1319
+
1320
+ def getValue(self):
1321
+ # Get corresponding value for each checked item
1322
+ values = []
1323
+ for item in self.items:
1324
+ if item.getChecked():
1325
+ # If checked, append value
1326
+ values.append(item.value)
1327
+ # Strip list if not multi
1328
+ if not self.multi:
1329
+ if len(values):
1330
+ values = values[0]
1331
+ else:
1332
+ values = ""
1333
+
1334
+ return values
1335
+
1336
+ def setValue(self, value):
1337
+ # Make sure value is iterable
1338
+ value = data.utils.listFromString(value)
1339
+ # Check/uncheck corresponding items
1340
+ for item in self.items:
1341
+ state = item.value in value
1342
+ item.check.SetValue(state)
1343
+
1344
+ self.Layout()
1345
+
1346
+
1347
+ class FileListCtrl(BaseParamCtrl):
1348
+ inputType = "fileList"
1349
+
1350
+ dlgWildcard = "All Files (*.*)|*.*"
1351
+ dlgStyle = wx.FD_FILE_MUST_EXIST
1352
+
1353
+ class FileListItem(FileCtrl):
1354
+ def makeCtrls(self):
1355
+ FileCtrl.makeCtrls(self)
1356
+ # add a delete button
1357
+ self.deleteBtn = wx.Button(self, style=wx.BU_EXACTFIT)
1358
+ self.deleteBtn.SetBitmap(
1359
+ icons.ButtonIcon("delete", size=16, theme="light").bitmap
1360
+ )
1361
+ self.sizer.Add(
1362
+ self.deleteBtn, border=6, flag=wx.EXPAND | wx.LEFT
1363
+ )
1364
+ self.deleteBtn.Bind(wx.EVT_BUTTON, self.deleteSelf)
1365
+
1366
+ self.Layout()
1367
+
1368
+ def deleteSelf(self, evt=None):
1369
+ # remove from parent sizer and array
1370
+ self.parent.items.pop(
1371
+ self.parent.items.index(self)
1372
+ )
1373
+ self.parent.itemsSizer.Detach(self)
1374
+ # clear any warnings
1375
+ self.clearWarning()
1376
+ # delete
1377
+ self.Destroy()
1378
+ self.parent.Layout()
1379
+
1380
+ def onChange(self, evt=None):
1381
+ FileCtrl.onChange(self, evt)
1382
+ self.parent.onChange(evt)
1383
+
1384
+ def makeCtrls(self):
1385
+ self.ctrl = self
1386
+ # make own sizer vertical
1387
+ self.sizer.SetOrientation(wx.VERTICAL)
1388
+ # array to store items
1389
+ self.items = []
1390
+ # sizer to layout items
1391
+ self.itemsSizer = wx.BoxSizer(wx.VERTICAL)
1392
+ self.sizer.Add(
1393
+ self.itemsSizer, border=6, proportion=1, flag=wx.EXPAND | wx.BOTTOM
1394
+ )
1395
+ # add multiple button
1396
+ self.addManyBtn = wx.Button(self, label=_translate("Add multiple items"))
1397
+ self.addManyBtn.SetBitmap(
1398
+ icons.ButtonIcon("add_many", size=16, theme="light").bitmap
1399
+ )
1400
+ self.sizer.Add(
1401
+ self.addManyBtn, border=6, flag=wx.ALIGN_LEFT | wx.BOTTOM
1402
+ )
1403
+ self.addManyBtn.Bind(wx.EVT_BUTTON, self.addMultiItems)
1404
+ # add button
1405
+ self.addBtn = wx.Button(self, label=_translate("Add item"))
1406
+ self.addBtn.SetBitmap(
1407
+ icons.ButtonIcon("add", size=16, theme="light").bitmap
1408
+ )
1409
+ self.sizer.Add(
1410
+ self.addBtn, border=6, flag=wx.ALIGN_LEFT | wx.BOTTOM
1411
+ )
1412
+ self.addBtn.Bind(wx.EVT_BUTTON, self.addItem)
1413
+ # set initial value
1414
+ self.setValue(self.param.val)
1415
+
1416
+ def layout(self):
1417
+ """
1418
+ Layout this element, and fit its parent around it.
1419
+ """
1420
+ self.Layout()
1421
+ self.GetParent().Layout()
1422
+ self.GetTopLevelParent().Layout()
1423
+ self.GetTopLevelParent().Fit()
1424
+
1425
+ def addItem(self, evt=None):
1426
+ """
1427
+ Add a new item to this ctrl
1428
+ """
1429
+ # make a file control for a param not attached to anything
1430
+ item = self.FileListItem(
1431
+ parent=self,
1432
+ field=str(len(self.items)),
1433
+ param=Param("", valType="str", inputType="file"),
1434
+ element=self.element,
1435
+ warnings=self.warnings
1436
+ )
1437
+ # append it to items array
1438
+ self.items.append(item)
1439
+ # add it to the items sizer
1440
+ self.itemsSizer.Add(
1441
+ item, border=6, flag=wx.EXPAND | wx.BOTTOM
1442
+ )
1443
+
1444
+ self.layout()
1445
+
1446
+ return item
1447
+
1448
+ def addMultiItems(self, evt=None):
1449
+ """
1450
+ Add several new items to this ctrl
1451
+ """
1452
+ items = []
1453
+ # open a file browser dialog
1454
+ dlg = wx.FileDialog(
1455
+ self,
1456
+ message=_translate("Specify file..."),
1457
+ defaultDir=str(self.rootDir),
1458
+ style=wx.FD_OPEN | wx.FD_MULTIPLE | self.dlgStyle,
1459
+ wildcard=self.dlgWildcard,
1460
+ )
1461
+ if dlg.ShowModal() != wx.ID_OK:
1462
+ return
1463
+ # get path
1464
+ for file in dlg.GetPaths():
1465
+ # relativise
1466
+ try:
1467
+ filename = Path(file).relative_to(self.rootDir)
1468
+ except ValueError:
1469
+ filename = Path(file).absolute()
1470
+ # make a file control for a param not attached to anything
1471
+ item = self.FileListItem(
1472
+ parent=self,
1473
+ field=str(len(self.items)),
1474
+ param=Param(str(filename).replace("\\", "/"), valType="str", inputType="file"),
1475
+ element=self.element,
1476
+ warnings=self.warnings
1477
+ )
1478
+ items.append(item)
1479
+ # append it to items array
1480
+ self.items.append(item)
1481
+ # add it to the items sizer
1482
+ self.itemsSizer.Add(
1483
+ item, border=6, flag=wx.EXPAND | wx.BOTTOM
1484
+ )
1485
+
1486
+ self.layout()
1487
+
1488
+ return items
1489
+
1490
+ def clearItems(self):
1491
+ """
1492
+ Clear all items from this ctrl
1493
+ """
1494
+ for item in self.items:
1495
+ item.deleteSelf()
1496
+
1497
+ self.layout()
1498
+
1499
+ def getValue(self):
1500
+ return [item.getValue() for item in self.items]
1501
+
1502
+ def setValue(self, value):
1503
+ # unstring value into an actual list
1504
+ value = data.utils.listFromString(value)
1505
+ # clear all items
1506
+ self.clearItems()
1507
+ # make a new item for each value
1508
+ for item in value:
1509
+ ctrl = self.addItem()
1510
+ ctrl.setValue(item)
1511
+
1512
+ @property
1513
+ def isValid(self):
1514
+ # return True if all children are valid
1515
+ return all([
1516
+ item.isValid
1517
+ for item in self.items
1518
+ ])
1519
+
1520
+ def validate(self):
1521
+ for item in self.items:
1522
+ item.validate()
1523
+
1524
+ @property
1525
+ def rootDir(self):
1526
+ # if no element, use system root
1527
+ if self.element is None or not hasattr(self.element, "exp"):
1528
+ return Path()
1529
+ # otherwise, get from experiment
1530
+ root = Path(self.element.exp.filename)
1531
+ # move up a dir if root is a file
1532
+ if root.is_file():
1533
+ root = root.parent
1534
+
1535
+ return root
1536
+
1537
+
1538
+ class DictCtrl(BaseParamCtrl):
1539
+ inputType = "dict"
1540
+
1541
+ class DictKey(SingleLineCtrl):
1542
+ def validate(self):
1543
+ """
1544
+ Dict keys can't key variables
1545
+ """
1546
+ if self.isCode:
1547
+ self.setWarning(_translate(
1548
+ "Dictionary keys can't be code"
1549
+ ), allowed=False)
1550
+ else:
1551
+ SingleLineCtrl.validate(self)
1552
+
1553
+ def onChange(self, evt=None):
1554
+ SingleLineCtrl.onChange(self, evt)
1555
+ self.parent.onChange(evt)
1556
+
1557
+ class DictValue(SingleLineCtrl):
1558
+ def validate(self):
1559
+ # update param label so the error reports the value of keyctrl
1560
+ if hasattr(self, "keyCtrl"):
1561
+ self.param.label = f"{self.parent.param.label}:{self.keyCtrl.getValue()}"
1562
+
1563
+ # validate first as code
1564
+ self.param.valType = "code"
1565
+ self.dollarLbl.Show()
1566
+ self.warnings.clearWarning(self)
1567
+ self.validateCode()
1568
+ # if this failed, try as string
1569
+ if self.warnings.getWarning(self):
1570
+ self.warnings.clearWarning(self)
1571
+ self.param.valType = "str"
1572
+ self.validateStr()
1573
+
1574
+ self.dollarLbl.Show(self.param.valType == "code")
1575
+
1576
+ self.Refresh()
1577
+ self.Layout()
1578
+
1579
+ def onChange(self, evt=None):
1580
+ SingleLineCtrl.onChange(self, evt)
1581
+ self.parent.onChange(evt)
1582
+
1583
+ class DictField:
1584
+ def __init__(self, parent):
1585
+ # store parent
1586
+ self.parent = parent
1587
+ # add ctrl for key
1588
+ self.keyCtrl = DictCtrl.DictKey(
1589
+ parent=parent,
1590
+ field=f"key{len(parent.items)}",
1591
+ param=Param("", valType="str", inputType="single"),
1592
+ element=parent.element,
1593
+ warnings=parent.warnings
1594
+ )
1595
+ # add ctrl for value
1596
+ self.valueCtrl = DictCtrl.DictValue(
1597
+ parent=parent,
1598
+ field=f"value{len(parent.items)}",
1599
+ param=Param("", valType="code", inputType="single"),
1600
+ element=parent.element,
1601
+ warnings=parent.warnings
1602
+ )
1603
+ self.valueCtrl.keyCtrl = self.keyCtrl
1604
+ # add delete button
1605
+ self.deleteBtn = wx.Button(parent, style=wx.BU_EXACTFIT)
1606
+ self.deleteBtn.SetBitmap(
1607
+ icons.ButtonIcon("delete", size=16, theme="light").bitmap
1608
+ )
1609
+ self.deleteBtn.Bind(wx.EVT_BUTTON, self.deleteSelf)
1610
+
1611
+ def deleteSelf(self, evt=None):
1612
+ # remove from parent array
1613
+ self.parent.items.pop(
1614
+ self.parent.items.index(self)
1615
+ )
1616
+ # clear any warnings
1617
+ self.keyCtrl.clearWarning()
1618
+ self.valueCtrl.clearWarning()
1619
+ # remove all windows from parent sizer
1620
+ self.parent.itemsSizer.Detach(self.keyCtrl)
1621
+ self.parent.itemsSizer.Detach(self.valueCtrl)
1622
+ self.parent.itemsSizer.Detach(self.deleteBtn)
1623
+ # delete all windows
1624
+ self.keyCtrl.Destroy()
1625
+ self.valueCtrl.Destroy()
1626
+ self.deleteBtn.Destroy()
1627
+ # layout
1628
+ self.parent.layout()
1629
+
1630
+ def makeCtrls(self):
1631
+ self.ctrl = self
1632
+ # make own sizer vertical
1633
+ self.sizer.SetOrientation(wx.VERTICAL)
1634
+ # array to store items
1635
+ self.items = []
1636
+ # sizer to layout items
1637
+ self.itemsSizer = wx.FlexGridSizer(3, vgap=6, hgap=6)
1638
+ self.itemsSizer.AddGrowableCol(0, proportion=1)
1639
+ self.itemsSizer.AddGrowableCol(1, proportion=1)
1640
+ self.sizer.Add(
1641
+ self.itemsSizer, border=6, proportion=1, flag=wx.EXPAND | wx.BOTTOM
1642
+ )
1643
+ # add button
1644
+ self.addBtn = wx.Button(self, label=_translate("Add item"))
1645
+ self.addBtn.SetBitmap(
1646
+ icons.ButtonIcon("add", size=16, theme="light").bitmap
1647
+ )
1648
+ self.sizer.Add(
1649
+ self.addBtn, border=6, flag=wx.ALIGN_LEFT | wx.BOTTOM
1650
+ )
1651
+ self.addBtn.Bind(wx.EVT_BUTTON, self.addItem)
1652
+ # set initial value
1653
+ self.setValue(self.param.val)
1654
+
1655
+ def layout(self):
1656
+ """
1657
+ Layout this element, and fit its parent around it.
1658
+ """
1659
+ self.Layout()
1660
+ self.GetParent().Layout()
1661
+ self.GetTopLevelParent().Layout()
1662
+ self.GetTopLevelParent().Fit()
1663
+
1664
+ def addItem(self, evt=None):
1665
+ # create item
1666
+ item = self.DictField(self)
1667
+ # append to array
1668
+ self.items.append(item)
1669
+ # add to sizer
1670
+ self.itemsSizer.Add(
1671
+ item.keyCtrl, proportion=1, flag=wx.EXPAND
1672
+ )
1673
+ self.itemsSizer.Add(
1674
+ item.valueCtrl, proportion=1, flag=wx.EXPAND
1675
+ )
1676
+ self.itemsSizer.Add(
1677
+ item.deleteBtn, flag=wx.EXPAND
1678
+ )
1679
+ # layout
1680
+ self.layout()
1681
+
1682
+ return item
1683
+
1684
+ def clearItems(self):
1685
+ # delete each item
1686
+ for item in self.items:
1687
+ item.deleteSelf()
1688
+ # layout
1689
+ self.layout()
1690
+
1691
+ def getValue(self):
1692
+ return {
1693
+ item.keyCtrl.getValue(): item.valueCtrl.getValue()
1694
+ for item in self.items
1695
+ }
1696
+
1697
+ def setValue(self, value):
1698
+ # clear items
1699
+ self.clearItems()
1700
+ # make sure value is a dict
1701
+ value = data.utils.dictFromString(value)
1702
+ # iterate through key:val pairs
1703
+ for key, val in value.items():
1704
+ # add an item ctrl for each
1705
+ item = self.addItem()
1706
+ # populate
1707
+ item.keyCtrl.setValue(key)
1708
+ item.valueCtrl.setValue(val)
1709
+
1710
+ def validate(self):
1711
+ # check for duplicate keys
1712
+ used = []
1713
+ for key in self.getValue():
1714
+ if key in used:
1715
+ self.setWarning(_translate(
1716
+ "Duplicate dictionary key: {}"
1717
+ ).format(key))
1718
+ return
1719
+ used.append(key)
1720
+ # otherwise validate all children
1721
+ for item in self.items:
1722
+ item.keyCtrl.validate()
1723
+ item.valueCtrl.validate()
1724
+
1725
+ @property
1726
+ def isValid(self):
1727
+ # return true if self has no warnings and children have no warnings
1728
+ return BaseParamCtrl.isValid.fget(self) and all([
1729
+ item.keyCtrl.isValid and item.valueCtrl.isValid
1730
+ for item in self.items
1731
+ ])
1732
+
1733
+
1734
+ class DeviceCtrl(ChoiceCtrl):
1735
+ inputType = "device"
1736
+
1737
+ def makeCtrls(self):
1738
+ ChoiceCtrl.makeCtrls(self)
1739
+ # add a button to open device manager
1740
+ self.deviceMgrBtn = wx.Button(self, style=wx.BU_EXACTFIT)
1741
+ self.deviceMgrBtn.Bind(wx.EVT_BUTTON, self.openDeviceManager)
1742
+ self.deviceMgrBtn.SetBitmap(
1743
+ icons.ButtonIcon("devices", size=16, theme="light").bitmap
1744
+ )
1745
+ self.deviceMgrBtn.SetToolTip(_translate(
1746
+ "Open the Device Manager to setup devices"
1747
+ ))
1748
+ self.sizer.Add(
1749
+ self.deviceMgrBtn, border=6, flag=wx.EXPAND | wx.LEFT
1750
+ )
1751
+
1752
+ def openDeviceManager(self, evt=None):
1753
+ from psychopy.app.deviceManager import DeviceManagerDlg
1754
+ # create dialog
1755
+ dlg = DeviceManagerDlg(parent=self.GetTopLevelParent())
1756
+ # show it
1757
+ dlg.ShowModal()
1758
+ # repopulate devices
1759
+ self.populate()
1760
+ # also repopulate sibling controls
1761
+ for sibling in self.GetParent().GetChildren():
1762
+ if isinstance(sibling, DeviceCtrl) and sibling is not self:
1763
+ sibling.populate()
1764
+
1765
+ def onElementOk(self, evt=None):
1766
+ # get the device manager
1767
+ from psychopy.preferences import prefs
1768
+ from psychopy.app.deviceManager import AddDeviceDlg
1769
+ # if not setup, ask the user whether they want to set it up
1770
+ if self.getValue() and self.getValue() not in prefs.devices:
1771
+ # create dialog
1772
+ dlg = wx.MessageDialog(
1773
+ self.GetTopLevelParent(),
1774
+ _translate(
1775
+ "No device named `{}` has been setup in the Device Manager, set one up now?"
1776
+ ).format(self.getValue()),
1777
+ style=wx.YES|wx.NO|wx.ICON_QUESTION
1778
+ )
1779
+ # open device manager if yes
1780
+ if dlg.ShowModal() == wx.ID_YES:
1781
+ dlg = AddDeviceDlg(self, deviceName=self.getValue())
1782
+ # on OK, add device and refresh list
1783
+ if dlg.ShowModal() == wx.ID_OK:
1784
+ device = dlg.getDevice()
1785
+ prefs.devices[device.name] = device
1786
+ prefs.devices.save()
1787
+ self.populate()