param_widgets.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
  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. def set_gooey_param_spec(self, name: str, default=_NoValue):
  236. super().set_gooey_param_spec(name, default)
  237. self._set_gooey_param_value(default)
  238. class StrParamWidget(QLineEdit, GooeyParamWidgetMixin):
  239. def _set_gooey_param_value(self, value):
  240. self.setText(str(value))
  241. self.setModified(True)
  242. def set_gooey_param_default(self, value):
  243. if value != _NoValue:
  244. self.setPlaceholderText(str(value))
  245. def get_gooey_param_value(self):
  246. # return the value if it was set be the caller, or modified
  247. # by the user -- otherwise stay silent and let the command
  248. # use its default
  249. if self.isEnabled() and not self.isModified():
  250. raise ValueError('Present value was not modified')
  251. return self.text()
  252. class PathParamWidget(QWidget, GooeyParamWidgetMixin):
  253. def __init__(self, basedir=None,
  254. pathtype: QFileDialog.FileMode = QFileDialog.AnyFile,
  255. disable_manual_edit: bool = False,
  256. parent=None):
  257. """Supported `pathtype` values are
  258. - `QFileDialog.AnyFile`
  259. - `QFileDialog.ExistingFile`
  260. - `QFileDialog.Directory`
  261. """
  262. super().__init__(parent)
  263. self._basedir = basedir
  264. self._pathtype = pathtype
  265. hl = QHBoxLayout()
  266. # squash the margins to fit into a list widget item as much as possible
  267. margins = hl.contentsMargins()
  268. # we stay with the default left/right, but minimize vertically
  269. hl.setContentsMargins(margins.left(), 0, margins.right(), 0)
  270. self.setLayout(hl)
  271. # the main widget is a simple line edit
  272. self._edit = QLineEdit(self)
  273. if disable_manual_edit:
  274. # in e.g. simplified mode we do not allow manual entry of paths
  275. # to avoid confusions re interpretation of relative paths
  276. # https://github.com/datalad/datalad-gooey/issues/106
  277. self._edit.setDisabled(True)
  278. hl.addWidget(self._edit)
  279. self._edit.editingFinished.connect(self.emit_gooey_value_changed)
  280. # next to the line edit, we place to small button to facilitate
  281. # selection of file/directory paths by a browser dialog.
  282. if pathtype in (
  283. QFileDialog.AnyFile,
  284. QFileDialog.ExistingFile):
  285. file_button = QToolButton(self)
  286. file_button.setToolTip(
  287. 'Select path'
  288. if pathtype == QFileDialog.AnyFile
  289. else 'Select file')
  290. file_button.setIcon(
  291. gooey_resources.get_best_icon(
  292. 'path' if pathtype == QFileDialog.AnyFile else 'file'))
  293. hl.addWidget(file_button)
  294. # wire up the slots
  295. file_button.clicked.connect(self._select_path)
  296. if pathtype in (
  297. QFileDialog.AnyFile,
  298. QFileDialog.Directory):
  299. # we use a dedicated directory selector.
  300. # on some platforms the respected native
  301. # dialogs are different... so we go with two for the best "native"
  302. # experience
  303. dir_button = QToolButton(self)
  304. dir_button.setToolTip('Choose directory')
  305. dir_button.setIcon(gooey_resources.get_best_icon('directory'))
  306. hl.addWidget(dir_button)
  307. dir_button.clicked.connect(
  308. lambda: self._select_path(dirs_only=True))
  309. def _set_gooey_param_value(self, value):
  310. self._edit.setText(str(value))
  311. self._edit.setModified(True)
  312. def set_gooey_param_default(self, value):
  313. placeholder = 'Select path'
  314. if value not in (_NoValue, None):
  315. placeholder += f'(default: {value})'
  316. self._edit.setPlaceholderText(placeholder)
  317. def get_gooey_param_value(self):
  318. # return the value if it was set be the caller, or modified
  319. # by the user -- otherwise stay silent and let the command
  320. # use its default
  321. edit = self._edit
  322. if not edit.isModified():
  323. raise ValueError
  324. return edit.text()
  325. def set_gooey_param_docs(self, docs: str) -> None:
  326. # only use edit tooltip for the docs, and let the buttons
  327. # have their own
  328. self._edit.setToolTip(docs)
  329. def _select_path(self, dirs_only=False):
  330. dialog = QFileDialog(self)
  331. dialog.setFileMode(
  332. QFileDialog.Directory if dirs_only else self._pathtype)
  333. dialog.setOption(QFileDialog.DontResolveSymlinks)
  334. if self._basedir:
  335. # we have a basedir, so we can be clever
  336. dialog.setDirectory(str(self._basedir))
  337. # we need to turn on 'System' in order to get broken symlinks
  338. # too
  339. if not dirs_only:
  340. dialog.setFilter(dialog.filter() | QDir.System)
  341. dialog.finished.connect(self._select_path_receiver)
  342. dialog.open()
  343. def _select_path_receiver(self):
  344. """Internal slot to receive the outcome of _select_path() dialog"""
  345. dialog = self.sender()
  346. paths = dialog.selectedFiles()
  347. if paths:
  348. # ignores any multi-selection
  349. # TODO prevent or support specifically
  350. self.set_gooey_param_value(paths[0])
  351. self._edit.setModified(True)
  352. def init_gooey_from_other_param(self, spec: Dict) -> None:
  353. if self._gooey_param_name == 'dataset':
  354. # prevent update from self
  355. return
  356. if 'dataset' in spec:
  357. self._basedir = spec['dataset']
  358. class CfgProcParamWidget(ChoiceParamWidget):
  359. """Choice widget with items from `run_procedure(discover=True)`"""
  360. def __init__(self, choices=None, parent=None):
  361. super().__init__(parent=parent)
  362. self.init_gooey_from_other_param({})
  363. def init_gooey_from_other_param(self, spec: Dict) -> None:
  364. if self.count() and 'dataset' not in spec:
  365. # we have items and no context change is required
  366. return
  367. # we have no items yet, or the dataset has changed: query!
  368. # reset first
  369. while self.count():
  370. self.removeItem(0)
  371. from datalad.local.run_procedure import RunProcedure
  372. for res in RunProcedure.__call__(
  373. dataset=spec.get('dataset'),
  374. discover=True,
  375. return_type='generator',
  376. result_renderer='disabled',
  377. on_failure='ignore',
  378. ):
  379. proc_name = res.get('procedure_name', '')
  380. if res.get('status') != 'ok' \
  381. or not proc_name.startswith('cfg_'):
  382. # not a good config procedure
  383. continue
  384. # strip 'cfg_' prefix, even when reporting, we do not want it
  385. # because commands like `create()` put it back themselves
  386. self._add_item(proc_name[4:])
  387. if self.count():
  388. self.setEnabled(True)
  389. self.setPlaceholderText('Select procedure')
  390. class SiblingChoiceParamWidget(ChoiceParamWidget):
  391. """Choice widget with items from `siblings()`"""
  392. def __init__(self, choices=None, parent=None):
  393. super().__init__(parent=parent)
  394. self.init_gooey_from_other_param({})
  395. self._saw_dataset = False
  396. self._set_placeholder_msg()
  397. def _set_placeholder_msg(self):
  398. if not self._saw_dataset:
  399. self.setPlaceholderText('Select dataset first')
  400. elif not self.count():
  401. self.setPlaceholderText('No known siblings')
  402. else:
  403. self.setPlaceholderText('Select sibling')
  404. def init_gooey_from_other_param(self, spec: Dict) -> None:
  405. if 'dataset' not in spec:
  406. # we have items and no context change is required
  407. return
  408. self._saw_dataset = True
  409. # the dataset has changed: query!
  410. # reset first
  411. while self.count():
  412. self.removeItem(0)
  413. from datalad.distribution.siblings import Siblings
  414. for res in Siblings.__call__(
  415. dataset=spec['dataset'],
  416. action='query',
  417. return_type='generator',
  418. result_renderer='disabled',
  419. on_failure='ignore',
  420. ):
  421. sibling_name = res.get('name')
  422. if (not sibling_name or res.get('status') != 'ok'
  423. or res.get('type') != 'sibling'
  424. or (sibling_name == 'here'
  425. and res.get('path') == spec['dataset'])):
  426. # not a good sibling
  427. continue
  428. self._add_item(sibling_name)
  429. if self.count():
  430. self.setEnabled(True)
  431. self._set_placeholder_msg()