param_widgets.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  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. self._adjust_width()
  167. def _adjust_width(self, max_chars=80, margin_chars=3):
  168. if not self.count():
  169. return
  170. self.setMinimumContentsLength(
  171. min(
  172. max_chars,
  173. max(len(self.itemText(r))
  174. for r in range(self.count())) + margin_chars
  175. )
  176. )
  177. def _set_gooey_param_value_in_widget(self, value):
  178. self.setCurrentText(self._gooey_map_val2label(value))
  179. def _handle_input(self):
  180. self._set_gooey_param_value(self.currentData())
  181. def _add_item(self, value) -> None:
  182. # we add items, and we stick their real values in too
  183. # to avoid tricky conversion via str
  184. self.addItem(self._gooey_map_val2label(value), userData=value)
  185. def _gooey_map_val2label(self, val):
  186. return '--none--' if val is None else str(val)
  187. class PosIntParamWidget(QSpinBox, GooeyParamWidgetMixin):
  188. def __init__(self, allow_none=False, parent=None):
  189. super().__init__(parent)
  190. if allow_none:
  191. self.setMinimum(-1)
  192. self.setSpecialValueText('none')
  193. else:
  194. # this is not entirely correct, but large enough for any practical
  195. # purpose
  196. # TODO libshiboken: Overflow: Value 9223372036854775807 exceedsi
  197. # limits of type [signed] "i" (4bytes).
  198. # Do we need to set a maximum value at all?
  199. #self.setMaximum(sys.maxsize)
  200. pass
  201. self._allow_none = allow_none
  202. self.valueChanged.connect(self._handle_input)
  203. def _set_gooey_param_value_in_widget(self, value):
  204. # generally assumed to be int and fit in the range
  205. self.setValue(-1 if value is None and self._allow_none else value)
  206. def _handle_input(self):
  207. val = self.value()
  208. # convert special value -1 back to None
  209. self._set_gooey_param_value(
  210. None if val == -1 and self._allow_none else val
  211. )
  212. class BoolParamWidget(QCheckBox, GooeyParamWidgetMixin):
  213. def __init__(self, allow_none=False, parent=None) -> None:
  214. super().__init__(parent)
  215. if allow_none:
  216. self.setTristate(True)
  217. self.stateChanged.connect(self._handle_input)
  218. def _set_gooey_param_value_in_widget(self, value):
  219. if value not in (True, False):
  220. # if the value is not representable by a checkbox
  221. # leave it in "partiallychecked". In cases where the
  222. # default is something like `None`, we can distinguish
  223. # a user not having set anything different from the default,
  224. # even if the default is not a bool
  225. self.setCheckState(Qt.PartiallyChecked)
  226. else:
  227. # otherwise flip the switch accordingly
  228. self.setChecked(value)
  229. def _handle_input(self):
  230. state = self.checkState()
  231. # convert to bool/None
  232. self._set_gooey_param_value(
  233. None if state == Qt.PartiallyChecked
  234. else state == Qt.Checked
  235. )
  236. class StrParamWidget(QLineEdit, GooeyParamWidgetMixin):
  237. def __init__(self, parent=None):
  238. super().__init__(parent)
  239. self.setPlaceholderText('Not set')
  240. self.textChanged.connect(self._handle_input)
  241. def _set_gooey_param_value_in_widget(self, value):
  242. if value in (_NoValue, None):
  243. # we treat both as "unset"
  244. self.clear()
  245. else:
  246. self.setText(str(value))
  247. def _handle_input(self):
  248. self._set_gooey_param_value(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. self._edit.setPlaceholderText('Not set')
  276. self._edit.textChanged.connect(self._handle_input)
  277. self._edit.textEdited.connect(self._handle_input)
  278. hl.addWidget(self._edit)
  279. # next to the line edit, we place to small button to facilitate
  280. # selection of file/directory paths by a browser dialog.
  281. if pathtype in (
  282. QFileDialog.AnyFile,
  283. QFileDialog.ExistingFile):
  284. file_button = QToolButton(self)
  285. file_button.setToolTip(
  286. 'Select path'
  287. if pathtype == QFileDialog.AnyFile
  288. else 'Select file')
  289. file_button.setIcon(
  290. gooey_resources.get_best_icon(
  291. 'path' if pathtype == QFileDialog.AnyFile else 'file'))
  292. hl.addWidget(file_button)
  293. # wire up the slots
  294. file_button.clicked.connect(self._select_path)
  295. if pathtype in (
  296. QFileDialog.AnyFile,
  297. QFileDialog.Directory):
  298. # we use a dedicated directory selector.
  299. # on some platforms the respected native
  300. # dialogs are different... so we go with two for the best "native"
  301. # experience
  302. dir_button = QToolButton(self)
  303. dir_button.setToolTip('Choose directory')
  304. dir_button.setIcon(gooey_resources.get_best_icon('directory'))
  305. hl.addWidget(dir_button)
  306. dir_button.clicked.connect(
  307. lambda: self._select_path(dirs_only=True))
  308. def _set_gooey_param_value_in_widget(self, value):
  309. if value and value is not _NoValue:
  310. self._edit.setText(str(value))
  311. else:
  312. self._edit.clear()
  313. def _handle_input(self):
  314. val = self._edit.text()
  315. # treat an empty path as None
  316. self._set_gooey_param_value(val if val else None)
  317. def set_gooey_param_docs(self, docs: str) -> None:
  318. # only use edit tooltip for the docs, and let the buttons
  319. # have their own
  320. self._edit.setToolTip(docs)
  321. def _select_path(self, dirs_only=False):
  322. dialog = QFileDialog(self)
  323. dialog.setFileMode(
  324. QFileDialog.Directory if dirs_only else self._pathtype)
  325. dialog.setOption(QFileDialog.DontResolveSymlinks)
  326. if self._basedir:
  327. # we have a basedir, so we can be clever
  328. dialog.setDirectory(str(self._basedir))
  329. # we need to turn on 'System' in order to get broken symlinks
  330. # too
  331. if not dirs_only:
  332. dialog.setFilter(dialog.filter() | QDir.System)
  333. dialog.finished.connect(self._select_path_receiver)
  334. dialog.open()
  335. def _select_path_receiver(self, result_code: int):
  336. """Internal slot to receive the outcome of _select_path() dialog"""
  337. if not result_code:
  338. if not self._edit.isEnabled():
  339. # if the selection was canceled, clear the path,
  340. # otherwise users have no ability to unset a pervious
  341. # selection
  342. self._set_gooey_param_value_in_widget(_NoValue)
  343. # otherwise just keep the present value as-is
  344. return
  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_in_widget(paths[0])
  351. def _init_gooey_from_other_params(self, spec: Dict) -> None:
  352. if self._gooey_param_name == 'dataset':
  353. # prevent update from self
  354. return
  355. if 'dataset' in spec:
  356. self._basedir = spec['dataset']
  357. class CfgProcParamWidget(ChoiceParamWidget):
  358. """Choice widget with items from `run_procedure(discover=True)`"""
  359. def __init__(self, choices=None, parent=None):
  360. super().__init__(parent=parent)
  361. def _init_gooey_from_other_params(self, spec: Dict) -> None:
  362. if self.count() and spec.get('dataset', _NoValue) is _NoValue:
  363. # we have items and no context change is required
  364. return
  365. # we have no items yet, or the dataset has changed: query!
  366. # reset first
  367. while self.count():
  368. self.removeItem(0)
  369. from datalad.local.run_procedure import RunProcedure
  370. for res in RunProcedure.__call__(
  371. dataset=spec.get('dataset'),
  372. discover=True,
  373. return_type='generator',
  374. result_renderer='disabled',
  375. on_failure='ignore',
  376. ):
  377. proc_name = res.get('procedure_name', '')
  378. if res.get('status') != 'ok' \
  379. or not proc_name.startswith('cfg_'):
  380. # not a good config procedure
  381. continue
  382. # strip 'cfg_' prefix, even when reporting, we do not want it
  383. # because commands like `create()` put it back themselves
  384. self._add_item(proc_name[4:])
  385. if self.count():
  386. self.setEnabled(True)
  387. self.setPlaceholderText('Select procedure')
  388. class SiblingChoiceParamWidget(ChoiceParamWidget):
  389. """Choice widget with items from `siblings()`"""
  390. def __init__(self, choices=None, parent=None):
  391. super().__init__(parent=parent)
  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_params(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. self.clear()
  409. from datalad.distribution.siblings import Siblings
  410. from datalad.support.exceptions import (
  411. CapturedException,
  412. NoDatasetFound,
  413. )
  414. try:
  415. for res in Siblings.__call__(
  416. dataset=spec['dataset'],
  417. action='query',
  418. return_type='generator',
  419. result_renderer='disabled',
  420. on_failure='ignore',
  421. ):
  422. sibling_name = res.get('name')
  423. if (not sibling_name or res.get('status') != 'ok'
  424. or res.get('type') != 'sibling'
  425. or (sibling_name == 'here'
  426. # be robust with Path objects
  427. and res.get('path') == str(spec['dataset']))):
  428. # not a good sibling
  429. continue
  430. self._add_item(sibling_name)
  431. except NoDatasetFound as e:
  432. CapturedException(e)
  433. # TODO this should happen upon validation of the
  434. # `dataset` parameter value
  435. QMessageBox.critical(
  436. self,
  437. 'No dataset selected',
  438. 'The path selected for the <code>dataset</code> parameter '
  439. 'does not point to a valid dataset. '
  440. 'Please select another path!'
  441. )
  442. self._saw_dataset = False
  443. # always update the placeholder, even when no items were created,
  444. # because we have no seen a dataset, and this is the result
  445. self._set_placeholder_msg()
  446. if self.count():
  447. self.setEnabled(True)
  448. class CredentialChoiceParamWidget(QComboBox, GooeyParamWidgetMixin):
  449. """Choice widget with items from `credentials()`"""
  450. def __init__(self, parent=None):
  451. super().__init__(parent=parent)
  452. self.setEditable(True)
  453. self.setInsertPolicy(QComboBox.InsertAtTop)
  454. self.setEnabled(True)
  455. self.currentTextChanged.connect(self._handle_input)
  456. self.setSizeAdjustPolicy(
  457. QComboBox.AdjustToMinimumContentsLengthWithIcon)
  458. self.setPlaceholderText('--auto--')
  459. self._saw_dataset = False
  460. def _init_gooey_from_other_params(self, spec: Dict) -> None:
  461. if 'dataset' not in spec:
  462. # we have items and no context change is required
  463. return
  464. self._saw_dataset = True
  465. self._init_choices(
  466. spec['dataset'] if spec['dataset'] != _NoValue else None)
  467. def _init_choices(self, dataset=None):
  468. # the dataset has changed: query!
  469. # reset first
  470. self.clear()
  471. from datalad_next.credentials import Credentials
  472. from datalad.support.exceptions import (
  473. CapturedException,
  474. NoDatasetFound,
  475. )
  476. self.addItem('')
  477. try:
  478. for res in Credentials.__call__(
  479. dataset=dataset,
  480. action='query',
  481. return_type='generator',
  482. result_renderer='disabled',
  483. on_failure='ignore',
  484. ):
  485. name = res.get('name')
  486. if (not name or res.get('status') != 'ok'
  487. or res.get('type') != 'credential'):
  488. # not a good sibling
  489. continue
  490. self.addItem(name)
  491. except NoDatasetFound as e:
  492. CapturedException(e)
  493. # TODO this should happen upon validation of the
  494. # `dataset` parameter value
  495. QMessageBox.critical(
  496. self,
  497. 'No dataset selected',
  498. 'The path selected for the <code>dataset</code> parameter '
  499. 'does not point to a valid dataset. '
  500. 'Please select another path!'
  501. )
  502. self._saw_dataset = False
  503. def _set_gooey_param_value_in_widget(self, value):
  504. self.setCurrentText(value or '')
  505. def _handle_input(self):
  506. self._set_gooey_param_value(
  507. self.currentText() or _NoValue)