param_widgets.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. from collections.abc import Callable
  2. from types import MappingProxyType
  3. from typing import (
  4. Any,
  5. Dict,
  6. )
  7. from PySide6.QtCore import (
  8. QDir,
  9. Qt,
  10. Signal,
  11. )
  12. from PySide6.QtWidgets import (
  13. QCheckBox,
  14. QComboBox,
  15. QFileDialog,
  16. QHBoxLayout,
  17. QLineEdit,
  18. QSpinBox,
  19. QToolButton,
  20. QWidget,
  21. )
  22. from .resource_provider import gooey_resources
  23. from .utils import _NoValue
  24. class GooeyParamWidgetMixin:
  25. """API mixin for QWidget to get/set parameter specifications
  26. Any parameter widget implementation should also derive from the class,
  27. and implement, at minimum, `_set_gooey_param_value()` and
  28. `get_gooey_param_value()` for compatibility with the command parameter
  29. UI generator.
  30. The main API used by the GUI generator are `set_gooey_param_spec()`
  31. and `get_gooey_param_spec()`. They take care of providing a standard
  32. widget behavior across all widget types, such as, not returning values if
  33. they do not deviate from the default.
  34. """
  35. value_changed = Signal(MappingProxyType)
  36. """Signal to be emitted whenever a parameter value is changed. The signal
  37. payload type matches the return value of `get_gooey_param_spec()`"""
  38. def set_gooey_param_value(self, value):
  39. """Implement to set a particular value in the target widget.
  40. The `value_changed` signal is emitted a the given value differs
  41. from the current value.
  42. """
  43. # what we had
  44. try:
  45. prev = self.get_gooey_param_value()
  46. except ValueError:
  47. prev = _NoValue
  48. # let widget implementation actually set the value
  49. self._set_gooey_param_value(value)
  50. if prev != value:
  51. # an actual change, emit corresponding signal
  52. self.emit_gooey_value_changed()
  53. def _set_gooey_param_value(self, value):
  54. """Implement to set a particular value in the target widget.
  55. By default, this method is also used to set a default value.
  56. If that is not desirable for a particular widget type,
  57. override `set_gooey_param_default()`.
  58. """
  59. raise NotImplementedError
  60. def get_gooey_param_value(self):
  61. """Implement to get the parameter value from the widget.
  62. Raises
  63. ------
  64. ValueError
  65. The implementation must raise this exception, when no value
  66. has been entered/is available.
  67. """
  68. raise NotImplementedError
  69. def set_gooey_param_default(self, value):
  70. """Implement to set a parameter default value in the widget
  71. """
  72. pass
  73. def set_gooey_param_spec(
  74. self, name: str, default=_NoValue):
  75. """Called by the command UI generator to set parameter
  76. name, and a default.
  77. """
  78. self._gooey_param_name = name
  79. # always store here for later inspection by get_gooey_param_spec()
  80. self._gooey_param_default = default
  81. self.set_gooey_param_default(default)
  82. def get_gooey_param_spec(self) -> Dict:
  83. """Called by the command UI generator to get a parameter specification
  84. Return a mapping of the parameter name to the gathered value or _NoValue
  85. when no value was gathered, or the gather value is not different from
  86. the default)
  87. is a mapping of parameter name to the gather value.
  88. """
  89. try:
  90. val = self.get_gooey_param_value()
  91. except ValueError:
  92. # class signals that no value was set
  93. return {self._gooey_param_name: _NoValue}
  94. return {self._gooey_param_name: val} \
  95. if val != self._gooey_param_default \
  96. else {self._gooey_param_name: _NoValue}
  97. def set_gooey_param_validator(self, validator: Callable) -> None:
  98. """Set a validator callable that can be used by the widget
  99. for input validation
  100. """
  101. self._gooey_param_validator = validator
  102. def set_gooey_param_docs(self, docs: str) -> None:
  103. """Present documentation on the parameter in the widget
  104. The default implementation assigns the documentation to a widget-wide
  105. tooltip.
  106. """
  107. # recycle the docs as widget tooltip, this is more compact than
  108. # having to integrate potentially lengthy text into the layout
  109. self.setToolTip(docs)
  110. def init_gooey_from_other_param(self, spec: Dict) -> None:
  111. """Slot to receive changes of other parameter values
  112. Can be implemented to act on context changes that require a
  113. reinitialization of a widget. For example, update a list
  114. of remotes after changing the reference dataset.
  115. Parameters
  116. ----------
  117. spec: dict
  118. Mapping of parameter names to new values, in the same format
  119. and semantics as the return value of get_gooey_param_spec().
  120. """
  121. pass
  122. def emit_gooey_value_changed(self):
  123. """Slot to connect "changed" signals of underlying input widgets too
  124. It emits the standard Gooey `value_changed` signal with the
  125. current Gooey `param_spec` as value.
  126. """
  127. self.value_changed.emit(MappingProxyType(self.get_gooey_param_spec()))
  128. def load_parameter_widget(
  129. parent: QWidget,
  130. pwid_factory: Callable,
  131. name: str,
  132. docs: str,
  133. default: Any = _NoValue,
  134. validator: Callable or None = None) -> QWidget:
  135. """ """
  136. pwid = pwid_factory(parent=parent)
  137. if validator:
  138. pwid.set_gooey_param_validator(validator)
  139. pwid.set_gooey_param_docs(docs)
  140. # set any default last, as they might need a validator,
  141. # docs, and all other bits in place already for an editor or
  142. # validation to work
  143. pwid.set_gooey_param_spec(name, default)
  144. return pwid
  145. #
  146. # Parameter widget implementations
  147. #
  148. class ChoiceParamWidget(QComboBox, GooeyParamWidgetMixin):
  149. def __init__(self, choices=None, parent=None):
  150. super().__init__(parent)
  151. self.setInsertPolicy(QComboBox.NoInsert)
  152. # TODO: We may need to always have --none-- as an option.
  153. # That's b/c of specs like EnsureChoice('a', 'b') | EnsureNone()
  154. # with default None.
  155. if choices:
  156. for c in choices:
  157. self._add_item(c)
  158. else:
  159. # avoid making the impression something could be selected
  160. self.setPlaceholderText('No known choices')
  161. self.setDisabled(True)
  162. def _add_item(self, value) -> None:
  163. # we add items, and we stick their real values in too
  164. # to avoid tricky conversion via str
  165. self.addItem(self._gooey_map_val2label(value), userData=value)
  166. def _set_gooey_param_value(self, value):
  167. self.setCurrentText(self._gooey_map_val2label(value))
  168. def get_gooey_param_value(self):
  169. return self.currentData()
  170. def set_gooey_param_spec(self, name: str, default=_NoValue):
  171. super().set_gooey_param_spec(name, default)
  172. # QComboBox will report the first choice to be selected by default.
  173. # Set the specified default instead.
  174. if default is _NoValue:
  175. default = None
  176. self._set_gooey_param_value(default)
  177. def _gooey_map_val2label(self, val):
  178. return '--none--' if val is None else str(val)
  179. class PosIntParamWidget(QSpinBox, GooeyParamWidgetMixin):
  180. def __init__(self, allow_none=False, parent=None):
  181. super().__init__(parent)
  182. if allow_none:
  183. self.setMinimum(-1)
  184. self.setSpecialValueText('none')
  185. else:
  186. # this is not entirely correct, but large enough for any practical
  187. # purpose
  188. # TODO libshiboken: Overflow: Value 9223372036854775807 exceedsi
  189. # limits of type [signed] "i" (4bytes).
  190. # Do we need to set a maximum value at all?
  191. #self.setMaximum(sys.maxsize)
  192. pass
  193. self._allow_none = allow_none
  194. def _set_gooey_param_value(self, value):
  195. # generally assumed to be int and fit in the range
  196. self.setValue(-1 if value is None and self._allow_none else value)
  197. def set_gooey_param_spec(self, name: str, default=_NoValue):
  198. # QSpinBox' values is set to 0 by default. Hence, we need to overwrite
  199. # here.
  200. super().set_gooey_param_spec(name, default)
  201. if default is _NoValue:
  202. default = None
  203. self.setValue(default if default is not None else -1)
  204. def get_gooey_param_value(self):
  205. val = self.value()
  206. # convert special value -1 back to None
  207. return None if val == -1 and self._allow_none else val
  208. class BoolParamWidget(QCheckBox, GooeyParamWidgetMixin):
  209. def __init__(self, parent=None) -> None:
  210. super().__init__(parent)
  211. # set to no value initially
  212. self._set_gooey_param_value(None)
  213. def _set_gooey_param_value(self, value):
  214. if value not in (True, False):
  215. # if the value is not representable by a checkbox
  216. # leave it in "partiallychecked". In cases where the
  217. # default is something like `None`, we can distinguish
  218. # a user not having set anything different from the default,
  219. # even if the default is not a bool
  220. self.setTristate(True)
  221. self.setCheckState(Qt.PartiallyChecked)
  222. else:
  223. # otherwise flip the switch accordingly
  224. self.setChecked(value)
  225. def get_gooey_param_value(self):
  226. state = self.checkState()
  227. if state == Qt.PartiallyChecked:
  228. # TODO error if partiallychecked still (means a
  229. # value with no default was not set)
  230. # a default `validator` could handle that
  231. # Mixin picks this up and communicates: nothing was set
  232. raise ValueError
  233. # convert to bool
  234. return state == Qt.Checked
  235. class StrParamWidget(QLineEdit, GooeyParamWidgetMixin):
  236. def _set_gooey_param_value(self, value):
  237. self.setText(str(value))
  238. self.setModified(True)
  239. def set_gooey_param_default(self, value):
  240. if value != _NoValue:
  241. self.setPlaceholderText(str(value))
  242. def get_gooey_param_value(self):
  243. # return the value if it was set be the caller, or modified
  244. # by the user -- otherwise stay silent and let the command
  245. # use its default
  246. if self.isEnabled() and not self.isModified():
  247. raise ValueError('Present value was not modified')
  248. return self.text()
  249. class PathParamWidget(QWidget, GooeyParamWidgetMixin):
  250. def __init__(self, basedir=None,
  251. pathtype: QFileDialog.FileMode = QFileDialog.AnyFile,
  252. disable_manual_edit: bool = False,
  253. parent=None):
  254. """Supported `pathtype` values are
  255. - `QFileDialog.AnyFile`
  256. - `QFileDialog.ExistingFile`
  257. - `QFileDialog.Directory`
  258. """
  259. super().__init__(parent)
  260. self._basedir = basedir
  261. self._pathtype = pathtype
  262. hl = QHBoxLayout()
  263. # squash the margins to fit into a list widget item as much as possible
  264. margins = hl.contentsMargins()
  265. # we stay with the default left/right, but minimize vertically
  266. hl.setContentsMargins(margins.left(), 0, margins.right(), 0)
  267. self.setLayout(hl)
  268. # the main widget is a simple line edit
  269. self._edit = QLineEdit(self)
  270. if disable_manual_edit:
  271. # in e.g. simplified mode we do not allow manual entry of paths
  272. # to avoid confusions re interpretation of relative paths
  273. # https://github.com/datalad/datalad-gooey/issues/106
  274. self._edit.setDisabled(True)
  275. hl.addWidget(self._edit)
  276. self._edit.editingFinished.connect(self.emit_gooey_value_changed)
  277. # next to the line edit, we place to small button to facilitate
  278. # selection of file/directory paths by a browser dialog.
  279. if pathtype in (
  280. QFileDialog.AnyFile,
  281. QFileDialog.ExistingFile):
  282. file_button = QToolButton(self)
  283. file_button.setToolTip(
  284. 'Select path'
  285. if pathtype == QFileDialog.AnyFile
  286. else 'Select file')
  287. file_button.setIcon(
  288. gooey_resources.get_best_icon(
  289. 'path' if pathtype == QFileDialog.AnyFile else 'file'))
  290. hl.addWidget(file_button)
  291. # wire up the slots
  292. file_button.clicked.connect(self._select_path)
  293. if pathtype in (
  294. QFileDialog.AnyFile,
  295. QFileDialog.Directory):
  296. # we use a dedicated directory selector.
  297. # on some platforms the respected native
  298. # dialogs are different... so we go with two for the best "native"
  299. # experience
  300. dir_button = QToolButton(self)
  301. dir_button.setToolTip('Choose directory')
  302. dir_button.setIcon(gooey_resources.get_best_icon('directory'))
  303. hl.addWidget(dir_button)
  304. dir_button.clicked.connect(
  305. lambda: self._select_path(dirs_only=True))
  306. def _set_gooey_param_value(self, value):
  307. self._edit.setText(str(value))
  308. self._edit.setModified(True)
  309. def set_gooey_param_default(self, value):
  310. placeholder = 'Select path'
  311. if value not in (_NoValue, None):
  312. placeholder += f'(default: {value})'
  313. self._edit.setPlaceholderText(placeholder)
  314. def get_gooey_param_value(self):
  315. # return the value if it was set be the caller, or modified
  316. # by the user -- otherwise stay silent and let the command
  317. # use its default
  318. edit = self._edit
  319. if not edit.isModified():
  320. raise ValueError
  321. return edit.text()
  322. def set_gooey_param_docs(self, docs: str) -> None:
  323. # only use edit tooltip for the docs, and let the buttons
  324. # have their own
  325. self._edit.setToolTip(docs)
  326. def _select_path(self, dirs_only=False):
  327. dialog = QFileDialog(self)
  328. dialog.setFileMode(
  329. QFileDialog.Directory if dirs_only else self._pathtype)
  330. dialog.setOption(QFileDialog.DontResolveSymlinks)
  331. if self._basedir:
  332. # we have a basedir, so we can be clever
  333. dialog.setDirectory(str(self._basedir))
  334. # we need to turn on 'System' in order to get broken symlinks
  335. # too
  336. if not dirs_only:
  337. dialog.setFilter(dialog.filter() | QDir.System)
  338. dialog.finished.connect(self._select_path_receiver)
  339. dialog.open()
  340. def _select_path_receiver(self):
  341. """Internal slot to receive the outcome of _select_path() dialog"""
  342. dialog = self.sender()
  343. paths = dialog.selectedFiles()
  344. if paths:
  345. # ignores any multi-selection
  346. # TODO prevent or support specifically
  347. self.set_gooey_param_value(paths[0])
  348. self._edit.setModified(True)
  349. def init_gooey_from_other_param(self, spec: Dict) -> None:
  350. if self._gooey_param_name == 'dataset':
  351. # prevent update from self
  352. return
  353. if 'dataset' in spec:
  354. self._basedir = spec['dataset']
  355. class CfgProcParamWidget(ChoiceParamWidget):
  356. """Choice widget with items from `run_procedure(discover=True)`"""
  357. def __init__(self, choices=None, parent=None):
  358. super().__init__(parent=parent)
  359. self.init_gooey_from_other_param({})
  360. def init_gooey_from_other_param(self, spec: Dict) -> None:
  361. if self.count() and 'dataset' not in spec:
  362. # we have items and no context change is required
  363. return
  364. # we have no items yet, or the dataset has changed: query!
  365. # reset first
  366. while self.count():
  367. self.removeItem(0)
  368. from datalad.local.run_procedure import RunProcedure
  369. for res in RunProcedure.__call__(
  370. dataset=spec.get('dataset'),
  371. discover=True,
  372. return_type='generator',
  373. result_renderer='disabled',
  374. on_failure='ignore',
  375. ):
  376. proc_name = res.get('procedure_name', '')
  377. if res.get('status') != 'ok' \
  378. or not proc_name.startswith('cfg_'):
  379. # not a good config procedure
  380. continue
  381. # strip 'cfg_' prefix, even when reporting, we do not want it
  382. # because commands like `create()` put it back themselves
  383. self._add_item(proc_name[4:])
  384. if self.count():
  385. self.setEnabled(True)
  386. self.setPlaceholderText('Select procedure')
  387. class SiblingChoiceParamWidget(ChoiceParamWidget):
  388. """Choice widget with items from `siblings()`"""
  389. def __init__(self, choices=None, parent=None):
  390. super().__init__(parent=parent)
  391. self.init_gooey_from_other_param({})
  392. self._saw_dataset = False
  393. self._set_placeholder_msg()
  394. def _set_placeholder_msg(self):
  395. if not self._saw_dataset:
  396. self.setPlaceholderText('Select dataset first')
  397. elif not self.count():
  398. self.setPlaceholderText('No known siblings')
  399. else:
  400. self.setPlaceholderText('Select sibling')
  401. def init_gooey_from_other_param(self, spec: Dict) -> None:
  402. if 'dataset' not in spec:
  403. # we have items and no context change is required
  404. return
  405. self._saw_dataset = True
  406. # the dataset has changed: query!
  407. # reset first
  408. while self.count():
  409. self.removeItem(0)
  410. from datalad.distribution.siblings import Siblings
  411. for res in Siblings.__call__(
  412. dataset=spec['dataset'],
  413. action='query',
  414. return_type='generator',
  415. result_renderer='disabled',
  416. on_failure='ignore',
  417. ):
  418. sibling_name = res.get('name')
  419. if (not sibling_name or res.get('status') != 'ok'
  420. or res.get('type') != 'sibling'
  421. or (sibling_name == 'here'
  422. and res.get('path') == spec['dataset'])):
  423. # not a good sibling
  424. continue
  425. self._add_item(sibling_name)
  426. if self.count():
  427. self.setEnabled(True)
  428. self._set_placeholder_msg()