param_widgets.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  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 emited 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 {}
  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. if choices:
  153. for c in choices:
  154. self._add_item(c)
  155. else:
  156. # avoid making the impression something could be selected
  157. self.setPlaceholderText('No known choices')
  158. self.setDisabled(True)
  159. def _add_item(self, value) -> None:
  160. # we add items, and we stick their real values in too
  161. # to avoid tricky conversion via str
  162. self.addItem(self._gooey_map_val2label(value), userData=value)
  163. def _set_gooey_param_value(self, value):
  164. self.setCurrentText(self._gooey_map_val2label(value))
  165. def get_gooey_param_value(self):
  166. return self.currentData()
  167. def _gooey_map_val2label(self, val):
  168. return '--none--' if val is None else str(val)
  169. class PosIntParamWidget(QSpinBox, GooeyParamWidgetMixin):
  170. def __init__(self, allow_none=False, parent=None):
  171. super().__init__(parent)
  172. if allow_none:
  173. self.setMinimum(-1)
  174. self.setSpecialValueText('none')
  175. else:
  176. # this is not entirely correct, but large enough for any practical
  177. # purpose
  178. # TODO libshiboken: Overflow: Value 9223372036854775807 exceedsi
  179. # limits of type [signed] "i" (4bytes).
  180. # Do we need to set a maximum value at all?
  181. #self.setMaximum(sys.maxsize)
  182. pass
  183. self._allow_none = allow_none
  184. def _set_gooey_param_value(self, value):
  185. # generally assumed to be int and fit in the range
  186. self.setValue(-1 if value is None and self._allow_none else value)
  187. def get_gooey_param_value(self):
  188. val = self.value()
  189. # convert special value -1 back to None
  190. return None if val == -1 and self._allow_none else val
  191. class BoolParamWidget(QCheckBox, GooeyParamWidgetMixin):
  192. def _set_gooey_param_value(self, value):
  193. if value not in (True, False):
  194. # if the value is not representable by a checkbox
  195. # leave it in "partiallychecked". In cases where the
  196. # default is something like `None`, we can distinguish
  197. # a user not having set anything different from the default,
  198. # even if the default is not a bool
  199. self.setTristate(True)
  200. self.setCheckState(Qt.PartiallyChecked)
  201. else:
  202. # otherwise flip the switch accordingly
  203. self.setChecked(value)
  204. def get_gooey_param_value(self):
  205. state = self.checkState()
  206. if state == Qt.PartiallyChecked:
  207. # TODO error if partiallychecked still (means a
  208. # value with no default was not set)
  209. # a default `validator` could handle that
  210. # Mixin pics this up and communicates: nothing was set
  211. raise ValueError
  212. # convert to bool
  213. return state == Qt.Checked
  214. class StrParamWidget(QLineEdit, GooeyParamWidgetMixin):
  215. def _set_gooey_param_value(self, value):
  216. self.setText(str(value))
  217. self.setModified(True)
  218. def set_gooey_param_default(self, value):
  219. if value != _NoValue:
  220. self.setPlaceholderText(str(value))
  221. def get_gooey_param_value(self):
  222. # return the value if it was set be the caller, or modified
  223. # by the user -- otherwise stay silent and let the command
  224. # use its default
  225. if self.isEnabled() and not self.isModified() :
  226. raise ValueError('Present value was not modified')
  227. return self.text()
  228. class PathParamWidget(QWidget, GooeyParamWidgetMixin):
  229. def __init__(self, basedir=None,
  230. pathtype: QFileDialog.FileMode = QFileDialog.AnyFile,
  231. disable_manual_edit: bool = False,
  232. parent=None):
  233. """Supported `pathtype` values are
  234. - `QFileDialog.AnyFile`
  235. - `QFileDialog.ExistingFile`
  236. - `QFileDialog.Directory`
  237. """
  238. super().__init__(parent)
  239. self._basedir = basedir
  240. self._pathtype = pathtype
  241. hl = QHBoxLayout()
  242. # squash the margins to fit into a list widget item as much as possible
  243. margins = hl.contentsMargins()
  244. # we stay with the default left/right, but minimize vertically
  245. hl.setContentsMargins(margins.left(), 0, margins.right(), 0)
  246. self.setLayout(hl)
  247. # the main widget is a simple line edit
  248. self._edit = QLineEdit(self)
  249. if disable_manual_edit:
  250. # in e.g. simplified mode we do not allow manual entry of paths
  251. # to avoid confusions re interpretation of relative paths
  252. # https://github.com/datalad/datalad-gooey/issues/106
  253. self._edit.setDisabled(True)
  254. hl.addWidget(self._edit)
  255. self._edit.editingFinished.connect(self.emit_gooey_value_changed)
  256. # next to the line edit, we place to small button to facilitate
  257. # selection of file/directory paths by a browser dialog.
  258. if pathtype in (
  259. QFileDialog.AnyFile,
  260. QFileDialog.ExistingFile):
  261. file_button = QToolButton(self)
  262. file_button.setToolTip(
  263. 'Select path'
  264. if pathtype == QFileDialog.AnyFile
  265. else 'Select file')
  266. file_button.setIcon(
  267. gooey_resources.get_best_icon(
  268. 'path' if pathtype == QFileDialog.AnyFile else 'file'))
  269. hl.addWidget(file_button)
  270. # wire up the slots
  271. file_button.clicked.connect(self._select_path)
  272. if pathtype in (
  273. QFileDialog.AnyFile,
  274. QFileDialog.Directory):
  275. # we use a dedicated directory selector.
  276. # on some platforms the respected native
  277. # dialogs are different... so we go with two for the best "native"
  278. # experience
  279. dir_button = QToolButton(self)
  280. dir_button.setToolTip('Choose directory')
  281. dir_button.setIcon(gooey_resources.get_best_icon('directory'))
  282. hl.addWidget(dir_button)
  283. dir_button.clicked.connect(
  284. lambda: self._select_path(dirs_only=True))
  285. def _set_gooey_param_value(self, value):
  286. self._edit.setText(str(value))
  287. self._edit.setModified(True)
  288. def set_gooey_param_default(self, value):
  289. placeholder = 'Select path'
  290. if value not in (_NoValue, None):
  291. placeholder += f'(default: {value})'
  292. self._edit.setPlaceholderText(placeholder)
  293. def get_gooey_param_value(self):
  294. # return the value if it was set be the caller, or modified
  295. # by the user -- otherwise stay silent and let the command
  296. # use its default
  297. edit = self._edit
  298. if not edit.isModified():
  299. raise ValueError
  300. return edit.text()
  301. def set_gooey_param_docs(self, docs: str) -> None:
  302. # only use edit tooltip for the docs, and let the buttons
  303. # have their own
  304. self._edit.setToolTip(docs)
  305. def _select_path(self, dirs_only=False):
  306. dialog = QFileDialog(self)
  307. dialog.setFileMode(
  308. QFileDialog.Directory if dirs_only else self._pathtype)
  309. dialog.setOption(QFileDialog.DontResolveSymlinks)
  310. if self._basedir:
  311. # we have a basedir, so we can be clever
  312. dialog.setDirectory(str(self._basedir))
  313. # we need to turn on 'System' in order to get broken symlinks
  314. # too
  315. if not dirs_only:
  316. dialog.setFilter(dialog.filter() | QDir.System)
  317. dialog.finished.connect(self._select_path_receiver)
  318. dialog.open()
  319. def _select_path_receiver(self):
  320. """Internal slot to receive the outcome of _select_path() dialog"""
  321. dialog = self.sender()
  322. paths = dialog.selectedFiles()
  323. if paths:
  324. # ignores any multi-selection
  325. # TODO prevent or support specifically
  326. self.set_gooey_param_value(paths[0])
  327. self._edit.setModified(True)
  328. def init_gooey_from_other_param(self, spec: Dict) -> None:
  329. if self._gooey_param_name == 'dataset':
  330. # prevent update from self
  331. return
  332. if 'dataset' in spec:
  333. self._basedir = spec['dataset']
  334. class CfgProcParamWidget(ChoiceParamWidget):
  335. """Choice widget with items from `run_procedure(discover=True)`"""
  336. def __init__(self, choices=None, parent=None):
  337. super().__init__(parent=parent)
  338. self.init_gooey_from_other_param({})
  339. def init_gooey_from_other_param(self, spec: Dict) -> None:
  340. if self.count() and 'dataset' not in spec:
  341. # we have items and no context change is required
  342. return
  343. # we have no items yet, or the dataset has changed: query!
  344. # reset first
  345. while self.count():
  346. self.removeItem(0)
  347. from datalad.local.run_procedure import RunProcedure
  348. for res in RunProcedure.__call__(
  349. dataset=spec.get('dataset'),
  350. discover=True,
  351. return_type='generator',
  352. result_renderer='disabled',
  353. on_failure='ignore',
  354. ):
  355. proc_name = res.get('procedure_name', '')
  356. if res.get('status') != 'ok' \
  357. or not proc_name.startswith('cfg_'):
  358. # not a good config procedure
  359. continue
  360. # strip 'cfg_' prefix, even when reporting, we do not want it
  361. # because commands like `create()` put it back themselves
  362. self._add_item(proc_name[4:])
  363. if self.count():
  364. self.setEnabled(True)
  365. self.setPlaceholderText('Select procedure')
  366. class SiblingChoiceParamWidget(ChoiceParamWidget):
  367. """Choice widget with items from `siblings()`"""
  368. def __init__(self, choices=None, parent=None):
  369. super().__init__(parent=parent)
  370. self.init_gooey_from_other_param({})
  371. self._saw_dataset = False
  372. self._set_placeholder_msg()
  373. def _set_placeholder_msg(self):
  374. if not self._saw_dataset:
  375. self.setPlaceholderText('Select dataset first')
  376. elif not self.count():
  377. self.setPlaceholderText('No known siblings')
  378. else:
  379. self.setPlaceholderText('Select sibling')
  380. def init_gooey_from_other_param(self, spec: Dict) -> None:
  381. if 'dataset' not in spec:
  382. # we have items and no context change is required
  383. return
  384. self._saw_dataset = True
  385. # the dataset has changed: query!
  386. # reset first
  387. while self.count():
  388. self.removeItem(0)
  389. from datalad.distribution.siblings import Siblings
  390. for res in Siblings.__call__(
  391. dataset=spec['dataset'],
  392. action='query',
  393. return_type='generator',
  394. result_renderer='disabled',
  395. on_failure='ignore',
  396. ):
  397. sibling_name = res.get('name')
  398. if (not sibling_name or res.get('status') != 'ok'
  399. or res.get('type') != 'sibling'
  400. or (sibling_name == 'here'
  401. and res.get('path') == spec['dataset'])):
  402. # not a good sibling
  403. continue
  404. self._add_item(sibling_name)
  405. if self.count():
  406. self.setEnabled(True)
  407. self._set_placeholder_msg()