From 4bc81e923353679413b1d1b4442293a466c95fdd Mon Sep 17 00:00:00 2001 From: Hanjin Liu <40591297+hanjinliu@users.noreply.github.com> Date: Fri, 22 Nov 2024 23:32:04 +0900 Subject: [PATCH] Make all the valued containers subclass `ValueWidget` (#663) * use ValuedContainerWidget, refactor inheritance * typing * rename * remove backend EmptyWidget * style(pre-commit.ci): auto fixes [...] * update docs references to BaseValueWidget methods * add missing references to docs * fix references in TypeMap doc --------- Co-authored-by: Talley Lambert Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- docs/api/type_map.md | 5 + docs/api/widgets/bases.md | 29 +- docs/scripts/_hooks.py | 2 + docs/widgets.md | 2 +- src/magicgui/backends/_ipynb/__init__.py | 2 - src/magicgui/backends/_ipynb/widgets.py | 13 - src/magicgui/backends/_qtpy/__init__.py | 2 - src/magicgui/backends/_qtpy/widgets.py | 17 - src/magicgui/schema/_guiclass.py | 6 +- src/magicgui/schema/_ui_field.py | 8 +- src/magicgui/type_map/_type_map.py | 8 +- src/magicgui/widgets/_concrete.py | 253 +++++++------ src/magicgui/widgets/_function_gui.py | 6 +- src/magicgui/widgets/_image/_image.py | 6 +- src/magicgui/widgets/_table.py | 12 +- src/magicgui/widgets/bases/__init__.py | 15 +- src/magicgui/widgets/bases/_button_widget.py | 6 +- .../widgets/bases/_categorical_widget.py | 10 +- .../widgets/bases/_container_widget.py | 335 +++++++++++------- src/magicgui/widgets/bases/_ranged_widget.py | 34 +- src/magicgui/widgets/bases/_slider_widget.py | 8 +- src/magicgui/widgets/bases/_value_widget.py | 69 +++- tests/test_widgets.py | 18 +- 23 files changed, 520 insertions(+), 346 deletions(-) diff --git a/docs/api/type_map.md b/docs/api/type_map.md index 19a4ecd5f..38b5f6daf 100644 --- a/docs/api/type_map.md +++ b/docs/api/type_map.md @@ -5,6 +5,7 @@ magicgui.type_map.register_type magicgui.type_map.type_registered magicgui.type_map.type2callback + magicgui.type_map.TypeMap ::: magicgui.type_map.get_widget_class @@ -13,3 +14,7 @@ ::: magicgui.type_map.type_registered ::: magicgui.type_map.type2callback + +::: magicgui.type_map.TypeMap + options: + show_signature_annotations: false diff --git a/docs/api/widgets/bases.md b/docs/api/widgets/bases.md index 17146cd29..7d856a3d6 100644 --- a/docs/api/widgets/bases.md +++ b/docs/api/widgets/bases.md @@ -12,12 +12,15 @@ widgets. Therefore, it is worth being aware of the type of widget you are worki magicgui.widgets.bases.Widget magicgui.widgets.bases.ButtonWidget magicgui.widgets.bases.CategoricalWidget + magicgui.widgets.bases.BaseContainerWidget magicgui.widgets.bases.ContainerWidget + magicgui.widgets.bases.ValuedContainerWidget magicgui.widgets.bases.DialogWidget magicgui.widgets.bases.MainWindowWidget magicgui.widgets.bases.RangedWidget magicgui.widgets.bases.SliderWidget magicgui.widgets.bases.ValueWidget + magicgui.widgets.bases.BaseValueWidget ## Class Hierarchy @@ -25,13 +28,17 @@ In visual form, the widget class hierarchy looks like this: ``` mermaid classDiagram - Widget <|-- ValueWidget - Widget <|-- ContainerWidget + Widget <|-- BaseValueWidget + BaseValueWidget <|-- ValueWidget + Widget <|-- BaseContainerWidget BackendWidget ..|> WidgetProtocol : implements a ValueWidget <|-- RangedWidget ValueWidget <|-- ButtonWidget ValueWidget <|-- CategoricalWidget RangedWidget <|-- SliderWidget + BaseContainerWidget <|-- ContainerWidget + BaseContainerWidget <|-- ValuedContainerWidget + BaseValueWidget <|-- ValuedContainerWidget Widget --* WidgetProtocol : controls a <> WidgetProtocol class WidgetProtocol { @@ -53,12 +60,14 @@ classDiagram close() render() } - class ValueWidget{ + class BaseValueWidget{ value: Any changed: SignalInstance bind(value, call) Any unbind() } + class ValueWidget{ + } class RangedWidget{ value: float | tuple min: float @@ -78,7 +87,7 @@ classDiagram class CategoricalWidget{ choices: List[Any] } - class ContainerWidget{ + class BaseContainerWidget{ widgets: List[Widget] labels: bool layout: str @@ -89,12 +98,13 @@ classDiagram } click Widget href "#magicgui.widgets.bases.Widget" + click BaseValueWidget href "#magicgui.widgets.bases.BaseValueWidget" click ValueWidget href "#magicgui.widgets.bases.ValueWidget" click RangedWidget href "#magicgui.widgets.bases.RangedWidget" click SliderWidget href "#magicgui.widgets.bases.SliderWidget" click ButtonWidget href "#magicgui.widgets.bases.ButtonWidget" click CategoricalWidget href "#magicgui.widgets.bases.CategoricalWidget" - click ContainerWidget href "#magicgui.widgets.bases.ContainerWidget" + click BaseContainerWidget href "#magicgui.widgets.bases.BaseContainerWidget" ``` @@ -109,9 +119,15 @@ classDiagram ::: magicgui.widgets.bases.CategoricalWidget options: heading_level: 3 +::: magicgui.widgets.bases.BaseContainerWidget + options: + heading_level: 3 ::: magicgui.widgets.bases.ContainerWidget options: heading_level: 3 +::: magicgui.widgets.bases.ValuedContainerWidget + options: + heading_level: 3 ::: magicgui.widgets.bases.DialogWidget options: heading_level: 3 @@ -127,3 +143,6 @@ classDiagram ::: magicgui.widgets.bases.ValueWidget options: heading_level: 3 +::: magicgui.widgets.bases.BaseValueWidget + options: + heading_level: 3 diff --git a/docs/scripts/_hooks.py b/docs/scripts/_hooks.py index 0dfe5b23e..c6d928ff7 100644 --- a/docs/scripts/_hooks.py +++ b/docs/scripts/_hooks.py @@ -111,6 +111,8 @@ def _replace_autosummary(md: str) -> str: if name: module, _name = name.rsplit(".", 1) obj = getattr(import_module(module), _name) + if obj.__doc__ is None: + raise ValueError(f"Missing docstring for {obj}") table.append(f"| [`{_name}`][{name}] | {obj.__doc__.splitlines()[0]} |") lines[start:last_line] = table return "\n".join(lines) diff --git a/docs/widgets.md b/docs/widgets.md index 98b67ac03..829ef0380 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -119,7 +119,7 @@ following `ValueWidgets` track some `value`: |-----------|------|-------------| | `value` | `Any` | The current value of the widget. | | `changed` | [`psygnal.SignalInstance`][psygnal.SignalInstance] | A [`psygnal.SignalInstance`][psygnal.SignalInstance] that will emit an event when the `value` has changed. Connect callbacks to the change event using `widget.changed.connect(callback)` | -| `bind` | `Any, optional` | A value or callback to bind this widget. If bound, whenever `widget.value` is accessed, the value provided here will be returned. The bound value can be a callable, in which case `bound_value(self)` will be returned (i.e. your callback must accept a single parameter, which is this widget instance.). see [`ValueWidget.bind`][magicgui.widgets.bases.ValueWidget.bind] for details. | +| `bind` | `Any, optional` | A value or callback to bind this widget. If bound, whenever `widget.value` is accessed, the value provided here will be returned. The bound value can be a callable, in which case `bound_value(self)` will be returned (i.e. your callback must accept a single parameter, which is this widget instance.). see [`ValueWidget.bind`][magicgui.widgets.bases.BaseValueWidget.bind] for details. | Here is a demonstration of all these: diff --git a/src/magicgui/backends/_ipynb/__init__.py b/src/magicgui/backends/_ipynb/__init__.py index b9f98e29a..29106e07d 100644 --- a/src/magicgui/backends/_ipynb/__init__.py +++ b/src/magicgui/backends/_ipynb/__init__.py @@ -5,7 +5,6 @@ Container, DateEdit, DateTimeEdit, - EmptyWidget, FloatSlider, FloatSpinBox, Label, @@ -34,7 +33,6 @@ "DateEdit", "TimeEdit", "DateTimeEdit", - "EmptyWidget", "FloatSlider", "FloatSpinBox", "Label", diff --git a/src/magicgui/backends/_ipynb/widgets.py b/src/magicgui/backends/_ipynb/widgets.py index aa0778325..a2dd6c57a 100644 --- a/src/magicgui/backends/_ipynb/widgets.py +++ b/src/magicgui/backends/_ipynb/widgets.py @@ -143,19 +143,6 @@ def _mgui_render(self): pass -class EmptyWidget(_IPyWidget): - _ipywidget: ipywdg.Widget - - def _mgui_get_value(self) -> Any: - raise NotImplementedError() - - def _mgui_set_value(self, value: Any) -> None: - raise NotImplementedError() - - def _mgui_bind_change_callback(self, callback: Callable): - pass - - class _IPyValueWidget(_IPyWidget, protocols.ValueWidgetProtocol): def _mgui_get_value(self) -> float: return self._ipywidget.value diff --git a/src/magicgui/backends/_qtpy/__init__.py b/src/magicgui/backends/_qtpy/__init__.py index a42b100c3..2f98cf554 100644 --- a/src/magicgui/backends/_qtpy/__init__.py +++ b/src/magicgui/backends/_qtpy/__init__.py @@ -6,7 +6,6 @@ DateEdit, DateTimeEdit, Dialog, - EmptyWidget, FloatRangeSlider, FloatSlider, FloatSpinBox, @@ -41,7 +40,6 @@ "DateEdit", "DateTimeEdit", "Dialog", - "EmptyWidget", "FloatRangeSlider", "FloatSlider", "FloatSpinBox", diff --git a/src/magicgui/backends/_qtpy/widgets.py b/src/magicgui/backends/_qtpy/widgets.py index 7d2605173..60dd869eb 100644 --- a/src/magicgui/backends/_qtpy/widgets.py +++ b/src/magicgui/backends/_qtpy/widgets.py @@ -232,23 +232,6 @@ def _pre_set_hook(self, value: Any) -> Any: return value -# BASE WIDGET - - -class EmptyWidget(QBaseWidget): - def __init__(self, **kwargs: Any) -> None: - super().__init__(QtW.QWidget, **kwargs) - - def _mgui_get_value(self) -> Any: - raise NotImplementedError() - - def _mgui_set_value(self, value: Any) -> None: - raise NotImplementedError() - - def _mgui_bind_change_callback(self, callback: Callable) -> None: - pass - - # STRING WIDGETS diff --git a/src/magicgui/schema/_guiclass.py b/src/magicgui/schema/_guiclass.py index 9f9f24c57..110b07fac 100644 --- a/src/magicgui/schema/_guiclass.py +++ b/src/magicgui/schema/_guiclass.py @@ -19,7 +19,7 @@ from magicgui.schema._ui_field import build_widget from magicgui.widgets import PushButton -from magicgui.widgets.bases import ContainerWidget, ValueWidget +from magicgui.widgets.bases import BaseValueWidget, ContainerWidget if TYPE_CHECKING: from collections.abc import Mapping @@ -206,7 +206,7 @@ def __set_name__(self, owner: type, name: str) -> None: def __get__( self, instance: object | None, owner: type - ) -> ContainerWidget[ValueWidget]: + ) -> ContainerWidget[BaseValueWidget]: wdg = build_widget(owner if instance is None else instance) # look for @button-decorated methods @@ -317,7 +317,7 @@ def unbind_gui_from_instance(gui: ContainerWidget, instance: Any) -> None: An instance of a `guiclass`. """ for widget in gui: - if isinstance(widget, ValueWidget): + if isinstance(widget, BaseValueWidget): widget.changed.disconnect_setattr(instance, widget.name, missing_ok=True) diff --git a/src/magicgui/schema/_ui_field.py b/src/magicgui/schema/_ui_field.py index 98f85d2e4..24dc80138 100644 --- a/src/magicgui/schema/_ui_field.py +++ b/src/magicgui/schema/_ui_field.py @@ -32,7 +32,7 @@ from attrs import Attribute from pydantic.fields import FieldInfo, ModelField - from magicgui.widgets.bases import ContainerWidget, ValueWidget + from magicgui.widgets.bases import BaseValueWidget, ContainerWidget class HasAttrs(Protocol): """Protocol for objects that have an ``attrs`` attribute.""" @@ -394,7 +394,7 @@ def parse_annotated(self) -> UiField[T]: kwargs.pop("name", None) return dc.replace(self, **kwargs) - def create_widget(self, value: T | _Undefined = Undefined) -> ValueWidget[T]: + def create_widget(self, value: T | _Undefined = Undefined) -> BaseValueWidget[T]: """Create a new Widget for this field.""" from magicgui.type_map import get_widget_class @@ -786,7 +786,7 @@ def _uifields_to_container( values: Mapping[str, Any] | None = None, *, container_kwargs: Mapping | None = None, -) -> ContainerWidget[ValueWidget]: +) -> ContainerWidget[BaseValueWidget]: """Create a container widget from a sequence of UiFields. This function is the heart of build_widget. @@ -849,7 +849,7 @@ def _get_values(obj: Any) -> dict | None: # TODO: unify this with magicgui -def build_widget(cls_or_instance: Any) -> ContainerWidget[ValueWidget]: +def build_widget(cls_or_instance: Any) -> ContainerWidget[BaseValueWidget]: """Build a magicgui widget from a dataclass, attrs, pydantic, or function.""" values = None if isinstance(cls_or_instance, type) else _get_values(cls_or_instance) return _uifields_to_container(get_ui_fields(cls_or_instance), values=values) diff --git a/src/magicgui/type_map/_type_map.py b/src/magicgui/type_map/_type_map.py index 08fa8a9f5..d916a434b 100644 --- a/src/magicgui/type_map/_type_map.py +++ b/src/magicgui/type_map/_type_map.py @@ -61,7 +61,7 @@ class MissingWidget(RuntimeError): PathLike: widgets.FileEdit, } -_SIMPLE_TYPES_DEFAULTS = { +_SIMPLE_TYPES_DEFAULTS: dict[type, type[widgets.Widget]] = { bool: widgets.CheckBox, int: widgets.SpinBox, float: widgets.FloatSpinBox, @@ -85,6 +85,8 @@ class MissingWidget(RuntimeError): class TypeMap: + """Storage for mapping from types to widgets and callbacks.""" + def __init__( self, *, @@ -404,7 +406,7 @@ def create_widget( This factory function can be used to create a widget appropriate for the provided `value` and/or `annotation` provided. See - [Type Mapping Docs](../../type_map.md) for details on how the widget type is + [Type Mapping Docs](../type_map.md) for details on how the widget type is determined from type annotations. Parameters @@ -453,7 +455,7 @@ def create_widget( ------ TypeError If the provided or autodetected `widget_type` does not implement any known - [widget protocols](../protocols.md) + [widget protocols](protocols.md) Examples -------- diff --git a/src/magicgui/widgets/_concrete.py b/src/magicgui/widgets/_concrete.py index 733503033..d9d7b066f 100644 --- a/src/magicgui/widgets/_concrete.py +++ b/src/magicgui/widgets/_concrete.py @@ -33,6 +33,7 @@ from magicgui.application import use_app from magicgui.types import ChoicesType, FileDialogMode, PathLike, Undefined, _Undefined from magicgui.widgets.bases import ( + BaseValueWidget, ButtonWidget, CategoricalWidget, ContainerWidget, @@ -43,6 +44,7 @@ SliderWidget, ToolBarWidget, TransformedRangedWidget, + ValuedContainerWidget, ValueWidget, Widget, create_widget, @@ -55,7 +57,10 @@ from typing_extensions import Unpack from magicgui.widgets import protocols - from magicgui.widgets.bases._container_widget import ContainerKwargs + from magicgui.widgets.bases._container_widget import ( + ContainerKwargs, + ValuedContainerKwargs, + ) from magicgui.widgets.bases._widget import WidgetKwargs @@ -123,8 +128,7 @@ def __init__(self: object, **kwargs: Any) -> None: return wrapper(cls) if cls else wrapper -@backend_widget -class EmptyWidget(ValueWidget): +class EmptyWidget(ValuedContainerWidget[Any]): """A base widget with no value. This widget is primarily here to serve as a "hidden widget" to which a value or @@ -137,13 +141,7 @@ def get_value(self) -> Any: """Return value if one has been manually set... otherwise return Param.empty.""" return self._hidden_value - @property - def value(self) -> Any: - """Look for a bound value, otherwise fallback to `get_value`.""" - return super().value - - @value.setter - def value(self, value: Any) -> None: + def set_value(self, value: Any) -> None: self._hidden_value = value def __repr__(self) -> str: @@ -386,7 +384,7 @@ class MainWindow(MainWindowWidget): @merge_super_sigs -class FileEdit(Container): +class FileEdit(ValuedContainerWidget[Union[Path, tuple[Path, ...], None]]): """A LineEdit widget with a button that opens a FileDialog. Parameters @@ -406,26 +404,30 @@ def __init__( self, mode: FileDialogMode = FileDialogMode.EXISTING_FILE, filter: str | None = None, - nullable: bool = False, - **kwargs: Unpack[ContainerKwargs], + value: Path | tuple[Path, ...] | None | _Undefined = Undefined, + **kwargs: Unpack[ValuedContainerKwargs], ) -> None: # use empty string as a null value - value = kwargs.pop("value", None) # type: ignore [typeddict-item] - if value is None: - value = "" - self.line_edit = LineEdit(value=value) + if value is None or value is Undefined: + _line_edit_value = "" + elif isinstance(value, (str, Path)): + _line_edit_value = str(value) + elif isinstance(value, (list, tuple)): + _line_edit_value = ", ".join(os.fspath(p) for p in value) + else: + raise TypeError( + f"value must be a string, or list/tuple of strings, got {type(value)}" + ) + self.line_edit = LineEdit(value=_line_edit_value) self.choose_btn = PushButton() self.mode = mode # sets the button text too self.filter = filter - self._nullable = nullable kwargs["widgets"] = [self.line_edit, self.choose_btn] kwargs["labels"] = False kwargs["layout"] = "horizontal" super().__init__(**kwargs) self.margins = (0, 0, 0, 0) self._show_file_dialog = use_app().get_obj("show_file_dialog") - self.choose_btn.changed.disconnect() - self.line_edit.changed.disconnect() self.choose_btn.changed.connect(self._on_choose_clicked) self.line_edit.changed.connect(lambda: self.changed.emit(self.value)) @@ -462,9 +464,8 @@ def _on_choose_clicked(self) -> None: if result: self.value = result - @property - def value(self) -> tuple[Path, ...] | Path | None: - """Return current value of the widget. This may be interpreted by backends.""" + def get_value(self) -> tuple[Path, ...] | Path | None: + """Return current value.""" text = self.line_edit.value if self._nullable and not text: return None @@ -472,8 +473,7 @@ def value(self) -> tuple[Path, ...] | Path | None: return tuple(Path(p) for p in text.split(", ") if p.strip()) return Path(text) - @value.setter - def value(self, value: Sequence[PathLike] | PathLike | None) -> None: + def set_value(self, value: Sequence[PathLike] | PathLike | None) -> None: """Set current file path.""" if value is None and self._nullable: value = "" @@ -492,25 +492,11 @@ def __repr__(self) -> str: return f"FileEdit(mode={self.mode.value!r}, value={self.value!r})" -@merge_super_sigs -class RangeEdit(Container[SpinBox]): - """A widget to represent a python range object, with start/stop/step. +_V0 = TypeVar("_V0", range, slice) - A range object produces a sequence of integers from start (inclusive) - to stop (exclusive) by step. range(i, j) produces i, i+1, i+2, ..., j-1. - start defaults to 0, and stop is omitted! range(4) produces 0, 1, 2, 3. - These are exactly the valid indices for a list of 4 elements. - When step is given, it specifies the increment (or decrement). - Parameters - ---------- - start : int, optional - The range start value, by default 0 - stop : int, optional - The range stop value, by default 10 - step : int, optional - The range step value, by default 1 - """ +class _RangeOrSliceEdit(ValuedContainerWidget[_V0]): + _value_type: type[_V0] def __init__( self, @@ -519,7 +505,7 @@ def __init__( step: int = 1, min: int | tuple[int, int, int] | None = None, max: int | tuple[int, int, int] | None = None, - **kwargs: Unpack[ContainerKwargs], + **kwargs: Unpack[ValuedContainerKwargs], ) -> None: value = kwargs.pop("value", None) # type: ignore [typeddict-item] if value is not None and value is not Undefined: @@ -531,10 +517,12 @@ def __init__( self.start = SpinBox(value=start, min=minstart, max=maxstart, name="start") self.stop = SpinBox(value=stop, min=minstop, max=maxstop, name="stop") self.step = SpinBox(value=step, min=minstep, max=maxstep, name="step") - kwargs["widgets"] = [self.start, self.stop, self.step] + self.start.changed.connect(self._emit_current_value) + self.stop.changed.connect(self._emit_current_value) + self.step.changed.connect(self._emit_current_value) kwargs.setdefault("layout", "horizontal") kwargs.setdefault("labels", True) - kwargs.pop("nullable", None) # type: ignore [typeddict-item] + kwargs["widgets"] = [self.start, self.stop, self.step] super().__init__(**kwargs) @classmethod @@ -553,14 +541,17 @@ def _validate_min_max( else: return (int(default),) * 3 - @property - def value(self) -> range: - """Return current value of the widget. This may be interpreted by backends.""" - return range(self.start.value, self.stop.value, self.step.value) + def _emit_current_value(self) -> None: + return self.changed.emit(self.value) - @value.setter - def value(self, value: range) -> None: + def get_value(self) -> _V0: + """Return current value of the widget.""" + return self._value_type(self.start.value, self.stop.value, self.step.value) + + def set_value(self, value: _V0) -> None: """Set current file path.""" + if not isinstance(value, self._value_type): + raise TypeError(f"value must be a {self._value_type}, got {type(value)}") self.start.value = value.start self.stop.value = value.stop self.step.value = value.step @@ -570,56 +561,73 @@ def __repr__(self) -> str: return f"<{self.__class__.__name__} value={self.value!r}>" -class SliceEdit(RangeEdit): +@merge_super_sigs +class RangeEdit(_RangeOrSliceEdit[range]): + """A widget to represent a python range object, with start/stop/step. + + A range object produces a sequence of integers from start (inclusive) + to stop (exclusive) by step. range(i, j) produces i, i+1, i+2, ..., j-1. + start defaults to 0, and stop is omitted! range(4) produces 0, 1, 2, 3. + These are exactly the valid indices for a list of 4 elements. + When step is given, it specifies the increment (or decrement). + + Parameters + ---------- + start : int, optional + The range start value, by default 0 + stop : int, optional + The range stop value, by default 10 + step : int, optional + The range step value, by default 1 + """ + + _value_type = range + + +class SliceEdit(_RangeOrSliceEdit[slice]): """A widget to represent `slice` objects, with start/stop/step. slice(stop) slice(start, stop[, step]) Slice objects may be used for extended slicing (e.g. a[0:10:2]) + + Parameters + ---------- + start : int, optional + The range start value, by default 0 + stop : int, optional + The range stop value, by default 10 + step : int, optional + The range step value, by default 1 """ - @property # type: ignore - def value(self) -> slice: - """Return current value of the widget. This may be interpreted by backends.""" - return slice(self.start.value, self.stop.value, self.step.value) - - @value.setter - def value(self, value: slice) -> None: - """Set current file path.""" - self.start.value = value.start - self.stop.value = value.stop - self.step.value = value.step + _value_type = slice -class _ListEditChildWidget(Container[Widget]): +class _ListEditChildWidget(ValuedContainerWidget[_V]): """A widget to represent a single element of a ListEdit widget.""" - def __init__(self, widget: ValueWidget): + def __init__(self, widget: BaseValueWidget[_V]): btn = PushButton(text="-") super().__init__(widgets=[widget, btn], layout="horizontal", labels=False) self.btn_minus = btn self.value_widget = widget self.margins = (0, 0, 0, 0) - - btn.changed.disconnect() - widget.changed.disconnect() widget.changed.connect(self.changed.emit) btn.max_height = btn.max_width = use_app().get_obj("get_text_width")("-") + 4 - @property - def value(self) -> Any: + def get_value(self) -> Any: """Return value of the child widget.""" return self.value_widget.value - @value.setter - def value(self, value: Any) -> None: + def set_value(self, value: Any) -> None: """Set value of the child widget.""" self.value_widget.value = value @merge_super_sigs -class ListEdit(Container[ValueWidget[_V]]): +class ListEdit(ValuedContainerWidget[list[_V]]): """A widget to represent a list of values. A ListEdit container can create a list with multiple objects of same type. It @@ -641,12 +649,10 @@ class ListEdit(Container[ValueWidget[_V]]): def __init__( self, value: Iterable[_V] | _Undefined = Undefined, - nullable: bool = False, options: dict | None = None, - **container_kwargs: Unpack[ContainerKwargs], + **container_kwargs: Unpack[ValuedContainerKwargs], ) -> None: self._args_type: type | None = None - self._nullable = nullable container_kwargs.setdefault("layout", "horizontal") container_kwargs.setdefault("labels", False) super().__init__(**container_kwargs) @@ -667,8 +673,7 @@ def __init__( button_plus = PushButton(text="+", name="plus") - self.append(button_plus) # type: ignore - button_plus.changed.disconnect() + self._insert_widget(0, button_plus) button_plus.changed.connect(lambda: self._append_value()) for a in _value: @@ -719,7 +724,16 @@ def annotation(self, value: Any) -> None: def __delitem__(self, key: int | slice) -> None: """Delete child widget(s).""" - super().__delitem__(key) + if isinstance(key, int): + self._pop_widget(key) + elif isinstance(key, slice): + for i in range(*key.indices(len(self))): + self._pop_widget(i) + else: + raise TypeError( + f"{self.__class__.__name__} indices must be integers or slices, got " + f"{type(key).__name__}" + ) self.changed.emit(self.value) def _append_value(self, value: _V | _Undefined = Undefined) -> None: @@ -731,35 +745,46 @@ def _append_value(self, value: _V | _Undefined = Undefined) -> None: name=f"value_{i}", options=self._child_options, ) - widget = _ListEditChildWidget(cast(ValueWidget, _value_widget)) - # connect the minus-button-clicked event - widget.btn_minus.changed.connect(lambda: self.remove(widget)) + widget = _ListEditChildWidget(cast(BaseValueWidget, _value_widget)) - # _ListEditChildWidget is technically a ValueWidget. - self.insert(i, widget) # type: ignore + # connect the minus-button-clicked event + def _remove_me() -> None: + self._pop_widget(self.index(widget)) + self.changed.emit(self.value) - widget.changed.disconnect() + widget.btn_minus.changed.connect(_remove_me) + self._insert_widget(i, widget) # Value must be set after new widget is inserted because it could be # valid only after same parent is shared between widgets. if value is Undefined and i > 0: # copy value from the previous child widget if possible - value = self[i - 1].value + value = self._get_child_widget(i - 1).value if value is not Undefined: widget.value = value widget.changed.connect(lambda: self.changed.emit(self.value)) self.changed.emit(self.value) - @property - def value(self) -> list[_V]: + def _get_child_widget(self, key: int) -> _ListEditChildWidget[_V]: + if key < 0: + key += len(self) - 1 + if key < 0 or key >= len(self) - 1: + raise IndexError("list index out of range") + return self[key] # type: ignore + + def _get_child_widgets(self, key: slice) -> list[_ListEditChildWidget[_V]]: + key = slice(*key.indices(len(self) - 1)) + return self[key] # type: ignore + + def get_value(self) -> list[_V]: """Return current value as a list object.""" return list(ListDataView(self)) - @value.setter - def value(self, vals: Iterable[_V]) -> None: + def set_value(self, vals: Iterable[_V]) -> None: with self.changed.blocked(): - del self[:-1] + for _ in range(len(self) - 1): + self._pop_widget(0) for v in vals: self._append_value(v) self.changed.emit(self.value) @@ -779,7 +804,7 @@ class ListDataView(Generic[_V]): def __init__(self, obj: ListEdit[_V]): self._obj = obj - self._widgets = list(obj[:-1]) + self._widgets = list(obj._list[:-1]) def __repr__(self) -> str: """Return list-like representation.""" @@ -802,9 +827,9 @@ def __getitem__(self, key: slice) -> list[_V]: ... def __getitem__(self, key: int | slice) -> _V | list[_V]: """Slice as a list.""" if isinstance(key, int): - return self._widgets[key].value + return self._obj._get_child_widget(key).value elif isinstance(key, slice): - return [w.value for w in self._widgets[key]] + return [w.value for w in self._obj._get_child_widgets(key)] else: raise TypeError( f"list indices must be integers or slices, not {type(key).__name__}" @@ -819,14 +844,17 @@ def __setitem__(self, key: slice, value: _V | Iterable[_V]) -> None: ... def __setitem__(self, key: int | slice, value: _V | Iterable[_V]) -> None: """Update widget value.""" if isinstance(key, int): - self._widgets[key].value = cast(_V, value) + self._obj._get_child_widget(key).value = cast(_V, value) elif isinstance(key, slice): with self._obj.changed.blocked(): - if isinstance(value, type(self._widgets[0].value)): - for w in self._widgets[key]: + if isinstance(value, type(self._obj._get_child_widget(0).value)): + for w in self._obj._get_child_widgets(key): w.value = value else: - for w, v in zip(self._widgets[key], value): # type: ignore + value_list = list(value) # type: ignore + if len(value_list) != len(self._obj._get_child_widgets(key)): + raise ValueError("Length of value does not match.") + for w, v in zip(self._obj._get_child_widgets(key), value_list): w.value = v self._obj.changed.emit(self._obj.value) else: @@ -846,12 +874,12 @@ def __delitem__(self, key: int | slice) -> None: def __iter__(self) -> Iterator[_V]: """Iterate over values of child widgets.""" - for w in self._widgets: + for w in self._obj._get_child_widgets(slice(None)): yield w.value @merge_super_sigs -class TupleEdit(Container[ValueWidget]): +class TupleEdit(ValuedContainerWidget[tuple]): """A widget to represent a tuple of values. A TupleEdit container has several child widgets of different type. Their value is @@ -871,13 +899,11 @@ class TupleEdit(Container[ValueWidget]): def __init__( self, - value: Iterable[_V] | _Undefined = Undefined, + value: Iterable | _Undefined = Undefined, *, - nullable: bool = False, - options: dict | None = None, - **container_kwargs: Unpack[ContainerKwargs[ValueWidget]], + options: dict[str, Any] | None = None, + **container_kwargs: Unpack[ValuedContainerKwargs[tuple]], ) -> None: - self._nullable = nullable self._args_types: tuple[type, ...] | None = None container_kwargs.setdefault("labels", False) container_kwargs.setdefault("layout", "horizontal") @@ -900,7 +926,7 @@ def __init__( for a in _value: i = len(self) widget = cast( - ValueWidget, + BaseValueWidget, create_widget( value=a, annotation=self._args_types[i], @@ -908,8 +934,7 @@ def __init__( options=self._child_options, ), ) - self.insert(i, widget) - widget.changed.disconnect() + self._insert_widget(i, widget) widget.changed.connect(lambda: self.changed.emit(self.value)) @property @@ -949,19 +974,17 @@ def annotation(self, value: Any) -> None: self._annotation = value self._args_types = args - @property - def value(self) -> tuple: + def get_value(self) -> tuple: """Return current value as a tuple.""" - return tuple(w.value for w in self) + return tuple(w.value for w in self._list) # type: ignore - @value.setter - def value(self, vals: Sequence) -> None: + def set_value(self, vals: Sequence[Any]) -> None: if len(vals) != len(self): raise ValueError("Length of tuple does not match.") with self.changed.blocked(): - for w, v in zip(self, vals): - w.value = v + for w, v in zip(self._list, vals): + w.value = v # type: ignore self.changed.emit(self.value) diff --git a/src/magicgui/widgets/_function_gui.py b/src/magicgui/widgets/_function_gui.py index f7a4c532e..f4e1997fc 100644 --- a/src/magicgui/widgets/_function_gui.py +++ b/src/magicgui/widgets/_function_gui.py @@ -25,7 +25,7 @@ from magicgui._type_resolution import resolve_single_type from magicgui.signature import MagicSignature, magic_signature from magicgui.widgets import Container, MainWindow, ProgressBar, PushButton -from magicgui.widgets.bases import ValueWidget +from magicgui.widgets.bases import BaseValueWidget if TYPE_CHECKING: from collections.abc import Iterator @@ -238,10 +238,10 @@ def _disable_button_and_call() -> None: self.append(self._call_button) - self._result_widget: ValueWidget | None = None + self._result_widget: BaseValueWidget | None = None if result_widget: self._result_widget = cast( - ValueWidget, + BaseValueWidget, type_map.create_widget( value=None, annotation=self._return_annotation, diff --git a/src/magicgui/widgets/_image/_image.py b/src/magicgui/widgets/_image/_image.py index 75c94d99f..d4f75d359 100644 --- a/src/magicgui/widgets/_image/_image.py +++ b/src/magicgui/widgets/_image/_image.py @@ -25,13 +25,11 @@ class Image(ValueWidget): _widget: ValueWidgetProtocol _image: _mpl_image.Image | None = None - @property - def value(self): + def get_value(self): """Return current image array.""" return self._image._A if self._image else None - @value.setter - def value(self, value): + def set_value(self, value): """Set current data. Alias for ``image.set_data(value)``.""" self.set_data(value) diff --git a/src/magicgui/widgets/_table.py b/src/magicgui/widgets/_table.py index 233c27c2a..98c0f1c48 100644 --- a/src/magicgui/widgets/_table.py +++ b/src/magicgui/widgets/_table.py @@ -134,7 +134,11 @@ def __repr__(self) -> str: return f"table_items({n} {self._axis}s)" -class Table(ValueWidget, _ReadOnlyMixin, MutableMapping[TblKey, list]): +class Table( + ValueWidget[Mapping[TblKey, Collection]], + _ReadOnlyMixin, + MutableMapping[TblKey, list], +): """A widget to represent columnar or 2D data with headers. Tables behave like plain `dicts`, where the keys are column headers and the @@ -243,13 +247,11 @@ def __init__( "columns": columns if columns is not None else _columns, } - @property - def value(self) -> dict[TblKey, Collection]: + def get_value(self) -> dict[TblKey, Collection]: """Return dict with current `data`, `index`, and `columns` of the widget.""" return self.to_dict("split") - @value.setter - def value(self, value: TableData) -> None: + def set_value(self, value: TableData) -> None: """Set table data from dict, dataframe, list, or array. Parameters diff --git a/src/magicgui/widgets/bases/__init__.py b/src/magicgui/widgets/bases/__init__.py index 6d717c1d1..e7ab7b536 100644 --- a/src/magicgui/widgets/bases/__init__.py +++ b/src/magicgui/widgets/bases/__init__.py @@ -41,15 +41,22 @@ def __init__( from ._button_widget import ButtonWidget from ._categorical_widget import CategoricalWidget -from ._container_widget import ContainerWidget, DialogWidget, MainWindowWidget +from ._container_widget import ( + BaseContainerWidget, + ContainerWidget, + DialogWidget, + MainWindowWidget, + ValuedContainerWidget, +) from ._create_widget import create_widget from ._ranged_widget import RangedWidget, TransformedRangedWidget from ._slider_widget import MultiValuedSliderWidget, SliderWidget from ._toolbar import ToolBarWidget -from ._value_widget import ValueWidget +from ._value_widget import BaseValueWidget, ValueWidget from ._widget import Widget __all__ = [ + "BaseContainerWidget", "ButtonWidget", "CategoricalWidget", "ContainerWidget", @@ -57,10 +64,12 @@ def __init__( "DialogWidget", "MainWindowWidget", "MultiValuedSliderWidget", + "ValueWidget", "RangedWidget", "SliderWidget", "ToolBarWidget", "TransformedRangedWidget", - "ValueWidget", + "ValuedContainerWidget", + "BaseValueWidget", "Widget", ] diff --git a/src/magicgui/widgets/bases/_button_widget.py b/src/magicgui/widgets/bases/_button_widget.py index faed7d484..7978fac41 100644 --- a/src/magicgui/widgets/bases/_button_widget.py +++ b/src/magicgui/widgets/bases/_button_widget.py @@ -6,7 +6,7 @@ from magicgui.types import Undefined, _Undefined -from ._value_widget import ValueWidget +from ._value_widget import BaseValueWidget, ValueWidget if TYPE_CHECKING: from typing_extensions import Unpack @@ -29,7 +29,7 @@ class ButtonWidget(ValueWidget[bool]): The text to display on the button. If not provided, will use ``name``. bind : Callable[[ValueWidget], Any] | Any, optional A value or callback to bind this widget. If provided, whenever - [`widget.value`][magicgui.widgets.bases.ValueWidget.value] is + [`widget.value`][magicgui.widgets.bases.BaseValueWidget.value] is accessed, the value provided here will be returned instead. `bind` may be a callable, in which case `bind(self)` will be returned (i.e. your bound callback must accept a single parameter, which is this widget instance). @@ -54,7 +54,7 @@ def __init__( text: str | None = None, icon: str | None = None, icon_color: str | None = None, - bind: bool | Callable[[ValueWidget], bool] | _Undefined = Undefined, + bind: bool | Callable[[BaseValueWidget], bool] | _Undefined = Undefined, nullable: bool = False, **base_widget_kwargs: Unpack[WidgetKwargs], ) -> None: diff --git a/src/magicgui/widgets/bases/_categorical_widget.py b/src/magicgui/widgets/bases/_categorical_widget.py index d172caa3b..ff39d8a0e 100644 --- a/src/magicgui/widgets/bases/_categorical_widget.py +++ b/src/magicgui/widgets/bases/_categorical_widget.py @@ -5,7 +5,7 @@ from magicgui.types import ChoicesType, Undefined, _Undefined -from ._value_widget import T, ValueWidget +from ._value_widget import BaseValueWidget, T, ValueWidget if TYPE_CHECKING: from typing_extensions import Unpack @@ -26,7 +26,7 @@ class CategoricalWidget(ValueWidget[T]): Available choices displayed in the combo box. bind : Callable[[ValueWidget], Any] | Any, optional A value or callback to bind this widget. If provided, whenever - [`widget.value`][magicgui.widgets.bases.ValueWidget.value] is + [`widget.value`][magicgui.widgets.bases.BaseValueWidget.value] is accessed, the value provided here will be returned instead. `bind` may be a callable, in which case `bind(self)` will be returned (i.e. your bound callback must accept a single parameter, which is this widget instance). @@ -47,7 +47,7 @@ def __init__( choices: ChoicesType = (), *, allow_multiple: bool | None = None, - bind: T | Callable[[ValueWidget], T] | _Undefined = Undefined, + bind: T | Callable[[BaseValueWidget], T] | _Undefined = Undefined, nullable: bool = False, **base_widget_kwargs: Unpack[WidgetKwargs], ) -> None: @@ -66,7 +66,7 @@ def _post_init(self) -> None: @property def value(self) -> T: """Return current value of the widget.""" - return ValueWidget.value.fget(self) # type: ignore + return BaseValueWidget.value.fget(self) # type: ignore @value.setter def value(self, value: T) -> None: @@ -79,7 +79,7 @@ def value(self, value: T) -> None: raise ValueError( f"{value!r} is not a valid choice. must be in {self.choices}" ) - return ValueWidget.value.fset(self, value) # type: ignore + return BaseValueWidget.value.fset(self, value) # type: ignore @property def options(self) -> dict: diff --git a/src/magicgui/widgets/bases/_container_widget.py b/src/magicgui/widgets/bases/_container_widget.py index 7f8d9db2a..f07277002 100644 --- a/src/magicgui/widgets/bases/_container_widget.py +++ b/src/magicgui/widgets/bases/_container_widget.py @@ -9,6 +9,7 @@ Generic, NoReturn, TypeVar, + cast, overload, ) @@ -17,13 +18,15 @@ from magicgui._util import debounce from magicgui.application import use_app from magicgui.signature import MagicParameter, MagicSignature, magic_signature +from magicgui.types import Undefined, _Undefined from magicgui.widgets.bases._mixins import _OrientationMixin from ._button_widget import ButtonWidget -from ._value_widget import ValueWidget +from ._value_widget import BaseValueWidget from ._widget import Widget WidgetVar = TypeVar("WidgetVar", bound=Widget) +T = TypeVar("T") if TYPE_CHECKING: import inspect @@ -41,31 +44,18 @@ class ContainerKwargs(WidgetKwargs, Generic[WidgetVar], total=False): scrollable: bool labels: bool + class ValuedContainerKwargs(ContainerKwargs[Widget], Generic[T], total=False): + # NOTE: "value" is usually given as a positional argument + bind: T | Callable[[BaseValueWidget], T] + nullable: bool -class ContainerWidget(Widget, _OrientationMixin, MutableSequence[WidgetVar]): + +class BaseContainerWidget(Widget, _OrientationMixin, Sequence[WidgetVar]): """Widget that can contain other widgets. Wraps a widget that implements [`ContainerProtocol`][magicgui.widgets.protocols.ContainerProtocol]. - A `ContainerWidget` behaves like a python list of [Widget][magicgui.widgets.Widget] - objects. Subwidgets can be accessed using integer or slice-based indexing - (`container[0]`), as well as by widget name (`container.`). Widgets can - be added with `append` or `insert`, and removed with `del` or `pop`, etc... - - There is a tight connection between a `ContainerWidget` and an - [inspect.Signature][inspect.Signature] object, - just as there is a tight connection between individual [Widget` objects an - an :class:`inspect.Parameter][inspect.Parameter] object. - The signature representation of a `ContainerWidget` - (with the current settings as default values) is accessible with - the :meth:`~ContainerWidget.__signature__` method (or by using - :func:`inspect.signature` from the standard library) - - For a `ContainerWidget` subclass that is tightly coupled to a specific function - signature (as in the "classic" magicgui decorator), see - [magicgui.widgets.FunctionGui][magicgui.widgets.FunctionGui]. - Parameters ---------- widgets : Sequence[Widget], optional @@ -86,10 +76,6 @@ class ContainerWidget(Widget, _OrientationMixin, MutableSequence[WidgetVar]): [`magicgui.widgets.Widget`][magicgui.widgets.Widget] constructor. """ - changed = Signal( - object, - description="Emitted with `self` when any sub-widget in the container changes.", - ) _widget: protocols.ContainerProtocol _initialized = False # this is janky ... it's here to allow connections during __init__ by @@ -98,8 +84,8 @@ class ContainerWidget(Widget, _OrientationMixin, MutableSequence[WidgetVar]): def __init__( self, - widgets: Sequence[WidgetVar] = (), *, + widgets: Sequence[WidgetVar] = (), layout: str = "vertical", scrollable: bool = False, labels: bool = True, @@ -112,11 +98,15 @@ def __init__( base_widget_kwargs.setdefault("backend_kwargs", {}).update( # type: ignore {"layout": layout, "scrollable": scrollable} ) - super().__init__(**base_widget_kwargs) - self.extend(widgets) + Widget.__init__(self, **base_widget_kwargs) + for index, widget in enumerate(widgets): + self._insert_widget(index, widget) self.native_parent_changed.connect(self.reset_choices) self._initialized = True - self._unify_label_widths() + + def __len__(self) -> int: + """Return the count of widgets.""" + return len(self._list) def __getattr__(self, name: str) -> WidgetVar: """Return attribute ``name``. Will return a widget if present.""" @@ -125,18 +115,6 @@ def __getattr__(self, name: str) -> WidgetVar: return widget return object.__getattribute__(self, name) # type: ignore - def __setattr__(self, name: str, value: Any) -> None: - """Set attribute ``name``. Prevents changing widget if present, (use del).""" - if self._initialized: - for widget in self._list: - if name == widget.name: - raise AttributeError( - "Cannot set attribute with same name as a widget\n" - "If you are trying to change the value of a widget, use: " - f"`{self.__class__.__name__}.{name}.value = {value}`", - ) - object.__setattr__(self, name, value) - @overload def __getitem__(self, key: int | str) -> WidgetVar: ... @@ -156,6 +134,197 @@ def __getitem__( return getattr(item, "_inner_widget", item) raise TypeError(f"list indices must be integers or slices, not {type(key)}") + def reset_choices(self, *_: Any) -> None: + """Reset choices for all Categorical subWidgets to the default state. + + If widget._default_choices is a callable, this may NOT be the exact same set of + choices as when the widget was instantiated, if the callable relies on external + state. + """ + for widget in self._list: + if hasattr(widget, "reset_choices"): + widget.reset_choices() + + @property + def labels(self) -> bool: + """Whether widgets are presented with labels.""" + return self._labels + + @labels.setter + def labels(self, value: bool) -> None: + if value == self._labels: + return + self._labels = value + + for index, _ in enumerate(self._list): + widgt = self._list[index] + self._pop_widget(index) + self._insert_widget(index, widgt) + + @property + def layout(self) -> str: + """Return the layout of the widget.""" + return self._layout + + @layout.setter + def layout(self, value: str) -> NoReturn: + raise NotImplementedError( + "It is not yet possible to change layout after instantiation" + ) + + @property + def margins(self) -> tuple[int, int, int, int]: + """Return margin between the content and edges of the container.""" + return self._widget._mgui_get_margins() + + @margins.setter + def margins(self, margins: tuple[int, int, int, int]) -> None: + # left, top, right, bottom + self._widget._mgui_set_margins(margins) + + def _unify_label_widths(self) -> None: + if not self._initialized: + return + + need_labels = [w for w in self._list if not isinstance(w, ButtonWidget)] + if self.layout == "vertical" and self.labels and need_labels: + measure = use_app().get_obj("get_text_width") + widest_label = max(measure(w.label) for w in need_labels) + for w in self._list: + labeled_widget = w._labeled_widget() + if labeled_widget: + labeled_widget.label_width = widest_label + + def _insert_widget(self, index: int, widget: WidgetVar) -> None: + """Insert widget at the given index.""" + _widget = widget + + if self.labels: + from magicgui.widgets._concrete import _LabeledWidget + + # no labels for button widgets (push buttons, checkboxes, have their own) + if not isinstance(widget, (_LabeledWidget, ButtonWidget)): + _widget = _LabeledWidget(widget) # type: ignore + widget.label_changed.connect(self._unify_label_widths) + + if index < 0: + index += len(self) + self._list.insert(index, widget) + # NOTE: if someone has manually mucked around with self.native.layout() + # it's possible that indices will be off. + self._widget._mgui_insert_widget(index, _widget) + self._unify_label_widths() + + def _pop_widget(self, index: int) -> WidgetVar: + """Remove a widget instance and return it.""" + item = self._list[index] + ref = getattr(item, "_labeled_widget_ref", None) + if ref: + item = item._labeled_widget_ref() # type: ignore + self._widget._mgui_remove_widget(item) + del self._list[index] + return item + + +class ValuedContainerWidget( + BaseContainerWidget[Widget], BaseValueWidget[T], Generic[T] +): + """Container-type ValueWidget.""" + + _widget: protocols.ContainerProtocol + + def __init__( + self, + value: T | _Undefined = Undefined, + *, + bind: T | Callable[[BaseValueWidget], T] | _Undefined = Undefined, + nullable: bool = False, + widgets: Sequence[Widget] = (), + layout: str = "vertical", + scrollable: bool = False, + labels: bool = True, + **base_widget_kwargs: Unpack[WidgetKwargs], + ) -> None: + self._nullable = nullable + self._bound_value = bind + self._call_bound: bool = True + app = use_app() + assert app.native + base_widget_kwargs["widget_type"] = app.get_obj("Container") + BaseContainerWidget.__init__( + self, + widgets=widgets, + layout=layout, + scrollable=scrollable, + labels=labels, + **base_widget_kwargs, + ) + if value is not Undefined: + self.value = cast(T, value) + if self._bound_value is not Undefined and "visible" not in base_widget_kwargs: + self.hide() + + +class ContainerWidget(BaseContainerWidget[WidgetVar], MutableSequence[WidgetVar]): + """Container widget that can insert/remove child widgets. + + A `ContainerWidget` behaves like a python list of [Widget][magicgui.widgets.Widget] + objects. Subwidgets can be accessed using integer or slice-based indexing + (`container[0]`), as well as by widget name (`container.`). Widgets can + be added with `append` or `insert`, and removed with `del` or `pop`, etc... + + There is a tight connection between a `ContainerWidget` and an + [inspect.Signature][inspect.Signature] object, + just as there is a tight connection between individual [Widget` objects an + an :class:`inspect.Parameter][inspect.Parameter] object. + The signature representation of a `ContainerWidget` + (with the current settings as default values) is accessible with + the :meth:`~ContainerWidget.__signature__` method (or by using + :func:`inspect.signature` from the standard library) + + For a `ContainerWidget` subclass that is tightly coupled to a specific function + signature (as in the "classic" magicgui decorator), see + [magicgui.widgets.FunctionGui][magicgui.widgets.FunctionGui]. + """ + + _widget: protocols.ContainerProtocol + changed = Signal( + object, + description="Emitted with `self` when any sub-widget in the container changes.", + ) + + def __init__( + self, + widgets: Sequence[WidgetVar] = (), + *, + layout: str = "vertical", + scrollable: bool = False, + labels: bool = True, + **base_widget_kwargs: Unpack[WidgetKwargs], + ) -> None: + super().__init__( + widgets=widgets, + layout=layout, + scrollable=scrollable, + labels=labels, + **base_widget_kwargs, + ) + for widget in self._list: + if isinstance(widget, (BaseValueWidget, BaseContainerWidget)): + widget.changed.connect(lambda: self.changed.emit(self)) + + def __setattr__(self, name: str, value: Any) -> None: + """Set attribute ``name``. Prevents changing widget if present, (use del).""" + if self._initialized: + for widget in self._list: + if name == widget.name: + raise AttributeError( + "Cannot set attribute with same name as a widget\n" + "If you are trying to change the value of a widget, use: " + f"`{self.__class__.__name__}.{name}.value = {value}`", + ) + object.__setattr__(self, name, value) + def index(self, value: Any, start: int = 0, stop: int = 9223372036854775807) -> int: """Return index of a specific widget instance (or widget name).""" if isinstance(value, str): @@ -188,10 +357,6 @@ def __delitem__(self, key: int | slice) -> None: raise TypeError(f"list indices must be integers or slices, not {type(key)}") del self._list[key] - def __len__(self) -> int: - """Return the count of widgets.""" - return len(self._list) - def __setitem__(self, key: Any, value: Any) -> NoReturn: """Prevent assignment by index.""" raise RuntimeError("magicgui.Container does not support item setting.") @@ -204,70 +369,9 @@ def __dir__(self) -> list[str]: def insert(self, key: int, widget: WidgetVar) -> None: """Insert widget at ``key``.""" - if isinstance(widget, (ValueWidget, ContainerWidget)): + if isinstance(widget, (BaseValueWidget, BaseContainerWidget)): widget.changed.connect(lambda: self.changed.emit(self)) - _widget = widget - - if self.labels: - from magicgui.widgets._concrete import _LabeledWidget - - # no labels for button widgets (push buttons, checkboxes, have their own) - if not isinstance(widget, (_LabeledWidget, ButtonWidget)): - _widget = _LabeledWidget(widget) # type: ignore - widget.label_changed.connect(self._unify_label_widths) - - if key < 0: - key += len(self) - self._list.insert(key, widget) - # NOTE: if someone has manually mucked around with self.native.layout() - # it's possible that indices will be off. - self._widget._mgui_insert_widget(key, _widget) - self._unify_label_widths() - - def _unify_label_widths(self) -> None: - if not self._initialized: - return - - need_labels = [w for w in self._list if not isinstance(w, ButtonWidget)] - if self.layout == "vertical" and self.labels and need_labels: - measure = use_app().get_obj("get_text_width") - widest_label = max(measure(w.label) for w in need_labels) - for w in self: - labeled_widget = w._labeled_widget() - if labeled_widget: - labeled_widget.label_width = widest_label - - @property - def margins(self) -> tuple[int, int, int, int]: - """Return margin between the content and edges of the container.""" - return self._widget._mgui_get_margins() - - @margins.setter - def margins(self, margins: tuple[int, int, int, int]) -> None: - # left, top, right, bottom - self._widget._mgui_set_margins(margins) - - @property - def layout(self) -> str: - """Return the layout of the widget.""" - return self._layout - - @layout.setter - def layout(self, value: str) -> NoReturn: - raise NotImplementedError( - "It is not yet possible to change layout after instantiation" - ) - - def reset_choices(self, *_: Any) -> None: - """Reset choices for all Categorical subWidgets to the default state. - - If widget._default_choices is a callable, this may NOT be the exact same set of - choices as when the widget was instantiated, if the callable relies on external - state. - """ - for widget in self._list: - if hasattr(widget, "reset_choices"): - widget.reset_choices() + self._insert_widget(key, widget) @property def __signature__(self) -> MagicSignature: @@ -313,21 +417,6 @@ def __repr__(self) -> str: """Return a repr.""" return f"" - @property - def labels(self) -> bool: - """Whether widgets are presented with labels.""" - return self._labels - - @labels.setter - def labels(self, value: bool) -> None: - if value == self._labels: - return - self._labels = value - - for index, _ in enumerate(self): - widget = self.pop(index) - self.insert(index, widget) - NO_VALUE = "NO_VALUE" def asdict(self) -> dict[str, Any]: @@ -351,7 +440,7 @@ def update( getattr(self, key).value = value for key, value in kwargs.items(): getattr(self, key).value = value - self.changed.emit() + self.changed.emit(self) @debounce def _dump(self, path: str | Path) -> None: diff --git a/src/magicgui/widgets/bases/_ranged_widget.py b/src/magicgui/widgets/bases/_ranged_widget.py index 821148587..2e419d267 100644 --- a/src/magicgui/widgets/bases/_ranged_widget.py +++ b/src/magicgui/widgets/bases/_ranged_widget.py @@ -8,7 +8,7 @@ from magicgui.types import Undefined, _Undefined -from ._value_widget import ValueWidget +from ._value_widget import BaseValueWidget, ValueWidget if TYPE_CHECKING: from typing_extensions import Unpack @@ -38,7 +38,7 @@ class RangedWidget(ValueWidget[T]): The step size for incrementing the value, by default adaptive step is used bind : Callable[[ValueWidget], Any] | Any, optional A value or callback to bind this widget. If provided, whenever - [`widget.value`][magicgui.widgets.bases.ValueWidget.value] is + [`widget.value`][magicgui.widgets.bases.BaseValueWidget.value] is accessed, the value provided here will be returned instead. `bind` may be a callable, in which case `bind(self)` will be returned (i.e. your bound callback must accept a single parameter, which is this widget instance). @@ -58,7 +58,7 @@ def __init__( min: float | _Undefined = Undefined, max: float | _Undefined = Undefined, step: float | _Undefined | None = Undefined, - bind: T | Callable[[ValueWidget], T] | _Undefined = Undefined, + bind: T | Callable[[BaseValueWidget], T] | _Undefined = Undefined, nullable: bool = False, **base_widget_kwargs: Unpack[WidgetKwargs], ) -> None: @@ -76,7 +76,7 @@ def __init__( self.step = cast(float, step) self.min, self.max = self._init_range(value, min, max) - if value not in (Undefined, None): + if value is not None and not isinstance(value, _Undefined): self.value = value def _init_range( @@ -118,8 +118,7 @@ def options(self) -> dict: d.update({"min": self.min, "max": self.max, "step": self.step}) return d - @ValueWidget.value.setter # type: ignore - def value(self, value: T) -> None: + def set_value(self, value: T) -> None: """Set widget value, will raise Value error if not within min/max.""" val: tuple[float, ...] = value if isinstance(value, tuple) else (value,) if any(float(v) < self.min or float(v) > self.max for v in val): @@ -127,7 +126,7 @@ def value(self, value: T) -> None: f"value {value} is outside of the allowed range: " f"({self.min}, {self.max})" ) - ValueWidget.value.fset(self, value) # type: ignore + super().set_value(value) @property def min(self) -> float: @@ -210,7 +209,7 @@ class TransformedRangedWidget(RangedWidget[float], ABC): The step size for incrementing the value, by default 1 bind : Callable[[ValueWidget], Any] | Any, optional A value or callback to bind this widget. If provided, whenever - [`widget.value`][magicgui.widgets.bases.ValueWidget.value] is + [`widget.value`][magicgui.widgets.bases.BaseValueWidget.value] is accessed, the value provided here will be returned instead. `bind` may be a callable, in which case `bind(self)` will be returned (i.e. your bound callback must accept a single parameter, which is this widget instance). @@ -225,14 +224,14 @@ class TransformedRangedWidget(RangedWidget[float], ABC): def __init__( self, - value: T | _Undefined = Undefined, + value: float | _Undefined = Undefined, *, min: float = 0, max: float = 100, min_pos: int = 0, max_pos: int = 100, step: int = 1, - bind: T | Callable[[ValueWidget], T] | _Undefined = Undefined, + bind: float | Callable[[BaseValueWidget], float] | _Undefined = Undefined, nullable: bool = False, **base_widget_kwargs: Unpack[WidgetKwargs], ) -> None: @@ -240,8 +239,12 @@ def __init__( self._max = max self._min_pos = min_pos self._max_pos = max_pos - ValueWidget.__init__( # type: ignore - self, value=value, bind=bind, nullable=nullable, **base_widget_kwargs + ValueWidget.__init__( + self, # type: ignore + value=value, # type: ignore + bind=bind, # type: ignore + nullable=nullable, + **base_widget_kwargs, ) self._widget._mgui_set_min(self._min_pos) @@ -300,11 +303,10 @@ def max(self, value: float) -> None: self.value = prev -class MultiValueRangedWidget(RangedWidget[T]): +class MultiValueRangedWidget(RangedWidget[tuple[Union[int, float], ...]]): """Widget with a constrained *iterable* value, like a tuple.""" - @ValueWidget.value.setter # type: ignore - def value(self, value: tuple[float, ...]) -> None: + def set_value(self, value: tuple[float, ...]) -> None: """Set widget value, will raise Value error if not within min/max.""" if not isinstance(value, Iterable): raise ValueError( @@ -319,4 +321,4 @@ def value(self, value: tuple[float, ...]) -> None: f"value {v} is outside of the allowed range: " f"({self.min}, {self.max})" ) - ValueWidget.value.fset(self, value) # type: ignore + super().set_value(value) diff --git a/src/magicgui/widgets/bases/_slider_widget.py b/src/magicgui/widgets/bases/_slider_widget.py index cf8cc1721..4118be688 100644 --- a/src/magicgui/widgets/bases/_slider_widget.py +++ b/src/magicgui/widgets/bases/_slider_widget.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Callable, Union +from typing import TYPE_CHECKING, Callable from magicgui.types import Undefined, _Undefined @@ -40,7 +40,7 @@ class SliderWidget(RangedWidget[T], _OrientationMixin): the slider. bind : Callable[[ValueWidget], Any] | Any, optional A value or callback to bind this widget. If provided, whenever - [`widget.value`][magicgui.widgets.bases.ValueWidget.value] is + [`widget.value`][magicgui.widgets.bases.BaseValueWidget.value] is accessed, the value provided here will be returned instead. `bind` may be a callable, in which case `bind(self)` will be returned (i.e. your bound callback must accept a single parameter, which is this widget instance). @@ -118,9 +118,7 @@ def readout(self, value: bool) -> None: self._widget._mgui_set_readout_visibility(value) -class MultiValuedSliderWidget( - MultiValueRangedWidget[tuple[Union[int, float], ...]], SliderWidget -): +class MultiValuedSliderWidget(MultiValueRangedWidget, SliderWidget): """Slider widget that expects a iterable value.""" _widget: protocols.SliderWidgetProtocol diff --git a/src/magicgui/widgets/bases/_value_widget.py b/src/magicgui/widgets/bases/_value_widget.py index 5ce3c0545..b143ae87e 100644 --- a/src/magicgui/widgets/bases/_value_widget.py +++ b/src/magicgui/widgets/bases/_value_widget.py @@ -1,5 +1,6 @@ from __future__ import annotations +from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any, Callable, Generic, TypeVar, Union, cast from psygnal import Signal @@ -19,8 +20,10 @@ T = TypeVar("T") -class ValueWidget(Widget, Generic[T]): - """Widget with a value, Wraps ValueWidgetProtocol. +class BaseValueWidget(Widget, ABC, Generic[T]): + """An abstract base class for widgets that have a value. + + Subclasses must implement the `get_value` and `set_value` methods. Parameters ---------- @@ -28,7 +31,7 @@ class ValueWidget(Widget, Generic[T]): The starting value for the widget. bind : Callable[[ValueWidget], Any] | Any, optional A value or callback to bind this widget. If provided, whenever - [`widget.value`][magicgui.widgets.bases.ValueWidget.value] is + [`widget.value`][magicgui.widgets.bases.BaseValueWidget.value] is accessed, the value provided here will be returned instead. `bind` may be a callable, in which case `bind(self)` will be returned (i.e. your bound callback must accept a single parameter, which is this widget instance). @@ -39,7 +42,6 @@ class ValueWidget(Widget, Generic[T]): [`magicgui.widgets.Widget`][magicgui.widgets.Widget] constructor. """ - _widget: protocols.ValueWidgetProtocol changed = Signal(object, description="Emitted when the widget value changes.") null_value: Any = None @@ -47,7 +49,7 @@ def __init__( self, value: T | _Undefined = Undefined, *, - bind: T | Callable[[ValueWidget], T] | _Undefined = Undefined, + bind: T | Callable[[BaseValueWidget], T] | _Undefined = Undefined, nullable: bool = False, **base_widget_kwargs: Unpack[WidgetKwargs], ) -> None: @@ -60,16 +62,13 @@ def __init__( if self._bound_value is not Undefined and "visible" not in base_widget_kwargs: self.hide() - def _post_init(self) -> None: - super()._post_init() - self._widget._mgui_bind_change_callback(self._on_value_change) - def _on_value_change(self, value: T | None = None) -> None: """Called when the widget value changes.""" if value is self.null_value and not self._nullable: return self.changed.emit(value) + @abstractmethod def get_value(self) -> T: """Callable version of `self.value`. @@ -77,7 +76,10 @@ def get_value(self) -> T: an escape hatch if trying to access the widget's value inside of a callback bound to self._bound_value. """ - return cast(T, self._widget._mgui_get_value()) + + @abstractmethod + def set_value(self, value: Any) -> None: + """Normalize and set the value of the widget.""" @property def value(self) -> T: @@ -98,7 +100,7 @@ def value(self) -> T: @value.setter def value(self, value: T) -> None: - return self._widget._mgui_set_value(value) + return self.set_value(value) def __repr__(self) -> str: """Return representation of widget of instance.""" @@ -111,7 +113,11 @@ def __repr__(self) -> str: except AttributeError: # pragma: no cover return f"" - def bind(self, value: T | Callable[[ValueWidget], T], call: bool = True) -> None: + def bind( + self, + value: T | Callable[[BaseValueWidget], T], + call: bool = True, + ) -> None: """Binds ``value`` to self.value. If a value is bound to this widget, then whenever `widget.value` is accessed, @@ -159,3 +165,42 @@ def annotation(self) -> Any: @annotation.setter def annotation(self, value: Any) -> None: Widget.annotation.fset(self, value) # type: ignore + + +class ValueWidget(BaseValueWidget[T]): + """Widget with a value, Wraps ValueWidgetProtocol. + + Parameters + ---------- + value : Any, optional + The starting value for the widget. + bind : Callable[[ValueWidget], Any] | Any, optional + A value or callback to bind this widget. If provided, whenever + [`widget.value`][magicgui.widgets.bases.BaseValueWidget.value] is + accessed, the value provided here will be returned instead. `bind` may be a + callable, in which case `bind(self)` will be returned (i.e. your bound callback + must accept a single parameter, which is this widget instance). + nullable : bool, optional + If `True`, the widget will accepts `None` as a valid value, by default `False`. + **base_widget_kwargs : Any + All additional keyword arguments are passed to the base + [`magicgui.widgets.Widget`][magicgui.widgets.Widget] constructor. + """ + + _widget: protocols.ValueWidgetProtocol + + def _post_init(self) -> None: + super()._post_init() + self._widget._mgui_bind_change_callback(self._on_value_change) + + def get_value(self) -> T: + """Callable version of `self.value`. + + The main API is to use `self.value`, however, this is here in order to provide + an escape hatch if trying to access the widget's value inside of a callback + bound to self._bound_value. + """ + return cast(T, self._widget._mgui_get_value()) + + def set_value(self, value: Any) -> None: + self._widget._mgui_set_value(value) diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 938bc0f0e..6ee26603b 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -9,7 +9,7 @@ from magicgui import magicgui, types, use_app, widgets from magicgui.widgets import Container, request_values -from magicgui.widgets.bases import DialogWidget, ValueWidget +from magicgui.widgets.bases import BaseValueWidget, DialogWidget from tests import MyInt @@ -165,7 +165,7 @@ def test_custom_widget(): # widget with a widgets._bases.ValueWidget with pytest.warns(UserWarning, match="must accept a `parent` Argument"): wdg = widgets.create_widget(1, widget_type=MyValueWidget) # type:ignore - assert isinstance(wdg, ValueWidget) + assert isinstance(wdg, BaseValueWidget) wdg.close() @@ -314,6 +314,20 @@ def f(x: int = 5): assert f() == 5 +def test_bound_values_for_container_like(): + """Test that "bind" works for container-like value widgets.""" + + @magicgui(x={"bind": (1, "a")}) + def f(x: tuple[int, str] = (2, "b")): + return x + + # bound values hide the widget by default + assert not f.x.visible + assert f() == (1, "a") + f.x.unbind() + assert f() == (2, "b") + + def test_bound_unknown_type_annotation(): """Test that we can bind a "permanent" value override to a parameter."""