param_widgets.py 17 KB

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