from collections.abc import Callable
from types import MappingProxyType
from typing import (
Any,
Dict,
)
from PySide6.QtCore import (
QDir,
Qt,
Signal,
)
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QFileDialog,
QHBoxLayout,
QLineEdit,
QSpinBox,
QToolButton,
QWidget,
QMessageBox,
)
from .resource_provider import gooey_resources
from .utils import _NoValue
class GooeyParamWidgetMixin:
"""API mixin for QWidget to get/set parameter specifications
Any parameter widget implementation should also derive from this class,
and implement, at minimum, `_set_gooey_param_value_in_widget()` and
wire the input widget up in a way that `_set_gooey_param_value()`
receives any value a user entered in the widget.
The main API used by the GUI generator are `set_gooey_param_spec()`,
`get_gooey_param_spec()`, and `init_gooey_from_params()`. They take care
of providing a standard widget behavior across all widget types, such as,
not returning values if they do not deviate from the default.
"""
value_changed = Signal(MappingProxyType)
"""Signal to be emitted whenever a parameter value is changed. The signal
payload type matches the return value of `get_gooey_param_spec()`"""
def set_gooey_param_spec(
self, name: str, default=_NoValue):
"""Called by the command UI generator to set parameter
name, and a default.
"""
self._gooey_param_name = name
# always store here for later inspection by get_gooey_param_spec()
self._gooey_param_default = default
self._gooey_param_value = _NoValue
self._gooey_param_prev_value = _NoValue
def init_gooey_from_params(self, spec: Dict) -> None:
"""Slot to receive changes of parameter values (self or other)
There can be parameter value reports for multiple parameters
at once.
The default implementation calls a widgets implementation of
self._init_gooey_from_other_params(), followed by
self.set_gooey_param_value() with any value of a key that matches the
parameter name, and afterwards call
self._set_gooey_param_value_in_widget() to reflect the value change in
the widget.
If a widget prefers or needs to handle updates differently, this
method can be reimplemented. Any reimplementation must call
self._set_gooey_param_value() though.
Parameters
----------
spec: dict
Mapping of parameter names to new values, in the same format
and semantics as the return value of get_gooey_param_spec().
"""
# first let a widget reconfigure itself, before a value is set
self._init_gooey_from_other_params(spec)
if self._gooey_param_name in spec:
val = spec[self._gooey_param_name]
self._set_gooey_param_value(val)
# let widget implementation actually set the value
self._set_gooey_param_value_in_widget(val)
def get_gooey_param_spec(self) -> Dict:
"""Called by the command UI generator to get a parameter specification
Return a mapping of the parameter name to the gathered value or
_NoValue when no value was gathered, or the gather value is not
different from the default)
"""
val = self._gooey_param_value
return {self._gooey_param_name: val} \
if val != self._gooey_param_default \
else {self._gooey_param_name: _NoValue}
def emit_gooey_value_changed(self):
"""Slot to connect "changed" signals of underlying input widgets too
It emits the standard Gooey `value_changed` signal with the
current Gooey `param_spec` as value.
"""
self.value_changed.emit(MappingProxyType(self.get_gooey_param_spec()))
def _set_gooey_param_value(self, value):
"""Set a particular value in the widget.
The `value_changed` signal is emitted a the given value differs
from the current value.
The actual value setting in the widget is performed by
_set_gooey_param_value_in_widget() which must be implemented for each
widget type.
"""
# what we had
self._gooey_param_prev_value = self._gooey_param_value
# what we have now
self._gooey_param_value = value
if self._gooey_param_prev_value != value:
# an actual change, emit corresponding signal
self.emit_gooey_value_changed()
def _set_gooey_param_value_in_widget(self, value):
"""Implement to set a particular value in the target widget.
Any implementation must be able to handle `_NoValue`
"""
raise NotImplementedError
def set_gooey_param_validator(self, validator: Callable) -> None:
"""Set a validator callable that can be used by the widget
for input validation
"""
self._gooey_param_validator = validator
def set_gooey_param_docs(self, docs: str) -> None:
"""Present documentation on the parameter in the widget
The default implementation assigns the documentation to a widget-wide
tooltip.
"""
# recycle the docs as widget tooltip, this is more compact than
# having to integrate potentially lengthy text into the layout
self.setToolTip(docs)
def _init_gooey_from_other_params(self, spec: Dict) -> None:
"""Implement to init based on other parameter's values
Can be reimplemented to act on context changes that require a
reinitialization of a widget. For example, update a list
of remotes after changing the reference dataset.
"""
pass
def load_parameter_widget(
parent: QWidget,
pwid_factory: Callable,
name: str,
docs: str,
default: Any = _NoValue,
validator: Callable or None = None) -> QWidget:
""" """
pwid = pwid_factory(parent=parent)
if validator:
pwid.set_gooey_param_validator(validator)
pwid.set_gooey_param_docs(docs)
# set any default last, as they might need a validator,
# docs, and all other bits in place already for an editor or
# validation to work
pwid.set_gooey_param_spec(name, default)
return pwid
#
# Parameter widget implementations
#
class ChoiceParamWidget(QComboBox, GooeyParamWidgetMixin):
def __init__(self, choices=None, parent=None):
super().__init__(parent)
self.setInsertPolicy(QComboBox.NoInsert)
# TODO: We may need to always have --none-- as an option.
# That's b/c of specs like EnsureChoice('a', 'b') | EnsureNone()
# with default None.
if choices:
for c in choices:
self._add_item(c)
else:
# avoid making the impression something could be selected
self.setPlaceholderText('No known choices')
self.setDisabled(True)
self.currentIndexChanged.connect(self._handle_input)
self._adjust_width()
def _adjust_width(self, max_chars=80, margin_chars=3):
if not self.count():
return
self.setMinimumContentsLength(
min(
max_chars,
max(len(self.itemText(r))
for r in range(self.count())) + margin_chars
)
)
def _set_gooey_param_value_in_widget(self, value):
self.setCurrentText(self._gooey_map_val2label(value))
def _handle_input(self):
self._set_gooey_param_value(self.currentData())
def _add_item(self, value) -> None:
# we add items, and we stick their real values in too
# to avoid tricky conversion via str
self.addItem(self._gooey_map_val2label(value), userData=value)
def _gooey_map_val2label(self, val):
return '--none--' if val is None else str(val)
class PosIntParamWidget(QSpinBox, GooeyParamWidgetMixin):
def __init__(self, allow_none=False, parent=None):
super().__init__(parent)
if allow_none:
self.setMinimum(-1)
self.setSpecialValueText('none')
else:
# this is not entirely correct, but large enough for any practical
# purpose
# TODO libshiboken: Overflow: Value 9223372036854775807 exceedsi
# limits of type [signed] "i" (4bytes).
# Do we need to set a maximum value at all?
#self.setMaximum(sys.maxsize)
pass
self._allow_none = allow_none
self.valueChanged.connect(self._handle_input)
def _set_gooey_param_value_in_widget(self, value):
# generally assumed to be int and fit in the range
self.setValue(-1 if value is None and self._allow_none else value)
def _handle_input(self):
val = self.value()
# convert special value -1 back to None
self._set_gooey_param_value(
None if val == -1 and self._allow_none else val
)
class BoolParamWidget(QCheckBox, GooeyParamWidgetMixin):
def __init__(self, allow_none=False, parent=None) -> None:
super().__init__(parent)
if allow_none:
self.setTristate(True)
self.stateChanged.connect(self._handle_input)
def _set_gooey_param_value_in_widget(self, value):
if value not in (True, False):
# if the value is not representable by a checkbox
# leave it in "partiallychecked". In cases where the
# default is something like `None`, we can distinguish
# a user not having set anything different from the default,
# even if the default is not a bool
self.setCheckState(Qt.PartiallyChecked)
else:
# otherwise flip the switch accordingly
self.setChecked(value)
def _handle_input(self):
state = self.checkState()
# convert to bool/None
self._set_gooey_param_value(
None if state == Qt.PartiallyChecked
else state == Qt.Checked
)
class StrParamWidget(QLineEdit, GooeyParamWidgetMixin):
def __init__(self, parent=None):
super().__init__(parent)
self.setPlaceholderText('Not set')
self.textChanged.connect(self._handle_input)
def _set_gooey_param_value_in_widget(self, value):
if value in (_NoValue, None):
# we treat both as "unset"
self.clear()
else:
self.setText(str(value))
def _handle_input(self):
self._set_gooey_param_value(self.text())
class PathParamWidget(QWidget, GooeyParamWidgetMixin):
def __init__(self, basedir=None,
pathtype: QFileDialog.FileMode = QFileDialog.AnyFile,
disable_manual_edit: bool = False,
parent=None):
"""Supported `pathtype` values are
- `QFileDialog.AnyFile`
- `QFileDialog.ExistingFile`
- `QFileDialog.Directory`
"""
super().__init__(parent)
self._basedir = basedir
self._pathtype = pathtype
hl = QHBoxLayout()
# squash the margins to fit into a list widget item as much as possible
margins = hl.contentsMargins()
# we stay with the default left/right, but minimize vertically
hl.setContentsMargins(margins.left(), 0, margins.right(), 0)
self.setLayout(hl)
# the main widget is a simple line edit
self._edit = QLineEdit(self)
if disable_manual_edit:
# in e.g. simplified mode we do not allow manual entry of paths
# to avoid confusions re interpretation of relative paths
# https://github.com/datalad/datalad-gooey/issues/106
self._edit.setDisabled(True)
self._edit.setPlaceholderText('Not set')
self._edit.textChanged.connect(self._handle_input)
self._edit.textEdited.connect(self._handle_input)
hl.addWidget(self._edit)
# next to the line edit, we place to small button to facilitate
# selection of file/directory paths by a browser dialog.
if pathtype in (
QFileDialog.AnyFile,
QFileDialog.ExistingFile):
file_button = QToolButton(self)
file_button.setToolTip(
'Select path'
if pathtype == QFileDialog.AnyFile
else 'Select file')
file_button.setIcon(
gooey_resources.get_best_icon(
'path' if pathtype == QFileDialog.AnyFile else 'file'))
hl.addWidget(file_button)
# wire up the slots
file_button.clicked.connect(self._select_path)
if pathtype in (
QFileDialog.AnyFile,
QFileDialog.Directory):
# we use a dedicated directory selector.
# on some platforms the respected native
# dialogs are different... so we go with two for the best "native"
# experience
dir_button = QToolButton(self)
dir_button.setToolTip('Choose directory')
dir_button.setIcon(gooey_resources.get_best_icon('directory'))
hl.addWidget(dir_button)
dir_button.clicked.connect(
lambda: self._select_path(dirs_only=True))
def _set_gooey_param_value_in_widget(self, value):
if value and value is not _NoValue:
self._edit.setText(str(value))
else:
self._edit.clear()
def _handle_input(self):
val = self._edit.text()
# treat an empty path as None
self._set_gooey_param_value(val if val else None)
def set_gooey_param_docs(self, docs: str) -> None:
# only use edit tooltip for the docs, and let the buttons
# have their own
self._edit.setToolTip(docs)
def _select_path(self, dirs_only=False):
dialog = QFileDialog(self)
dialog.setFileMode(
QFileDialog.Directory if dirs_only else self._pathtype)
dialog.setOption(QFileDialog.DontResolveSymlinks)
if self._basedir:
# we have a basedir, so we can be clever
dialog.setDirectory(str(self._basedir))
# we need to turn on 'System' in order to get broken symlinks
# too
if not dirs_only:
dialog.setFilter(dialog.filter() | QDir.System)
dialog.finished.connect(self._select_path_receiver)
dialog.open()
def _select_path_receiver(self, result_code: int):
"""Internal slot to receive the outcome of _select_path() dialog"""
if not result_code:
if not self._edit.isEnabled():
# if the selection was canceled, clear the path,
# otherwise users have no ability to unset a pervious
# selection
self._set_gooey_param_value_in_widget(_NoValue)
# otherwise just keep the present value as-is
return
dialog = self.sender()
paths = dialog.selectedFiles()
if paths:
# ignores any multi-selection
# TODO prevent or support specifically
self._set_gooey_param_value_in_widget(paths[0])
def _init_gooey_from_other_params(self, spec: Dict) -> None:
if self._gooey_param_name == 'dataset':
# prevent update from self
return
if 'dataset' in spec:
self._basedir = spec['dataset']
class CfgProcParamWidget(ChoiceParamWidget):
"""Choice widget with items from `run_procedure(discover=True)`"""
def __init__(self, choices=None, parent=None):
super().__init__(parent=parent)
def _init_gooey_from_other_params(self, spec: Dict) -> None:
if self.count() and spec.get('dataset', _NoValue) is _NoValue:
# we have items and no context change is required
return
# we have no items yet, or the dataset has changed: query!
# reset first
while self.count():
self.removeItem(0)
from datalad.local.run_procedure import RunProcedure
for res in RunProcedure.__call__(
dataset=spec.get('dataset'),
discover=True,
return_type='generator',
result_renderer='disabled',
on_failure='ignore',
):
proc_name = res.get('procedure_name', '')
if res.get('status') != 'ok' \
or not proc_name.startswith('cfg_'):
# not a good config procedure
continue
# strip 'cfg_' prefix, even when reporting, we do not want it
# because commands like `create()` put it back themselves
self._add_item(proc_name[4:])
if self.count():
self.setEnabled(True)
self.setPlaceholderText('Select procedure')
class SiblingChoiceParamWidget(ChoiceParamWidget):
"""Choice widget with items from `siblings()`"""
def __init__(self, choices=None, parent=None):
super().__init__(parent=parent)
self._saw_dataset = False
self._set_placeholder_msg()
def _set_placeholder_msg(self):
if not self._saw_dataset:
self.setPlaceholderText('Select dataset first')
elif not self.count():
self.setPlaceholderText('No known siblings')
else:
self.setPlaceholderText('Select sibling')
def _init_gooey_from_other_params(self, spec: Dict) -> None:
if 'dataset' not in spec:
# we have items and no context change is required
return
self._saw_dataset = True
# the dataset has changed: query!
# reset first
self.clear()
from datalad.distribution.siblings import Siblings
from datalad.support.exceptions import (
CapturedException,
NoDatasetFound,
)
try:
for res in Siblings.__call__(
dataset=spec['dataset'],
action='query',
return_type='generator',
result_renderer='disabled',
on_failure='ignore',
):
sibling_name = res.get('name')
if (not sibling_name or res.get('status') != 'ok'
or res.get('type') != 'sibling'
or (sibling_name == 'here'
# be robust with Path objects
and res.get('path') == str(spec['dataset']))):
# not a good sibling
continue
self._add_item(sibling_name)
except NoDatasetFound as e:
CapturedException(e)
# TODO this should happen upon validation of the
# `dataset` parameter value
QMessageBox.critical(
self,
'No dataset selected',
'The path selected for the dataset
parameter '
'does not point to a valid dataset. '
'Please select another path!'
)
self._saw_dataset = False
# always update the placeholder, even when no items were created,
# because we have no seen a dataset, and this is the result
self._set_placeholder_msg()
if self.count():
self.setEnabled(True)
class CredentialChoiceParamWidget(QComboBox, GooeyParamWidgetMixin):
"""Choice widget with items from `credentials()`"""
def __init__(self, parent=None):
super().__init__(parent=parent)
self.setEditable(True)
self.setInsertPolicy(QComboBox.InsertAtTop)
self.setEnabled(True)
self.currentTextChanged.connect(self._handle_input)
self.setSizeAdjustPolicy(
QComboBox.AdjustToMinimumContentsLengthWithIcon)
self.setPlaceholderText('--auto--')
self._saw_dataset = False
def _init_gooey_from_other_params(self, spec: Dict) -> None:
if 'dataset' not in spec:
# we have items and no context change is required
return
self._saw_dataset = True
self._init_choices(
spec['dataset'] if spec['dataset'] != _NoValue else None)
def _init_choices(self, dataset=None):
# the dataset has changed: query!
# reset first
self.clear()
from datalad_next.credentials import Credentials
from datalad.support.exceptions import (
CapturedException,
NoDatasetFound,
)
self.addItem('')
try:
for res in Credentials.__call__(
dataset=dataset,
action='query',
return_type='generator',
result_renderer='disabled',
on_failure='ignore',
):
name = res.get('name')
if (not name or res.get('status') != 'ok'
or res.get('type') != 'credential'):
# not a good sibling
continue
self.addItem(name)
except NoDatasetFound as e:
CapturedException(e)
# TODO this should happen upon validation of the
# `dataset` parameter value
QMessageBox.critical(
self,
'No dataset selected',
'The path selected for the dataset
parameter '
'does not point to a valid dataset. '
'Please select another path!'
)
self._saw_dataset = False
def _set_gooey_param_value_in_widget(self, value):
self.setCurrentText(value or '')
def _handle_input(self):
self._set_gooey_param_value(
self.currentText() or _NoValue)