param_widgets.py 21 KB

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