From d9880586fb663aedc37777cfb99762c065ad0094 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:00:09 +0200 Subject: [PATCH 1/3] feat(dataset): add opt-in automatic numeric sliders Add layout-scoped sliders for bounded numeric items while preserving exact text input and explicitly configured slider behavior. --- doc/examples.rst | 26 +++ guidata/dataset/qtitemwidgets.py | 126 +++++++++++- guidata/dataset/qtwidgets.py | 19 ++ guidata/tests/dataset/test_auto_sliders.py | 190 ++++++++++++++++++ .../tests/dataset/test_edit_notifications.py | 50 +++++ 5 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 guidata/tests/dataset/test_auto_sliders.py create mode 100644 guidata/tests/dataset/test_edit_notifications.py diff --git a/doc/examples.rst b/doc/examples.rst index 8f3be0c..c707714 100644 --- a/doc/examples.rst +++ b/doc/examples.rst @@ -48,6 +48,32 @@ Embedding guidata objects in GUI layouts .. image:: images/screenshots/editgroupbox.png +Local automatic sliders +~~~~~~~~~~~~~~~~~~~~~~~ + +An embedded ``DataSetEditLayout`` can opt into sliders without changing shared +``DataItem`` declarations or other forms:: + + editor = DataSetEditLayout( + parent, parameters, grid, change_callback=parameters_changed, + auto_sliders=True, slider_steps=1000, + ) + +The policy is inherited by nested groups and tabs. Editable numeric items need +finite, ordered, representable bounds; otherwise they remain text-only. Integer +parity is respected, and ranges crossing zero for ``nonzero`` items are not +automatically given sliders. For floats, a usable positive ``step`` is used when +practical, otherwise ``slider_steps`` specifies the normalized resolution. The +text field retains its exact value independently of the slider thumb. + +``slider=True`` retains its existing behavior. Set +``item.set_prop("display", auto_slider=False)`` to exclude an item from the local +automatic policy. No bounds are inferred. The optional layout callback +``slider_callback(pressed)`` receives ``True`` at drag start and ``False`` at +release; value changes still use the existing ``change_callback``. Neither +callback automatically validates, accepts or applies the form. Use +``check_all_values()`` before ``accept_changes()`` when collecting a draft. + Data item groups and group selection ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/guidata/dataset/qtitemwidgets.py b/guidata/dataset/qtitemwidgets.py index ac0d63e..a44c115 100644 --- a/guidata/dataset/qtitemwidgets.py +++ b/guidata/dataset/qtitemwidgets.py @@ -19,6 +19,7 @@ import datetime import inspect +import math import os import os.path as osp import sys @@ -965,7 +966,17 @@ def __init__( ) -> None: super().__init__(item, parent_layout) self.slider = self.vmin = self.vmax = None - if item.get_prop_value("display", "slider"): + self._auto_slider = ( + parent_layout.auto_sliders + and item.get_prop_value("display", "auto_slider", True) + and not item.get_prop_value("display", "slider") + ) + self._slider_step = 1 + self._slider_steps = parent_layout.slider_steps + self._auto_slider_usable = False + if self._auto_slider: + self._refresh_auto_slider() + elif item.get_prop_value("display", "slider"): self.vmin = item.get_prop_value("data", "min") self.vmax = item.get_prop_value("data", "max") assert self.vmin is not None and self.vmax is not None, ( @@ -976,11 +987,110 @@ def __init__( self.setup_slider(item) self.slider.valueChanged.connect(self.value_changed) # type:ignore self.group.addWidget(self.slider) + self._connect_slider_gesture() + + def _connect_slider_gesture(self) -> None: + """Forward gestures without changing the value notification mechanism.""" + callback = self.parent_layout.slider_callback + if callback is not None: + self.slider.sliderPressed.connect(lambda: callback(True)) + self.slider.sliderReleased.connect(lambda: callback(False)) + + def _refresh_auto_slider(self) -> None: + """Resolve a safe local slider range without modifying the data item.""" + lower = self.item.get_prop_value("data", "min") + upper = self.item.get_prop_value("data", "max") + try: + finite_range = ( + isinstance(lower, (int, float)) + and isinstance(upper, (int, float)) + and math.isfinite(lower) + and math.isfinite(upper) + and lower < upper + and math.isfinite(upper - lower) + ) + except OverflowError: + finite_range = False + usable = ( + not self.is_readonly() + and not isinstance( + self.item.get_prop("data", "computed", None), ComputedProp + ) + and finite_range + ) + if usable and self.item.get_prop_value("data", "nonzero", False): + usable = not lower <= 0 <= upper + if usable and self.DATA_TYPE is int: + usable = ( + isinstance(lower, int) + and isinstance(upper, int) + and -(2**31) <= lower < upper < 2**31 + and upper - lower < 2**31 + ) + parity = self.item.get_prop_value("data", "even", None) + self._slider_step = 1 if parity is None else 2 + if usable and parity is not None: + remainder = 0 if parity else 1 + lower += (remainder - lower) % 2 + upper -= (upper - remainder) % 2 + usable = lower < upper + if usable: + self.vmin, self.vmax = lower, upper + if self.DATA_TYPE is float: + step = self.item.get_prop_value("data", "step", None) + count = (upper - lower) / step if step and step > 0 else math.inf + self._slider_steps = ( + math.ceil(count) + if math.isfinite(count) and 1 <= count <= 100000 + else self.parent_layout.slider_steps + ) + self._slider_step = ( + step + if math.isfinite(count) and 1 <= count <= 100000 + else (upper - lower) / self._slider_steps + ) + usable = ( + self._slider_step > 0 + and lower + self._slider_step > lower + and upper - self._slider_step < upper + ) + else: + self._slider_steps = (upper - lower) // self._slider_step + self._auto_slider_usable = usable + if usable: + if self.slider is None: + self.slider = QSlider(Qt.Horizontal) + self.slider.valueChanged.connect(self.value_changed) + self.group.addWidget(self.slider) + self._connect_slider_gesture() + self.slider.blockSignals(True) + self.slider.setRange(0, min(self._slider_steps, 2**31 - 1)) + self.slider.blockSignals(False) + if self.slider is not None: + self.slider.setVisible(usable) + self.slider.setEnabled(usable and bool(self.is_active())) + + def get(self) -> None: + """Refresh bounds as well as the numeric value.""" + if self._auto_slider: + self._refresh_auto_slider() + super().get() def value_to_slider(self, value): + if self._auto_slider: + if value <= self.vmin: + return 0 + if value >= self.vmax: + return self._slider_steps + return max( + 0, + min(self._slider_steps, round((value - self.vmin) / self._slider_step)), + ) return value def slider_to_value(self, value): + if self._auto_slider: + return min(self.vmax, self.vmin + value * self._slider_step) return value def setup_slider(self, item): @@ -989,7 +1099,12 @@ def setup_slider(self, item): def update(self, value): """Reimplement LineEditWidget method""" LineEditWidget.update(self, value) - if self.slider is not None and isinstance(value, self.DATA_TYPE): + if ( + self.slider is not None + and (not self._auto_slider or self._auto_slider_usable) + and isinstance(value, self.DATA_TYPE) + and math.isfinite(value) + ): self.slider.blockSignals(True) self.slider.setValue(self.value_to_slider(value)) self.slider.blockSignals(False) @@ -1004,6 +1119,9 @@ def set_state(self): """Update the visual status of the widget and enables/disables it if necessary""" super().set_state() + if self._auto_slider: + self._refresh_auto_slider() + return if self.slider is not None: if self.is_readonly(): # Widget does not support readonly mode, disable it self.slider.setDisabled(True) @@ -1017,9 +1135,13 @@ class FloatSliderWidget(SliderWidget): DATA_TYPE: type = float def value_to_slider(self, value): + if self._auto_slider: + return super().value_to_slider(value) return int((value - self.vmin) * 100 / (self.vmax - self.vmin)) def slider_to_value(self, value): + if self._auto_slider: + return float(super().slider_to_value(value)) return value * (self.vmax - self.vmin) / 100 + self.vmin def setup_slider(self, item): diff --git a/guidata/dataset/qtwidgets.py b/guidata/dataset/qtwidgets.py index fc3f5fd..21344d6 100644 --- a/guidata/dataset/qtwidgets.py +++ b/guidata/dataset/qtwidgets.py @@ -301,6 +301,9 @@ class DataSetEditLayout(Generic[AnyDataSet]): first_line: first line of grid layout change_callback: function called when any widget's value has changed group_widget: group widget associated with this layout, if any + auto_sliders: add sliders to editable bounded numeric items (default False) + slider_callback: optional callback receiving True/False at drag start/end + slider_steps: normalized float slider resolution when no usable step exists """ _widget_factory: dict[Any, Any] = {} @@ -324,6 +327,9 @@ def __init__( first_line: int = 0, change_callback: Callable | None = None, group_widget: GroupWidget | None = None, + auto_sliders: bool = False, + slider_callback: Callable[[bool], None] | None = None, + slider_steps: int = 1000, ) -> None: self.parent = parent self.instance = instance @@ -331,6 +337,19 @@ def __init__( self.first_line = first_line self.change_callback = change_callback self.group_widget = group_widget + self.auto_sliders = ( + group_widget.parent_layout.auto_sliders if group_widget else auto_sliders + ) + self.slider_callback = ( + group_widget.parent_layout.slider_callback + if group_widget + else slider_callback + ) + self.slider_steps = ( + group_widget.parent_layout.slider_steps if group_widget else slider_steps + ) + if not isinstance(self.slider_steps, int) or not 1 <= self.slider_steps < 2**31: + raise ValueError("slider_steps must be a positive Qt-representable integer") self.widgets: list[AbstractDataSetWidget] = [] # self.linenos = {} # prochaine ligne à remplir par colonne self.items_pos: dict[DataItem, list[int]] = {} diff --git a/guidata/tests/dataset/test_auto_sliders.py b/guidata/tests/dataset/test_auto_sliders.py new file mode 100644 index 0000000..627ae9f --- /dev/null +++ b/guidata/tests/dataset/test_auto_sliders.py @@ -0,0 +1,190 @@ +"""Local automatic sliders preserve exact values and parameter declarations.""" + +from __future__ import annotations + +import pytest +from qtpy.QtWidgets import QGridLayout, QWidget + +import guidata.dataset as gds +from guidata.dataset.qtwidgets import DataSetEditLayout +from guidata.qthelpers import qt_app_context + + +class Parameters(gds.DataSet): + """Bounded and unbounded parameters.""" + + tabs = gds.BeginTabGroup("Tabs") + general = gds.BeginGroup("General") + value = gds.FloatItem("Value", default=0.123456789, min=0.0, max=1.0, step=0.03) + odd = gds.IntItem("Odd", default=3, min=0, max=10, even=False) + unbounded = gds.FloatItem("Unbounded", default=1.0, min=0.0) + fixed = gds.FloatItem("Fixed", default=1.0, min=1.0, max=1.0) + opt_out = gds.IntItem("Opt out", default=2, min=0, max=10).set_prop( + "display", auto_slider=False + ) + end_general = gds.EndGroup("General") + end_tabs = gds.EndTabGroup("Tabs") + + +def test_auto_sliders_are_local_and_preserve_precision(): + """The numeric field remains authoritative and declarations are untouched.""" + with qt_app_context(): + parent = QWidget() + param = Parameters() + editor = DataSetEditLayout( + parent, param, QGridLayout(parent), auto_sliders=True + ) + fields = { + widget.item.item.get_name(): widget + for widget in editor.get_terminal_widgets() + } + assert fields["value"].slider is not None + assert fields["odd"].slider is not None + for name in ("unbounded", "fixed", "opt_out"): + assert fields[name].slider is None + assert param.value == 0.123456789 + fields["value"].edit.setText("0.987654321") + editor.accept_changes() + assert param.value == 0.987654321 + fields["value"].slider.setValue(fields["value"].slider.maximum()) + assert float(fields["value"].edit.text()) == 1.0 + for position in range(fields["odd"].slider.maximum() + 1): + fields["odd"].slider.setValue(position) + assert int(fields["odd"].edit.text()) % 2 == 1 + other_parent = QWidget() + other = DataSetEditLayout(other_parent, Parameters(), QGridLayout(other_parent)) + assert all( + getattr(widget, "slider", None) is None + for widget in other.get_terminal_widgets() + ) + assert not Parameters.value.get_prop("display", "slider") + parent.close() + other_parent.close() + + +def test_gestures_and_refresh_do_not_change_values(): + """Gestures are forwarded once and programmatic refresh is quiet.""" + with qt_app_context(): + parent = QWidget() + gestures = [] + changes = [] + param = Parameters() + editor = DataSetEditLayout( + parent, + param, + QGridLayout(parent), + auto_sliders=True, + change_callback=lambda: changes.append(True), + slider_callback=gestures.append, + ) + field = next( + widget + for widget in editor.get_terminal_widgets() + if widget.item.item.get_name() == "value" + ) + changes.clear() + field.slider.sliderPressed.emit() + field.slider.sliderReleased.emit() + assert gestures == [True, False] + assert not changes + field.edit.setText("-") + assert not editor.check_all_values() + assert param.value == 0.123456789 + assert changes + parent.close() + + +@pytest.mark.parametrize( + "bounds", + [ + (None, 10), + (0, None), + (1, 1), + (2, 1), + (0, float("inf")), + (float("nan"), 1), + (0, 2**40), + (0, 10**400), + ], +) +def test_unusable_bounds_do_not_create_auto_slider(bounds): + """Unusable ranges retain a text editor without a construction error.""" + + class Unusable(gds.DataSet): + value = gds.IntItem( + "Value", default=1, min=bounds[0], max=bounds[1], check=False + ) + + with qt_app_context(): + parent = QWidget() + editor = DataSetEditLayout( + parent, Unusable(), QGridLayout(parent), auto_sliders=True + ) + assert editor.widgets[0].slider is None + parent.close() + + +def test_dynamic_bounds_and_extreme_values(): + """Resolve presentation bounds independently of literal-bound validation.""" + + class Dynamic(gds.DataSet): + lower = -1e308 + upper = -9e307 + locked = False + value = ( + gds.FloatItem("Value", default=None, allow_none=True, step=0, check=False) + .set_prop( + "data", min=gds.GetAttrProp("lower"), max=gds.GetAttrProp("upper") + ) + .set_prop("display", readonly=gds.GetAttrProp("locked")) + ) + + with qt_app_context(): + parent = QWidget() + param = Dynamic() + editor = DataSetEditLayout( + parent, param, QGridLayout(parent), auto_sliders=True, slider_steps=200 + ) + field = editor.widgets[0] + assert param.value is None + assert field.slider.maximum() == 200 + field.edit.setText("1e308") + assert field.edit.text() == "1e308" + assert field.slider.value() == field.slider.maximum() + param.lower, param.upper = 0.0, 10.0 + param.value = 2.1256789 + field.build_mode = True + field.get() + field.build_mode = False + assert field.slider.value() == 43 + assert float(field.edit.text()) == param.value + param.locked = True + field.set_state() + assert field.slider.isHidden() + param.locked = False + field.set_state() + assert not field.slider.isHidden() + param.upper = None + field.get() + assert field.slider.isHidden() + assert float(field.edit.text()) == param.value + parent.close() + + +def test_authored_sliders_and_nonzero_fallback(): + """Legacy sliders retain their resolution; unsafe auto ranges stay text-only.""" + + class Constrained(gds.DataSet): + legacy = gds.FloatItem("Legacy", default=0.25, min=0.0, max=1.0, slider=True) + nonzero = gds.FloatItem("Nonzero", default=1.0, min=-1.0, max=1.0, nonzero=True) + tiny = gds.FloatItem("Tiny", default=0.0, min=0.0, max=5e-324, step=0) + + with qt_app_context(): + parent = QWidget() + editor = DataSetEditLayout( + parent, Constrained(), QGridLayout(parent), auto_sliders=True + ) + assert editor.widgets[0].slider.maximum() == 100 + assert editor.widgets[1].slider is None + assert editor.widgets[2].slider is None + parent.close() diff --git a/guidata/tests/dataset/test_edit_notifications.py b/guidata/tests/dataset/test_edit_notifications.py new file mode 100644 index 0000000..fe48007 --- /dev/null +++ b/guidata/tests/dataset/test_edit_notifications.py @@ -0,0 +1,50 @@ +"""Regression tests for embedded dataset editing and validation.""" + +from __future__ import annotations + +from qtpy.QtWidgets import QGridLayout, QWidget + +import guidata.dataset as gds +from guidata.dataset.qtwidgets import DataSetEditLayout +from guidata.qthelpers import qt_app_context + + +class TabbedParameters(gds.DataSet): + """Parameters nested inside a tab.""" + + tabs = gds.BeginTabGroup("Tabs") + general = gds.BeginGroup("General") + value = gds.FloatItem("Value", default=1.0, min=0.0, max=10.0) + ignored = gds.FloatItem("Ignored", default=1.0).set_prop("display", active=False) + end_general = gds.EndGroup("General") + end_tabs = gds.EndTabGroup("Tabs") + + +def test_nested_notifications_and_validation(): + """Invalid tab fields notify the owner but cannot be accepted silently.""" + with qt_app_context(): + parent = QWidget() + changes = [] + param = TabbedParameters() + editor = DataSetEditLayout( + parent, + param, + QGridLayout(parent), + change_callback=lambda: changes.append(1), + ) + fields = { + widget.item.item.get_name(): widget + for widget in editor.get_terminal_widgets() + } + changes.clear() + fields["value"].edit.setText("-") + assert changes + assert not editor.check_all_values() + assert param.value == 1.0 + fields["value"].edit.setText("2.5") + fields["ignored"].edit.setText("invalid") + assert editor.check_all_values() + editor.accept_changes() + assert param.value == 2.5 + assert param.ignored == 1.0 + parent.close() From 37e47d1a14ad3952a565a1b42b1196efab122218 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:32:24 +0200 Subject: [PATCH 2/3] feat(dataset): add histogram-backed range editor --- doc/examples.rst | 40 ++ doc/reference/dataset/dataitems.rst | 16 + guidata/dataset/__init__.py | 1 + guidata/dataset/conv.py | 6 +- guidata/dataset/dataitems.py | 91 +++ guidata/dataset/jsonschema.py | 26 +- guidata/dataset/qtitemwidgets.py | 594 +++++++++++++++++- guidata/dataset/qtwidgets.py | 5 + guidata/locale/fr/LC_MESSAGES/guidata.po | 17 + .../dataset/test_histogram_range_item.py | 492 +++++++++++++++ guidata/tests/dataset/test_jsonschema.py | 69 ++ guidata/tests/dataset/test_loadsave_json.py | 25 + 12 files changed, 1376 insertions(+), 6 deletions(-) create mode 100644 guidata/tests/dataset/test_histogram_range_item.py diff --git a/doc/examples.rst b/doc/examples.rst index c707714..c75a469 100644 --- a/doc/examples.rst +++ b/doc/examples.rst @@ -74,6 +74,46 @@ release; value changes still use the existing ``change_callback``. Neither callback automatically validates, accepts or applies the form. Use ``check_all_values()`` before ``accept_changes()`` when collecting a draft. +Histogram-backed interval selection +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``HistogramRangeItem`` edits two distinct ``FloatItem`` fields in the same dataset. +Its default presentation is a generic interval, suitable for measurements such as +durations. The caller supplies histogram counts and range proposals; guidata does +not inspect source arrays or choose an automatic range. + +The following tested dataset has bounded durations in seconds and a live dependent +field. Import ``guidata.dataset as gds`` before using it: + +.. literalinclude:: ../guidata/tests/dataset/test_histogram_range_item.py + :pyobject: DurationRange + +Call ``DurationRange().edit()`` to display it. The hidden numeric fields retain +their constraints, including ``nonzero`` and the ``check=False`` opt-out. A window +outside those constraints stays visible but cannot be accepted. The histogram's +``domain`` is only a drawing/slider domain, not an additional numeric constraint. +If either linked field is read-only or inactive, the entire composite is disabled. + +The histogram payload is transient: only the linked numeric values are serialized. +After loading a dataset, restore its histogram context before enabling editing. +An empty context leaves the saved bounds visible and the controls disabled. +Auto and Reset appear only when the caller supplies finite ordered proposals. + +The callback receives ``(instance, item, payload)`` once per valid changed pair. +Both bounds are already on the working dataset, and dependent fields are refreshed. +Without callbacks or computed fields, edits remain local until acceptance. With +live dependencies, guidata updates the working dataset before acceptance, just as +for ordinary items. Applications requiring transactional Cancel must edit a copy; +the dialog cannot undo arbitrary callback side effects. + +For image applications, explicitly select the brightness/contrast presentation:: + + DurationRange.histogram.set_prop("display", presentation="brightness_contrast") + +This adds brightness/contrast controls and a linear transfer overlay. It changes +only rendering metadata, never the stored parameters. The JSON Schema exporter +emits ``x-guidata-histogram-presentation`` so portable renderers use the same mode. + Data item groups and group selection ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/doc/reference/dataset/dataitems.rst b/doc/reference/dataset/dataitems.rst index b48a526..723bfe1 100644 --- a/doc/reference/dataset/dataitems.rst +++ b/doc/reference/dataset/dataitems.rst @@ -1,3 +1,19 @@ :tocdepth: 3 .. automodule:: guidata.dataset.dataitems + +Histogram range context +----------------------- + +``HistogramRangeItem`` consumes a plain mapping with uniformly spaced histogram +``counts``, a finite ordered ``domain`` pair, and an ``active`` boolean. Optional +``bin_edges`` has one more entry than ``counts``; adjacent edges may coincide after +float rounding. Optional ``y_max`` and ``minimum_width`` must be positive and finite. +Optional ``auto_range`` and ``reset_range`` are finite ordered pairs. Additional +keys are preserved for the caller. An empty mapping represents unavailable context. + +The default ``display.presentation`` is ``"range"``. The only specialization is +``"brightness_contrast"``; unknown modes and links to anything other than two +distinct ``FloatItem`` fields fail explicitly when constructing/exporting the form. +See :ref:`examples` for a complete, tested duration-selection example and the live +callback/Cancel contract. diff --git a/guidata/dataset/__init__.py b/guidata/dataset/__init__.py index 57f40c9..4230ae7 100644 --- a/guidata/dataset/__init__.py +++ b/guidata/dataset/__init__.py @@ -36,6 +36,7 @@ FloatArrayItem, FloatItem, FontFamilyItem, + HistogramRangeItem, ImageChoiceItem, IntItem, LabeledEnum, diff --git a/guidata/dataset/conv.py b/guidata/dataset/conv.py index 6220e52..881556b 100644 --- a/guidata/dataset/conv.py +++ b/guidata/dataset/conv.py @@ -396,11 +396,13 @@ def _resolve_dataset_class( def _dataset_items(dataset_class: type[gdt.DataSet]) -> dict[str, gdt.DataItem]: - """Return persisted DataItems, excluding structural and callback items.""" + """Return persisted DataItems, excluding structural and transient items.""" return { item.get_name(): item for item in dataset_class._items - if item.get_name() and not isinstance(item, _NON_SERIALIZED_ITEM_TYPES) + if item.get_name() + and not isinstance(item, _NON_SERIALIZED_ITEM_TYPES) + and not item.get_prop("data", "transient", False) } diff --git a/guidata/dataset/dataitems.py b/guidata/dataset/dataitems.py index 5919c30..a266863 100644 --- a/guidata/dataset/dataitems.py +++ b/guidata/dataset/dataitems.py @@ -86,6 +86,9 @@ .. autoclass:: guidata.dataset.DictItem :members: +.. autoclass:: guidata.dataset.HistogramRangeItem + :members: + .. autoclass:: guidata.dataset.FontFamilyItem :members: @@ -1531,6 +1534,94 @@ def get_value_from_reader(self, reader): return reader.read_dict() +class HistogramRangeItem(DataItem): + """Histogram-backed editor for two distinct FloatItem fields in one dataset. + + The item value is a JSON-compatible rendering payload. It is deliberately + transient: only the linked minimum and maximum fields belong to the + persistent dataset state. + + Set ``display.presentation`` to ``"brightness_contrast"`` to enable the + brightness/contrast controls and linear transfer display. The default + ``"range"`` presentation only selects an interval. Linked fields should be + hidden from the ordinary form; their validation and readonly properties + still apply. The histogram domain does not constrain their values. + + The payload describes uniformly spaced bins: ``counts`` and optional + ``bin_edges`` (one more edge than counts), a finite ordered ``domain``, and + optional positive ``y_max`` and ``minimum_width``. Rounded physical edges + may coincide for very narrow domains. ``active`` enables editing; + ``auto_range`` and ``reset_range`` optionally supply button targets. + An empty payload disables editing until context is provided. Extra keys + are allowed, but no image objects or computation callbacks belong here. + + A ``display.callback(instance, item, value)`` receives the unchanged payload; + both edited bounds are available on the working instance. Like other + guidata live callbacks, it updates the working dataset before acceptance. + Hosts needing transactional cancellation must edit a copy of their data. + + Args: + label: Item label + minimum: Name of the linked minimum field + maximum: Name of the linked maximum field + default: Initial rendering payload + help: Text shown in the tooltip + """ + + type: type[dict[str, Any]] = dict + + def __init__( + self, + label: str, + minimum: str, + maximum: str, + default: dict[str, Any] | None = None, + help: str = "", + ) -> None: + super().__init__(label, default=default or {}, help=help, check=False) + self.set_prop( + "data", + transient=True, + minimum_field=minimum, + maximum_field=maximum, + ) + self.set_prop("display", presentation="range") + + def get_presentation(self) -> str: + """Return the validated portable presentation name.""" + presentation = self.get_prop("display", "presentation", "range") + if presentation not in ("range", "brightness_contrast"): + raise ValueError(f"Unknown histogram presentation: {presentation!r}") + return presentation + + def get_range_items(self, instance: DataSet) -> tuple[FloatItem, FloatItem]: + """Resolve and validate the two linked fields without changing them.""" + minimum = self.get_prop("data", "minimum_field") + maximum = self.get_prop("data", "maximum_field") + items = {item.get_name(): item for item in instance.get_items()} + if minimum == maximum or any( + not isinstance(items.get(name), FloatItem) for name in (minimum, maximum) + ): + raise ValueError( + "HistogramRangeItem requires two distinct FloatItem fields" + ) + return items[minimum], items[maximum] + + def serialize( + self, + instance: DataSet, + writer: HDF5Writer | JSONWriter | INIWriter, + ) -> None: + """Skip the renderer payload when persisting the dataset.""" + + def deserialize( + self, + instance: DataSet, + reader: HDF5Reader | JSONReader | INIReader, + ) -> None: + """Keep the current renderer payload when loading persisted values.""" + + class ButtonItem(DataItem): """Construct a simple button that calls a method when hit diff --git a/guidata/dataset/jsonschema.py b/guidata/dataset/jsonschema.py index ab77bb4..adbc505 100644 --- a/guidata/dataset/jsonschema.py +++ b/guidata/dataset/jsonschema.py @@ -70,7 +70,7 @@ ``int``, ``float``, ``bool``, ``string``, ``text``, ``choice``, ``multiple_choice``, ``image_choice``, ``color``, ``date``, ``datetime``, -``file``, ``float_array``, ``dict``. +``file``, ``float_array``, ``dict``, ``histogram_range``. Items not supported (raise :class:`NotImplementedError`): ``ButtonItem`` (callbacks cannot cross JSON), and conditional visibility @@ -140,6 +140,9 @@ def dataset_to_schema(dataset_cls: type[gdt.DataSet]) -> dict[str, Any]: # Use a transient instance to obtain the title/comment computed by # ``DataSetMeta`` from the docstring, without paying for it twice. instance = dataset_cls() + for item in instance.get_items(): + if isinstance(item, gdi.HistogramRangeItem): + item.get_range_items(instance) title = instance.get_title() comment = instance.get_comment() @@ -455,7 +458,9 @@ def _item_to_property(item: gdt.DataItem, order: int) -> dict[str, Any]: ) # Dispatch in MRO-friendly order (most specific first). - if isinstance(item, gdi.FloatArrayItem): + if isinstance(item, gdi.HistogramRangeItem): + prop = _histogram_range_to_property(item) + elif isinstance(item, gdi.FloatArrayItem): prop = _float_array_to_property(item) elif isinstance(item, gdi.DictItem): prop = _dict_to_property(item) @@ -514,6 +519,8 @@ def _numeric_to_property(item: gdi.NumericTypeItem, kind: str) -> dict[str, Any] "type": json_type, "x-guidata-kind": kind, } + if item.get_prop("data", "check_value", True) is False: + prop["x-guidata-check-value"] = False minv = item.get_prop("data", "min", None) maxv = item.get_prop("data", "max", None) if minv is not None: @@ -687,6 +694,21 @@ def _dict_to_property(item: gdi.DictItem) -> dict[str, Any]: } +def _histogram_range_to_property( + item: gdi.HistogramRangeItem, +) -> dict[str, Any]: + """Return the portable contract for a histogram-backed range editor.""" + return { + "type": "object", + "additionalProperties": True, + "x-guidata-kind": "histogram_range", + "x-guidata-transient": True, + "x-guidata-minimum-field": item.get_prop("data", "minimum_field"), + "x-guidata-maximum-field": item.get_prop("data", "maximum_field"), + "x-guidata-histogram-presentation": item.get_presentation(), + } + + # --------------------------------------------------------------------------- # Common per-property keys and helpers # --------------------------------------------------------------------------- diff --git a/guidata/dataset/qtitemwidgets.py b/guidata/dataset/qtitemwidgets.py index a44c115..d364e54 100644 --- a/guidata/dataset/qtitemwidgets.py +++ b/guidata/dataset/qtitemwidgets.py @@ -29,8 +29,8 @@ import numpy as np from qtpy.compat import getexistingdirectory -from qtpy.QtCore import QSize, Qt -from qtpy.QtGui import QColor, QIcon, QPixmap +from qtpy.QtCore import QPointF, QSignalBlocker, QSize, Qt +from qtpy.QtGui import QColor, QIcon, QPainter, QPen, QPixmap from qtpy.QtWidgets import ( QAbstractButton, QCheckBox, @@ -1744,6 +1744,596 @@ def clicked(self, *args) -> None: self.notify_value_change() +def _range_fraction(value: float, lower: float, upper: float) -> float: + """Return the position of a finite value in a finite range.""" + value = min(max(value, lower), upper) + width = upper - lower + offset = value - lower + if math.isfinite(width) and math.isfinite(offset): + return offset / width + scale = max(abs(value), abs(lower), abs(upper)) + if scale == 0.0: + return 0.0 + return (value / scale - lower / scale) / (upper / scale - lower / scale) + + +def _range_value(lower: float, upper: float, fraction: float) -> float: + """Interpolate within a finite range without overflowing its width.""" + width = upper - lower + if math.isfinite(width): + value = lower + fraction * width + else: + scale = max(abs(lower), abs(upper)) + value = (lower / scale + fraction * (upper / scale - lower / scale)) * scale + return min(max(value, lower), upper) + + +def _half_span(lower: float, upper: float) -> float: + """Return half a finite range width without overflowing.""" + width = upper - lower + return 0.5 * width if math.isfinite(width) else 0.5 * upper - 0.5 * lower + + +def _bounded_add(value: float, offset: float) -> float: + """Add finite values and clamp overflow to the finite float range.""" + result = value + offset + if math.isfinite(result): + return result + return math.copysign(sys.float_info.max, offset) + + +def _width_ratio( + lower: float, upper: float, reference_lower: float, reference_upper: float +) -> float: + """Return the ratio of two finite range widths without overflowing.""" + width = upper - lower + reference_width = reference_upper - reference_lower + if math.isfinite(width) and math.isfinite(reference_width): + scale = max(width, reference_width) + scaled_reference = reference_width / scale + if scaled_reference == 0.0: + return math.inf + return (width / scale) / scaled_reference + scale = max(abs(lower), abs(upper), abs(reference_lower), abs(reference_upper)) + width = upper / scale - lower / scale + reference_width = reference_upper / scale - reference_lower / scale + if reference_width == 0.0: + return math.inf + return width / reference_width + + +def _finite_range(value: Any) -> tuple[float, float] | None: + """Return a finite ordered payload pair, or None for unavailable context.""" + if ( + isinstance(value, (list, tuple)) + and len(value) == 2 + and all( + isinstance(bound, (int, float)) and math.isfinite(bound) for bound in value + ) + and value[0] < value[1] + ): + return float(value[0]), float(value[1]) + return None + + +class _HistogramCanvas(QWidget): + """Draw a fixed histogram and its selected range or transfer function.""" + + def __init__(self, presentation: str) -> None: + super().__init__() + self.presentation = presentation + self.payload: dict[str, Any] = {} + self.minimum = 0.0 + self.maximum = 1.0 + self.setMinimumHeight(96) + + def set_data(self, payload: dict[str, Any], minimum: float, maximum: float) -> None: + """Set display data and schedule a repaint.""" + self.payload = payload + self.minimum = minimum + self.maximum = maximum + self.update() + + def paintEvent(self, event) -> None: # noqa: N802 + """Paint the histogram and transfer function.""" + del event + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + bounds = self.rect().adjusted(1, 1, -1, -1) + painter.fillRect(bounds, self.palette().base()) + painter.setPen(QPen(self.palette().mid().color(), 1)) + painter.drawRect(bounds) + + counts = self.payload.get("counts", []) + domain = self.payload.get("domain", [0.0, 1.0]) + if len(domain) != 2 or not domain[0] < domain[1]: + painter.end() + return + domain_min, domain_max = map(float, domain) + y_max = float(self.payload.get("y_max", max(counts, default=1)) or 1) + if counts: + bar_width = bounds.width() / len(counts) + painter.setPen(QPen(self.palette().mid().color(), 1)) + for index, count in enumerate(counts): + height = min(max(float(count) / y_max, 0.0), 1.0) * bounds.height() + x = bounds.left() + (index + 0.5) * bar_width + painter.drawLine( + QPointF(x, bounds.bottom()), QPointF(x, bounds.bottom() - height) + ) + + if self.minimum < self.maximum and self.presentation == "range": + left = ( + bounds.left() + + _range_fraction(self.minimum, domain_min, domain_max) * bounds.width() + ) + right = ( + bounds.left() + + _range_fraction(self.maximum, domain_min, domain_max) * bounds.width() + ) + color = self.palette().highlight().color() + color.setAlpha(45) + painter.fillRect( + int(left), + bounds.top(), + max(1, int(right - left)), + bounds.height(), + color, + ) + painter.setPen(QPen(self.palette().highlight().color(), 2)) + for position in (left, right): + painter.drawLine( + QPointF(position, bounds.top()), QPointF(position, bounds.bottom()) + ) + elif self.minimum < self.maximum: + points = [] + for x_value in (domain_min, self.minimum, self.maximum, domain_max): + clipped_x = min(max(x_value, domain_min), domain_max) + fraction_x = _range_fraction(clipped_x, domain_min, domain_max) + fraction_y = min( + max(_range_fraction(clipped_x, self.minimum, self.maximum), 0.0), + 1.0, + ) + points.append( + QPointF( + bounds.left() + fraction_x * bounds.width(), + bounds.bottom() - fraction_y * bounds.height(), + ) + ) + painter.setPen(QPen(self.palette().highlight().color(), 2)) + for start, end in zip(points, points[1:]): + painter.drawLine(start, end) + painter.end() + + +class HistogramRangeWidget(AbstractDataSetWidget): + """Edit two linked floats through a histogram-backed range display.""" + + _RANGE_STEPS = 1000 + _PERCENT_STEPS = 100 + + def __init__( + self, item: "DataItemVariable", parent_layout: "DataSetEditLayout" + ) -> None: + super().__init__(item, parent_layout) + self.range_items = item.item.get_range_items(item.instance) + self.presentation = item.item.get_presentation() + self._minimum = 0.0 + self._maximum = 1.0 + self.group = QWidget() + layout = QVBoxLayout(self.group) + layout.setContentsMargins(0, 0, 0, 0) + self.canvas = _HistogramCanvas(self.presentation) + self.canvas.setToolTip(item.get_help()) + layout.addWidget(QLabel(item.get_prop_value("display", "label"))) + layout.addWidget(self.canvas) + self.minimum_slider, self.minimum_edit = self._add_range_control( + layout, _("Minimum") + ) + self.maximum_slider, self.maximum_edit = self._add_range_control( + layout, _("Maximum") + ) + self.brightness_slider, self.brightness_value = self._add_percent_control( + layout, _("Brightness") + ) + self.contrast_slider, self.contrast_value = self._add_percent_control( + layout, _("Contrast") + ) + button_layout = QHBoxLayout() + self.auto_button = QPushButton(_("Auto")) + self.reset_button = QPushButton(_("Reset")) + button_layout.addWidget(self.auto_button) + button_layout.addWidget(self.reset_button) + button_layout.addStretch(1) + layout.addLayout(button_layout) + + self.minimum_slider.valueChanged.connect(self._minimum_slider_changed) + self.maximum_slider.valueChanged.connect(self._maximum_slider_changed) + self.brightness_slider.valueChanged.connect(self._brightness_changed) + self.contrast_slider.valueChanged.connect(self._contrast_changed) + self.minimum_edit.editingFinished.connect(self._minimum_edited) + self.maximum_edit.editingFinished.connect(self._maximum_edited) + self.auto_button.clicked.connect(lambda: self._apply_named_range("auto_range")) + self.reset_button.clicked.connect( + lambda: self._apply_named_range("reset_range") + ) + for slider in ( + self.minimum_slider, + self.maximum_slider, + self.brightness_slider, + self.contrast_slider, + ): + self._connect_slider_gesture(slider) + + def _add_range_control( + self, parent: QVBoxLayout, label: str + ) -> tuple[QSlider, QLineEdit]: + row = QHBoxLayout() + linked = self.range_items[0 if label == _("Minimum") else 1] + unit = linked.get_prop_value("display", self.item.instance, "unit", "") + row.addWidget(QLabel(f"{label} ({unit})" if unit else label)) + slider = QSlider(Qt.Horizontal) + slider.setRange(0, self._RANGE_STEPS) + row.addWidget(slider, 1) + edit = QLineEdit() + edit.setMaximumWidth(100) + row.addWidget(edit) + parent.addLayout(row) + return slider, edit + + def _add_percent_control( + self, parent: QVBoxLayout, label: str + ) -> tuple[QSlider, QLabel]: + row = QHBoxLayout() + row.addWidget(QLabel(label)) + slider = QSlider(Qt.Horizontal) + slider.setRange(0, self._PERCENT_STEPS) + row.addWidget(slider, 1) + value = QLabel() + value.setMinimumWidth(28) + value.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + row.addWidget(value) + parent.addLayout(row) + if self.presentation != "brightness_contrast": + for index in range(row.count()): + row.itemAt(index).widget().hide() + return slider, value + + def _connect_slider_gesture(self, slider: QSlider) -> None: + callback = self.parent_layout.slider_callback + if callback is not None: + slider.sliderPressed.connect(lambda: callback(True)) + slider.sliderReleased.connect(lambda: callback(False)) + + def _payload(self) -> dict[str, Any]: + payload = self.item.get() + if ( + not isinstance(payload, dict) + or _finite_range(payload.get("domain")) is None + ): + return {} + counts = payload.get("counts", []) + if not isinstance(counts, (list, tuple)) or not all( + isinstance(count, (int, float)) and math.isfinite(count) and count >= 0 + for count in counts + ): + return {} + for name in ("minimum_width", "y_max"): + value = payload.get(name, 1.0) + if ( + not isinstance(value, (int, float)) + or not math.isfinite(value) + or value <= 0 + ): + return {} + return payload + + def is_active(self) -> bool: + """Include linked fields in the composite's editability contract.""" + return super().is_active() and all( + item.get_prop_value("display", self.item.instance, "active", True) + for item in self.range_items + ) + + def is_readonly(self) -> bool: + """Never write readonly or computed linked fields.""" + return ( + self.READ_ONLY + or super().is_readonly() + or any( + item.get_prop_value("display", self.item.instance, "readonly", False) + or isinstance(item.get_prop("data", "computed", None), ComputedProp) + for item in self.range_items + ) + ) + + def _can_edit(self) -> bool: + return ( + self._payload().get("active") is True + and self.is_active() + and not self.is_readonly() + ) + + def _field_names(self) -> tuple[str, str]: + return ( + self.item.item.get_prop("data", "minimum_field"), + self.item.item.get_prop("data", "maximum_field"), + ) + + def _range(self) -> tuple[float, float]: + return self._minimum, self._maximum + + def _domain(self) -> tuple[float, float]: + domain = self._payload().get("domain", [0.0, 1.0]) + return float(domain[0]), float(domain[1]) + + def _minimum_width(self) -> float: + payload = self._payload() + width = float(payload.get("minimum_width", 0.0)) + domain_min, domain_max = self._domain() + if not math.isfinite(width) or width <= 0: + width = max(math.ulp(domain_min), math.ulp(domain_max)) + return width + + def _increase(self, value: float) -> float | None: + candidate = value + self._minimum_width() + if math.isfinite(candidate) and candidate > value: + return candidate + candidate = math.nextafter(value, math.inf) + return candidate if math.isfinite(candidate) else None + + def _decrease(self, value: float) -> float | None: + candidate = value - self._minimum_width() + if math.isfinite(candidate) and candidate < value: + return candidate + candidate = math.nextafter(value, -math.inf) + return candidate if math.isfinite(candidate) else None + + def _set_range(self, minimum: float, maximum: float) -> None: + if self.build_mode or not self._can_edit(): + self._sync_controls() + return + if not math.isfinite(minimum) or not math.isfinite(maximum): + self._sync_controls() + return + minimum_width = self._minimum_width() + width = maximum - minimum + if maximum <= minimum or (math.isfinite(width) and width < minimum_width): + expanded_maximum = self._increase(minimum) + if expanded_maximum is None: + self._sync_controls() + return + maximum = expanded_maximum + if (minimum, maximum) == self._range(): + self._sync_controls() + return + self._minimum = minimum + self._maximum = maximum + self._sync_controls() + if self.check(): + _display_callback(self, self.value()) + if ( + self.contains_computed_items() + or self.item.get_prop_value("display", "callback", None) is not None + ): + self.get() + self.notify_value_change() + + def _minimum_slider_changed(self, position: int) -> None: + if self.build_mode: + return + domain_min, domain_max = self._domain() + minimum = _range_value(domain_min, domain_max, position / self._RANGE_STEPS) + _, maximum = self._range() + if maximum <= minimum: + maximum = self._increase(minimum) + if maximum is None: + self._sync_controls() + return + self._set_range(minimum, maximum) + + def _maximum_slider_changed(self, position: int) -> None: + if self.build_mode: + return + domain_min, domain_max = self._domain() + maximum = _range_value(domain_min, domain_max, position / self._RANGE_STEPS) + minimum, _ = self._range() + if minimum >= maximum: + minimum = self._decrease(maximum) + if minimum is None: + self._sync_controls() + return + self._set_range(minimum, maximum) + + def _brightness_changed(self, position: int) -> None: + if self.build_mode: + return + domain_min, domain_max = self._domain() + minimum, maximum = self._range() + center = _range_value( + domain_min, domain_max, 1.0 - position / self._PERCENT_STEPS + ) + half_width = _half_span(minimum, maximum) + self._set_range( + _bounded_add(center, -half_width), + _bounded_add(center, half_width), + ) + + def _contrast_changed(self, position: int) -> None: + if self.build_mode: + return + domain_min, domain_max = self._domain() + minimum, maximum = self._range() + center = 0.5 * minimum + 0.5 * maximum + domain_half_width = _half_span(domain_min, domain_max) + if position == 0: + half_width = sys.float_info.max + elif position <= 50: + half_width = domain_half_width * (50.0 / position) + elif position >= self._PERCENT_STEPS: + half_width = 0.5 * self._minimum_width() + else: + half_width = domain_half_width * ((self._PERCENT_STEPS - position) / 50.0) + self._set_range( + _bounded_add(center, -half_width), + _bounded_add(center, half_width), + ) + + def _minimum_edited(self) -> None: + try: + minimum = float(self.minimum_edit.text()) + except ValueError: + self._sync_controls() + return + _, maximum = self._range() + if maximum <= minimum: + maximum = self._increase(minimum) + if maximum is None: + self._sync_controls() + return + self._set_range(minimum, maximum) + + def _maximum_edited(self) -> None: + try: + maximum = float(self.maximum_edit.text()) + except ValueError: + self._sync_controls() + return + minimum, _ = self._range() + if minimum >= maximum: + minimum = self._decrease(maximum) + if minimum is None: + self._sync_controls() + return + self._set_range(minimum, maximum) + + def _apply_named_range(self, name: str) -> None: + values = _finite_range(self._payload().get(name)) + if values is not None: + self._set_range(*values) + + def _sync_controls(self) -> None: + minimum, maximum = self._range() + domain_min, domain_max = self._domain() + center = 0.5 * minimum + 0.5 * maximum + brightness = round( + self._PERCENT_STEPS + * (1.0 - _range_fraction(center, domain_min, domain_max)) + ) + width_ratio = _width_ratio(minimum, maximum, domain_min, domain_max) + if width_ratio <= 0.0: + contrast = float(self._PERCENT_STEPS) + elif width_ratio < 1.0: + contrast = self._PERCENT_STEPS - 50.0 * width_ratio + else: + contrast = 50.0 / width_ratio + minimum_position = round( + self._RANGE_STEPS * _range_fraction(minimum, domain_min, domain_max) + ) + maximum_position = round( + self._RANGE_STEPS * _range_fraction(maximum, domain_min, domain_max) + ) + controls = ( + (self.minimum_slider, minimum_position), + (self.maximum_slider, maximum_position), + (self.brightness_slider, round(brightness)), + (self.contrast_slider, round(contrast)), + ) + for control, value in controls: + with QSignalBlocker(control): + control.setValue(min(max(value, control.minimum()), control.maximum())) + self.minimum_edit.setText(f"{minimum:.17g}") + self.maximum_edit.setText(f"{maximum:.17g}") + style = ( + _get_readonly_stylesheet() + if self.check() + else "background-color:rgb(255, 175, 90);" + ) + self.minimum_edit.setStyleSheet(style) + self.maximum_edit.setStyleSheet(style) + self.brightness_value.setText(str(min(max(round(brightness), 0), 100))) + self.contrast_value.setText(str(min(max(round(contrast), 0), 100))) + self.canvas.set_data(self._payload(), minimum, maximum) + self.auto_button.setVisible( + _finite_range(self._payload().get("auto_range")) is not None + ) + self.reset_button.setVisible( + _finite_range(self._payload().get("reset_range")) is not None + ) + + def get(self) -> None: + """Refresh controls from the linked fields and transient payload.""" + previous_mode = self.build_mode + self.build_mode = True + try: + minimum_field, maximum_field = self._field_names() + self._minimum = float(getattr(self.item.instance, minimum_field)) + self._maximum = float(getattr(self.item.instance, maximum_field)) + self._sync_controls() + self.set_state() + finally: + self.build_mode = previous_mode + + def set(self) -> None: + """Commit both linked fields atomically.""" + if not self._can_edit() or not self.check(): + return + minimum_field, maximum_field = self._field_names() + setattr(self.item.instance, minimum_field, self._minimum) + setattr(self.item.instance, maximum_field, self._maximum) + + def value(self) -> dict[str, Any]: + """Return the unchanged transient rendering payload.""" + return self._payload() + + def check(self) -> bool: + """Validate both linked fields, including those hidden from the form.""" + if not self.is_active() or self.is_readonly(): + return True + minimum, maximum = self._range() + return ( + math.isfinite(minimum) + and math.isfinite(maximum) + and minimum < maximum + and self.range_items[0].check_value(minimum) + and self.range_items[1].check_value(maximum) + ) + + def place_on_grid( + self, + layout: "QGridLayout", + row: int, + label_column: int, + widget_column: int, + row_span: int = 1, + column_span: int = 1, + ) -> None: + """Span the full form width.""" + del widget_column + layout.addWidget(self.group, row, label_column, row_span, column_span + 1) + + def set_state(self) -> None: + """Disable all controls when the payload reports no usable data.""" + self.group.setEnabled(self._can_edit()) + + +class HistogramRangeShowWidget(HistogramRangeWidget): + """Read-only histogram-backed range renderer.""" + + READ_ONLY = True + + def get(self) -> None: + """Refresh the renderer and keep every control disabled.""" + super().get() + self.group.setEnabled(False) + + def set(self) -> None: + """Do not commit values from a read-only renderer.""" + + def set_state(self) -> None: + """Keep the complete renderer disabled.""" + self.group.setEnabled(False) + + class DataSetWidget(AbstractDataSetWidget): """ DataSet widget diff --git a/guidata/dataset/qtwidgets.py b/guidata/dataset/qtwidgets.py index 21344d6..873a823 100644 --- a/guidata/dataset/qtwidgets.py +++ b/guidata/dataset/qtwidgets.py @@ -570,6 +570,7 @@ def get_terminal_widgets(self) -> list[AbstractDataSetWidget]: FilesOpenItem, FloatArrayItem, FloatItem, + HistogramRangeItem, ImageChoiceItem, IntItem, MultipleChoiceItem, @@ -591,6 +592,8 @@ def get_terminal_widgets(self) -> list[AbstractDataSetWidget]: FloatArrayWidget, FloatSliderWidget, GroupWidget, + HistogramRangeShowWidget, + HistogramRangeWidget, LineEditWidget, MultipleChoiceWidget, SeparatorWidget, @@ -624,6 +627,7 @@ def get_terminal_widgets(self) -> list[AbstractDataSetWidget]: DataSetEditLayout.register(ImageChoiceItem, ChoiceWidget) DataSetEditLayout.register(MultipleChoiceItem, MultipleChoiceWidget) DataSetEditLayout.register(FloatArrayItem, FloatArrayWidget) +DataSetEditLayout.register(HistogramRangeItem, HistogramRangeWidget) DataSetEditLayout.register(ButtonItem, ButtonWidget) DataSetEditLayout.register(DictItem, ButtonWidget) DataSetEditLayout.register(SeparatorItem, SeparatorWidget) @@ -801,6 +805,7 @@ def layout_factory(self, instance: DataSet, grid: QGridLayout) -> DataSetShowLay DataSetShowLayout.register(ImageChoiceItem, DataSetShowWidget) DataSetShowLayout.register(MultipleChoiceItem, DataSetShowWidget) DataSetShowLayout.register(FloatArrayItem, DataSetShowWidget) +DataSetShowLayout.register(HistogramRangeItem, HistogramRangeShowWidget) DataSetShowLayout.register(DictItem, DataSetShowWidget) DataSetShowLayout.register(SeparatorItem, SeparatorWidget) diff --git a/guidata/locale/fr/LC_MESSAGES/guidata.po b/guidata/locale/fr/LC_MESSAGES/guidata.po index 9f0e792..9dfd14b 100644 --- a/guidata/locale/fr/LC_MESSAGES/guidata.po +++ b/guidata/locale/fr/LC_MESSAGES/guidata.po @@ -772,3 +772,20 @@ msgstr "Instance :" msgid "Text editor" msgstr "Éditeur de texte" +msgid "Minimum" +msgstr "Minimum" + +msgid "Maximum" +msgstr "Maximum" + +msgid "Brightness" +msgstr "Luminosité" + +msgid "Contrast" +msgstr "Contraste" + +msgid "Auto" +msgstr "Auto" + +msgid "Reset" +msgstr "Réinitialiser" diff --git a/guidata/tests/dataset/test_histogram_range_item.py b/guidata/tests/dataset/test_histogram_range_item.py new file mode 100644 index 0000000..aa7bd01 --- /dev/null +++ b/guidata/tests/dataset/test_histogram_range_item.py @@ -0,0 +1,492 @@ +"""Tests for the portable histogram-backed range editor.""" + +from __future__ import annotations + +import math + +import pytest +from qtpy.QtCore import Qt +from qtpy.QtTest import QTest +from qtpy.QtWidgets import QGridLayout, QWidget + +import guidata.dataset as gds +from guidata.dataset.qtitemwidgets import HistogramRangeWidget +from guidata.dataset.qtwidgets import DataSetEditLayout, DataSetShowLayout +from guidata.qthelpers import qt_app_context + + +class Parameters(gds.DataSet): + """Range parameters with a transient histogram editor.""" + + minimum = gds.FloatItem("Minimum", default=0.0).set_prop("display", hide=True) + maximum = gds.FloatItem("Maximum", default=255.0).set_prop("display", hide=True) + histogram = gds.HistogramRangeItem( + "Brightness and contrast", "minimum", "maximum" + ).set_prop("display", presentation="brightness_contrast") + + +class GroupedParameters(gds.DataSet): + """Range parameters nested in a visual group.""" + + _begin = gds.BeginGroup("Range") + minimum = gds.FloatItem("Minimum", default=0.0).set_prop("display", hide=True) + maximum = gds.FloatItem("Maximum", default=255.0).set_prop("display", hide=True) + histogram = gds.HistogramRangeItem( + "Brightness and contrast", "minimum", "maximum" + ).set_prop("display", presentation="brightness_contrast") + _end = gds.EndGroup("Range") + + +def make_parameters() -> Parameters: + """Return parameters populated with a deterministic display payload.""" + param = Parameters() + param.histogram = { + "counts": [1, 4, 2, 1], + "bin_edges": [0.0, 63.75, 127.5, 191.25, 255.0], + "domain": [0.0, 255.0], + "y_max": 4, + "minimum_width": 1.0, + "reset_range": [0.0, 255.0], + "auto_range": [10.0, 240.0], + "active": True, + } + return param + + +def test_histogram_range_widget_commits_linked_fields_atomically(): + """Composite controls keep a draft until the layout accepts changes.""" + with qt_app_context(): + parent = QWidget() + changes = [] + gestures = [] + param = make_parameters() + editor = DataSetEditLayout( + parent, + param, + QGridLayout(parent), + change_callback=lambda: changes.append(True), + slider_callback=gestures.append, + ) + widget = editor.widgets[0] + assert isinstance(widget, HistogramRangeWidget) + + widget.minimum_slider.setValue(100) + assert param.minimum == 0.0 + assert param.maximum == 255.0 + assert widget.minimum_edit.text() == "25.5" + assert changes + + widget.maximum_edit.setText("20") + widget.maximum_edit.editingFinished.emit() + assert (param.minimum, param.maximum) == (0.0, 255.0) + assert widget.minimum_edit.text() == "19" + assert widget.maximum_edit.text() == "20" + + widget.auto_button.click() + assert (widget.minimum_edit.text(), widget.maximum_edit.text()) == ( + "10", + "240", + ) + widget.reset_button.click() + assert (widget.minimum_edit.text(), widget.maximum_edit.text()) == ( + "0", + "255", + ) + + widget.contrast_slider.setValue(25) + assert (widget.minimum_edit.text(), widget.maximum_edit.text()) == ( + "-127.5", + "382.5", + ) + widget.contrast_slider.setValue(100) + editor.accept_changes() + assert param.maximum - param.minimum == 1.0 + widget.minimum_slider.sliderPressed.emit() + widget.minimum_slider.sliderReleased.emit() + assert gestures == [True, False] + parent.close() + + +def test_histogram_range_widget_discard_and_read_only_semantics(): + """Unaccepted edits stay local and show layouts cannot edit the range.""" + with qt_app_context(): + edit_parent = QWidget() + param = make_parameters() + editor = DataSetEditLayout(edit_parent, param, QGridLayout(edit_parent)) + widget = editor.widgets[0] + assert isinstance(widget, HistogramRangeWidget) + widget.minimum_slider.setValue(100) + assert (param.minimum, param.maximum) == (0.0, 255.0) + edit_parent.close() + + show_parent = QWidget() + shown = make_parameters() + show_layout = DataSetShowLayout(show_parent, shown, QGridLayout(show_parent)) + show_widget = show_layout.widgets[0] + assert isinstance(show_widget, HistogramRangeWidget) + assert not show_widget.group.isEnabled() + show_widget.minimum_slider.setValue(100) + show_layout.accept_changes() + assert (shown.minimum, shown.maximum) == (0.0, 255.0) + show_parent.close() + + readonly_parent = QWidget() + readonly = make_parameters() + readonly.set_readonly() + readonly_layout = DataSetEditLayout( + readonly_parent, readonly, QGridLayout(readonly_parent) + ) + readonly_widget = readonly_layout.widgets[0] + assert isinstance(readonly_widget, HistogramRangeWidget) + assert not readonly_widget.group.isEnabled() + readonly_parent.close() + + +def test_histogram_range_widget_is_read_only_inside_show_group(): + """Show layouts propagate their inert renderer through visual groups.""" + with qt_app_context(): + parent = QWidget() + param = GroupedParameters() + param.histogram = make_parameters().histogram + layout = DataSetShowLayout(parent, param, QGridLayout(parent)) + group = layout.widgets[0] + widget = group.edit.widgets[0] + assert isinstance(widget, HistogramRangeWidget) + assert not widget.group.isEnabled() + widget.minimum_slider.setValue(100) + layout.accept_changes() + assert (param.minimum, param.maximum) == (0.0, 255.0) + parent.close() + + +def test_histogram_range_widget_honors_inactive_payload(): + """A constant or empty source disables the complete editor.""" + with qt_app_context(): + parent = QWidget() + param = make_parameters() + param.histogram["active"] = False + editor = DataSetEditLayout(parent, param, QGridLayout(parent)) + widget = editor.widgets[0] + assert isinstance(widget, HistogramRangeWidget) + assert not widget.group.isEnabled() + parent.close() + + +def test_histogram_range_widget_requires_rendering_context(): + """Persisted bounds remain visible while a missing payload disables editing.""" + with qt_app_context(): + parent = QWidget() + param = Parameters() + param.minimum = 10.0 + param.maximum = 20.0 + editor = DataSetEditLayout(parent, param, QGridLayout(parent)) + widget = editor.widgets[0] + assert isinstance(widget, HistogramRangeWidget) + assert widget.minimum_edit.text() == "10" + assert widget.maximum_edit.text() == "20" + assert not widget.group.isEnabled() + parent.close() + + +def test_histogram_range_widget_handles_extreme_float_domain(): + """All controls retain a finite ordered range at IEEE-754 extremes.""" + with qt_app_context(): + parent = QWidget() + param = make_parameters() + param.minimum = -1e308 + param.maximum = 1e308 + param.histogram.update( + domain=[-1e308, 1e308], + minimum_width=2e292, + reset_range=[-1e308, 1e308], + auto_range=[-5e307, 5e307], + ) + editor = DataSetEditLayout(parent, param, QGridLayout(parent)) + widget = editor.widgets[0] + assert isinstance(widget, HistogramRangeWidget) + + for control, position in ( + (widget.minimum_slider, 250), + (widget.maximum_slider, 750), + (widget.brightness_slider, 25), + (widget.contrast_slider, 75), + ): + control.setValue(position) + minimum, maximum = widget._range() + assert math.isfinite(minimum) + assert math.isfinite(maximum) + assert minimum < maximum + parent.close() + + +def test_histogram_range_widget_extreme_contrast_keeps_requested_position(): + """Contrast arithmetic does not overflow before applying its ratio.""" + with qt_app_context(): + parent = QWidget() + param = make_parameters() + param.minimum = -1e308 + param.maximum = 1e308 + param.histogram.update( + domain=[-1e308, 1e308], + minimum_width=2e292, + reset_range=[-1e308, 1e308], + ) + editor = DataSetEditLayout(parent, param, QGridLayout(parent)) + widget = editor.widgets[0] + assert isinstance(widget, HistogramRangeWidget) + + widget.contrast_slider.setValue(75) + + assert widget._range() == (-5e307, 5e307) + assert widget.contrast_slider.value() == 75 + parent.close() + + +def test_histogram_range_widget_zero_contrast_handles_tiny_domain(): + """Maximum widening remains representable for a tiny source domain.""" + with qt_app_context(): + parent = QWidget() + param = make_parameters() + param.minimum = 0.0 + param.maximum = 1e-30 + param.histogram.update( + domain=[0.0, 1e-30], + minimum_width=1e-45, + reset_range=[0.0, 1e-30], + ) + editor = DataSetEditLayout(parent, param, QGridLayout(parent)) + widget = editor.widgets[0] + assert isinstance(widget, HistogramRangeWidget) + + widget.contrast_slider.setValue(0) + + minimum, maximum = widget._range() + assert math.isfinite(minimum) + assert math.isfinite(maximum) + assert minimum < maximum + assert widget.contrast_slider.value() == 0 + widget.reset_button.click() + assert widget._range() == (0.0, 1e-30) + parent.close() + + +def test_histogram_range_widget_text_round_trip_preserves_narrow_range(): + """Accepting an unchanged field keeps both exact float64 bounds.""" + with qt_app_context(): + parent = QWidget() + param = make_parameters() + param.minimum = 1.0 + param.maximum = 1.0000000000005 + param.histogram.update( + domain=[param.minimum, param.maximum], + minimum_width=math.ulp(param.maximum), + reset_range=[param.minimum, param.maximum], + ) + editor = DataSetEditLayout(parent, param, QGridLayout(parent)) + widget = editor.widgets[0] + assert isinstance(widget, HistogramRangeWidget) + original = widget._range() + parent.show() + + assert float(widget.minimum_edit.text()) == original[0] + assert float(widget.maximum_edit.text()) == original[1] + widget.maximum_edit.setFocus() + QTest.keyClick(widget.maximum_edit, Qt.Key_Return) + editor.accept_changes() + + assert (param.minimum, param.maximum) == original + parent.close() + + +class DurationRange(gds.DataSet): + """Select a duration interval from a caller-supplied histogram.""" + + minimum = gds.FloatItem( + "Minimum", default=0.0, min=0.0, max=10.0, unit="s" + ).set_prop("display", hide=True) + maximum = gds.FloatItem( + "Maximum", default=10.0, min=0.0, max=10.0, unit="s" + ).set_prop("display", hide=True) + width = gds.FloatItem("Selected duration", default=10.0, unit="s").set_prop( + "display", readonly=True + ) + + def update_width(self, item, value): + """Refresh the dependent field from the complete edited pair.""" + self.width = self.maximum - self.minimum + + histogram = gds.HistogramRangeItem( + "Durations", + "minimum", + "maximum", + default={ + "counts": [1, 4, 3, 2], + "domain": [0.0, 10.0], + "minimum_width": 0.01, + "active": True, + "auto_range": [2.0, 8.0], + "reset_range": [0.0, 10.0], + }, + ).set_prop("display", callback=update_width) + + +@pytest.mark.parametrize("bounds", [(-5.0, 15.0), (2.0, 15.0)]) +def test_histogram_range_validates_both_hidden_fields(bounds): + """Invalid drafts never partially commit or invoke live callbacks.""" + with qt_app_context(): + parent = QWidget() + param = DurationRange() + layout = DataSetEditLayout(parent, param, QGridLayout(parent)) + widget = next( + item for item in layout.widgets if isinstance(item, HistogramRangeWidget) + ) + widget._set_range(*bounds) + assert not layout.check_all_values() + assert widget._range() == bounds + assert widget.minimum_edit.styleSheet() + layout.accept_changes() + assert (param.minimum, param.maximum, param.width) == (0.0, 10.0, 10.0) + parent.close() + + +def test_histogram_range_generic_presentation_and_live_callback(): + """A generic range updates a visible dependent item without recursion.""" + with qt_app_context(): + parent = QWidget() + param = DurationRange() + layout = DataSetEditLayout(parent, param, QGridLayout(parent)) + widget = next( + item for item in layout.widgets if isinstance(item, HistogramRangeWidget) + ) + assert widget.brightness_slider.isHidden() + assert widget.contrast_slider.isHidden() + assert widget.canvas.presentation == "range" + widget.auto_button.click() + assert (param.minimum, param.maximum, param.width) == (2.0, 8.0, 6.0) + assert layout.widgets[0].edit.text() == "6.0" + parent.close() + + +def test_histogram_range_callback_observes_pair_and_can_revise_it(): + """One callback sees both bounds and its own edits survive acceptance.""" + calls = [] + + def callback(instance, item, value): + calls.append((item.get_name(), instance.minimum, instance.maximum, value)) + instance.maximum = 9.0 + + class CallbackRange(DurationRange): + histogram = gds.HistogramRangeItem("Range", "minimum", "maximum").set_prop( + "display", callback=callback + ) + + with qt_app_context(): + parent = QWidget() + param = CallbackRange() + param.histogram = DurationRange().histogram + layout = DataSetEditLayout(parent, param, QGridLayout(parent)) + widget = next( + item for item in layout.widgets if isinstance(item, HistogramRangeWidget) + ) + assert calls == [] + widget.auto_button.click() + assert calls == [("histogram", 2.0, 8.0, param.histogram)] + assert widget._range() == (2.0, 9.0) + widget.get() + layout.accept_changes() + assert len(calls) == 1 + assert param.maximum == 9.0 + parent.close() + + +@pytest.mark.parametrize("property_name", ["readonly", "active"]) +def test_histogram_range_respects_linked_field_state(property_name): + """Read-only/inactive linked fields cannot be changed via the composite.""" + + class LockedRange(DurationRange): + maximum = gds.FloatItem("Maximum", default=10.0).set_prop( + "display", hide=True, **{property_name: property_name == "readonly"} + ) + + with qt_app_context(): + parent = QWidget() + param = LockedRange() + layout = DataSetEditLayout(parent, param, QGridLayout(parent)) + widget = next( + item for item in layout.widgets if isinstance(item, HistogramRangeWidget) + ) + assert not widget.group.isEnabled() + widget.minimum_slider.setValue(100) + layout.accept_changes() + assert (param.minimum, param.maximum, param.width) == (0.0, 10.0, 10.0) + parent.close() + + +@pytest.mark.parametrize("check", [True, False]) +def test_histogram_range_nonzero_and_validation_opt_out(check): + """The composite uses the exact FloatItem validation policy.""" + + class NonzeroRange(DurationRange): + minimum = gds.FloatItem( + "Minimum", default=1.0, min=0.0, nonzero=True, check=check + ).set_prop("display", hide=True) + + with qt_app_context(): + parent = QWidget() + param = NonzeroRange() + layout = DataSetEditLayout(parent, param, QGridLayout(parent)) + widget = next( + item for item in layout.widgets if isinstance(item, HistogramRangeWidget) + ) + widget._set_range(0.0, 8.0) + assert widget.check() is not check + layout.accept_changes() + assert param.minimum == (1.0 if check else 0.0) + parent.close() + + +def test_histogram_range_updates_computed_sibling(): + """Computed items refresh from the complete working range.""" + + class ComputedRange(DurationRange): + width = gds.FloatItem("Width").set_computed( + lambda instance: instance.maximum - instance.minimum + ) + histogram = gds.HistogramRangeItem("Range", "minimum", "maximum") + + with qt_app_context(): + parent = QWidget() + param = ComputedRange() + param.histogram = DurationRange().histogram + layout = DataSetEditLayout(parent, param, QGridLayout(parent)) + widget = next( + item for item in layout.widgets if isinstance(item, HistogramRangeWidget) + ) + widget.auto_button.click() + assert param.width == 6.0 + assert layout.widgets[0].edit.text() == "6.0" + parent.close() + + +@pytest.mark.parametrize( + "payload", + [ + {}, + {"domain": [1, 0]}, + {"domain": [0, 1], "minimum_width": -1}, + {"domain": [0, 1], "counts": [float("nan")]}, + ], +) +def test_histogram_range_invalid_context_is_inert(payload): + """Malformed or unavailable context cannot mutate persisted bounds.""" + with qt_app_context(): + parent = QWidget() + param = make_parameters() + param.histogram = {**payload, "active": True} + layout = DataSetEditLayout(parent, param, QGridLayout(parent)) + widget = layout.widgets[0] + assert not widget.group.isEnabled() + widget.minimum_slider.setValue(100) + layout.accept_changes() + assert (param.minimum, param.maximum) == (0.0, 255.0) + parent.close() diff --git a/guidata/tests/dataset/test_jsonschema.py b/guidata/tests/dataset/test_jsonschema.py index 3144927..38bfad6 100644 --- a/guidata/tests/dataset/test_jsonschema.py +++ b/guidata/tests/dataset/test_jsonschema.py @@ -64,6 +64,75 @@ class P(gds.DataSet): assert prop["x-guidata-slider"] is True +@pytest.mark.parametrize("presentation", ["range", "brightness_contrast"]) +def test_histogram_range_item_schema_and_transient_value(presentation): + class P(gds.DataSet): + minimum = gds.FloatItem("Minimum", default=0.0) + maximum = gds.FloatItem("Maximum", default=255.0) + histogram = gds.HistogramRangeItem( + "Brightness and contrast", "minimum", "maximum" + ) + + instance = P() + if presentation != "range": + P.histogram.set_prop("display", presentation=presentation) + instance.histogram = { + "counts": [1, 2], + "bin_edges": [0.0, 127.5, 255.0], + "domain": [0.0, 255.0], + } + snapshot = dataset_to_schema_with_values(instance) + prop = snapshot["schema"]["properties"]["histogram"] + assert prop["x-guidata-kind"] == "histogram_range" + assert prop["x-guidata-transient"] is True + assert prop["x-guidata-minimum-field"] == "minimum" + assert prop["x-guidata-maximum-field"] == "maximum" + assert prop["x-guidata-histogram-presentation"] == presentation + assert snapshot["values"]["histogram"] == instance.histogram + + +@pytest.mark.parametrize( + "minimum,maximum", + [ + ("missing", "upper"), + ("lower", "lower"), + ("text", "upper"), + ("histogram", "upper"), + ("integer", "upper"), + ], +) +def test_histogram_range_rejects_invalid_links(minimum, maximum): + class Invalid(gds.DataSet): + lower = gds.FloatItem("Lower") + upper = gds.FloatItem("Upper") + text = gds.StringItem("Text") + integer = gds.IntItem("Integer") + histogram = gds.HistogramRangeItem("Range", minimum, maximum) + + with pytest.raises(ValueError, match="distinct FloatItem"): + dataset_to_schema(Invalid) + + +def test_histogram_range_empty_context_and_unknown_presentation(): + class Interval(gds.DataSet): + lower = gds.FloatItem("Lower", default=0.0, min=1.0, check=False) + upper = gds.FloatItem("Upper", default=2.0) + histogram = gds.HistogramRangeItem("Range", "lower", "upper") + + instance = Interval() + snapshot = dataset_to_schema_with_values(instance) + assert snapshot["values"]["histogram"] == {} + assert snapshot["schema"]["properties"]["lower"]["x-guidata-check-value"] is False + instance.histogram = {"output_range": [0, 1], "extension": "caller-owned"} + assert ( + dataset_to_schema_with_values(instance)["values"]["histogram"] + == instance.histogram + ) + Interval.histogram.set_prop("display", presentation="unknown") + with pytest.raises(ValueError, match="Unknown histogram presentation"): + dataset_to_schema(Interval) + + def test_nonzero_flag(): class P(gds.DataSet): n = gds.IntItem("N", default=1, nonzero=True) diff --git a/guidata/tests/dataset/test_loadsave_json.py b/guidata/tests/dataset/test_loadsave_json.py index 96bb511..0b1c879 100644 --- a/guidata/tests/dataset/test_loadsave_json.py +++ b/guidata/tests/dataset/test_loadsave_json.py @@ -14,6 +14,7 @@ import os +import guidata.dataset as gds from guidata.env import execenv from guidata.io import JSONReader, JSONWriter from guidata.qthelpers import qt_app_context @@ -47,5 +48,29 @@ def test_loadsave_json(): execenv.print("OK") +def test_transient_item_is_not_serialized(): + class ParametersWithTransientItem(gds.DataSet): + minimum = gds.FloatItem("Minimum", default=0.0) + maximum = gds.FloatItem("Maximum", default=1.0) + histogram = gds.HistogramRangeItem( + "Brightness and contrast", "minimum", "maximum" + ) + + source = ParametersWithTransientItem() + source.minimum = 0.25 + source.maximum = 0.75 + source.histogram = {"counts": [1, 2, 3]} + + restored = gds.json_to_dataset( + gds.dataset_to_json(source), + strict=True, + expected_class=ParametersWithTransientItem, + ) + + assert restored.minimum == 0.25 + assert restored.maximum == 0.75 + assert restored.histogram == {} + + if __name__ == "__main__": test_loadsave_json() From 77a7ae6914300e7d6aafc0ff25dabf1e76b69cee Mon Sep 17 00:00:00 2001 From: Thomas MALLET Date: Thu, 10 Sep 2026 15:55:12 +0200 Subject: [PATCH 3/3] docs(dataset): fix bin_edges contract, document transient serialization, add release notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address pre-review findings on feat/automatic-dataset-sliders: * HistogramRangeItem: align docstring and reference doc with the actual rendering behavior — bins are always drawn uniformly across `domain`; non-uniform `bin_edges` were documented but never consumed by `_HistogramCanvas.paintEvent`. Drop the fictive key from the two test fixtures that referenced it. * conv.py: document the `data.transient` exclusion from JSON/dict serialization introduced by HistogramRangeItem. * Add `test_histogram_range_canvas_paints_every_branch`, covering `_HistogramCanvas.paintEvent` for both presentations and three payload shapes (normal, empty selection, inverted domain). Raises guidata/dataset/qtitemwidgets.py coverage from 81% to 84%. * Add missing docstrings on test_loadsave_json.py (Pylint C0115/C0116). * Add `# guitest: show` demo entry points to test_auto_sliders.py and test_histogram_range_item.py for manual visual validation via `python -m guidata.tests`. * Add release notes for 3.16.0 (auto_sliders, HistogramRangeItem). --- doc/reference/dataset/dataitems.rst | 8 +-- doc/release_notes/release_3.16.md | 11 ++++ guidata/dataset/conv.py | 4 ++ guidata/dataset/dataitems.py | 8 +-- guidata/locale/fr/LC_MESSAGES/guidata.po | 35 ++++++----- guidata/tests/dataset/test_auto_sliders.py | 35 ++++++++++- .../dataset/test_histogram_range_item.py | 63 ++++++++++++++++++- guidata/tests/dataset/test_jsonschema.py | 2 +- guidata/tests/dataset/test_loadsave_json.py | 4 ++ 9 files changed, 140 insertions(+), 30 deletions(-) create mode 100644 doc/release_notes/release_3.16.md diff --git a/doc/reference/dataset/dataitems.rst b/doc/reference/dataset/dataitems.rst index 723bfe1..27ecb78 100644 --- a/doc/reference/dataset/dataitems.rst +++ b/doc/reference/dataset/dataitems.rst @@ -5,10 +5,10 @@ Histogram range context ----------------------- -``HistogramRangeItem`` consumes a plain mapping with uniformly spaced histogram -``counts``, a finite ordered ``domain`` pair, and an ``active`` boolean. Optional -``bin_edges`` has one more entry than ``counts``; adjacent edges may coincide after -float rounding. Optional ``y_max`` and ``minimum_width`` must be positive and finite. +``HistogramRangeItem`` consumes a plain mapping with histogram ``counts``, a finite +ordered ``domain`` pair, and an ``active`` boolean. Bins are always drawn uniformly +across ``domain``: non-uniform bin edges are not supported, so callers must rebin +beforehand. Optional ``y_max`` and ``minimum_width`` must be positive and finite. Optional ``auto_range`` and ``reset_range`` are finite ordered pairs. Additional keys are preserved for the caller. An empty mapping represents unavailable context. diff --git a/doc/release_notes/release_3.16.md b/doc/release_notes/release_3.16.md new file mode 100644 index 0000000..3547de1 --- /dev/null +++ b/doc/release_notes/release_3.16.md @@ -0,0 +1,11 @@ +# Version 3.16 # + +## guidata Version 3.16.0 ## + +✨ New features: + +* **Local automatic sliders for numeric `DataSet` items** — `DataSetEditLayout` (and derived group boxes/dialogs) can now opt into adding sliders to editable, bounded numeric items without changing shared `DataItem` declarations or affecting other forms. Enable it with `DataSetEditLayout(..., auto_sliders=True)`; the policy is inherited by nested groups and tabs. Editable items need finite, ordered, representable bounds to receive a slider — integer parity (`even`) and `nonzero` ranges crossing zero are respected, unusable items remain text-only. For floats, a usable positive `step` is reused when practical, otherwise the new `slider_steps` parameter sets the normalized resolution (default 1000). The text field always keeps the exact underlying value, independently of the slider thumb resolution. Use `item.set_prop("display", auto_slider=False)` to exclude a specific item from the policy. An optional `slider_callback(pressed)` reports drag start/end without affecting the existing `change_callback` value-change notifications. `slider=True` on individual items keeps its previous behavior unchanged. + +* **`HistogramRangeItem` — histogram-backed interval selection** — added a new `DataItem` that edits two distinct `FloatItem` fields of the same dataset through a single histogram-backed range editor widget. The caller supplies a rendering payload (bin `counts`, a finite ordered `domain`, optional `y_max`, `minimum_width`, `auto_range` and `reset_range` proposals); guidata never inspects source arrays or computes a range on its own. The default `"range"` presentation shows a plain interval selector suitable for generic measurements (durations, thresholds, …); setting `display.presentation` to `"brightness_contrast"` adds brightness/contrast sliders and a linear transfer function overlay for image-oriented use cases. The item is transient: only the two linked numeric fields are persisted (HDF5/JSON/INI), so applications must restore the histogram context after loading before editing is possible. A `display.callback(instance, item, value)` is invoked once per valid edited pair, with both bounds already applied to the working dataset, matching the behavior of other guidata live callbacks. The JSON Schema exporter emits a dedicated `"histogram_range"` kind with `x-guidata-minimum-field`, `x-guidata-maximum-field` and `x-guidata-histogram-presentation` extension keywords so portable renderers can reproduce the same behavior. See the [examples](../examples.rst) and [`HistogramRangeItem` reference](../reference/dataset/dataitems.rst) documentation for a complete, tested duration-selection walkthrough. + +🛠 Bug fixes: diff --git a/guidata/dataset/conv.py b/guidata/dataset/conv.py index 881556b..8110fa7 100644 --- a/guidata/dataset/conv.py +++ b/guidata/dataset/conv.py @@ -24,6 +24,10 @@ Serialize datasets as JSON -------------------------- +Items whose ``data.transient`` property is ``True`` are omitted from the +serialized output and left untouched when loading: they carry presentation +context supplied by the host application, not dataset state. + .. autofunction:: guidata.dataset.dataset_to_json .. autofunction:: guidata.dataset.json_to_dataset diff --git a/guidata/dataset/dataitems.py b/guidata/dataset/dataitems.py index a266863..12c7c22 100644 --- a/guidata/dataset/dataitems.py +++ b/guidata/dataset/dataitems.py @@ -1547,10 +1547,10 @@ class HistogramRangeItem(DataItem): hidden from the ordinary form; their validation and readonly properties still apply. The histogram domain does not constrain their values. - The payload describes uniformly spaced bins: ``counts`` and optional - ``bin_edges`` (one more edge than counts), a finite ordered ``domain``, and - optional positive ``y_max`` and ``minimum_width``. Rounded physical edges - may coincide for very narrow domains. ``active`` enables editing; + The payload describes ``counts`` spread uniformly over a finite ordered + ``domain``, plus optional positive ``y_max`` and ``minimum_width``. Bin + positions are always derived from ``domain``: non-uniform bin edges are not + supported, so callers must rebin beforehand. ``active`` enables editing; ``auto_range`` and ``reset_range`` optionally supply button targets. An empty payload disables editing until context is provided. Extra keys are allowed, but no image objects or computation callbacks belong here. diff --git a/guidata/locale/fr/LC_MESSAGES/guidata.po b/guidata/locale/fr/LC_MESSAGES/guidata.po index 9dfd14b..f4dd025 100644 --- a/guidata/locale/fr/LC_MESSAGES/guidata.po +++ b/guidata/locale/fr/LC_MESSAGES/guidata.po @@ -77,6 +77,24 @@ msgstr "Valeur minimale du tableau" msgid "Largest element in array" msgstr "Valeur maximale du tableau" +msgid "Minimum" +msgstr "Minimum" + +msgid "Maximum" +msgstr "Maximum" + +msgid "Brightness" +msgstr "Luminosité" + +msgid "Contrast" +msgstr "Contraste" + +msgid "Auto" +msgstr "Auto" + +msgid "Reset" +msgstr "Réinitialiser" + msgid "Some required entries are incorrect" msgstr "Les champs surlignés n'ont pas été remplis correctement." @@ -772,20 +790,3 @@ msgstr "Instance :" msgid "Text editor" msgstr "Éditeur de texte" -msgid "Minimum" -msgstr "Minimum" - -msgid "Maximum" -msgstr "Maximum" - -msgid "Brightness" -msgstr "Luminosité" - -msgid "Contrast" -msgstr "Contraste" - -msgid "Auto" -msgstr "Auto" - -msgid "Reset" -msgstr "Réinitialiser" diff --git a/guidata/tests/dataset/test_auto_sliders.py b/guidata/tests/dataset/test_auto_sliders.py index 627ae9f..43cc38e 100644 --- a/guidata/tests/dataset/test_auto_sliders.py +++ b/guidata/tests/dataset/test_auto_sliders.py @@ -1,4 +1,15 @@ -"""Local automatic sliders preserve exact values and parameter declarations.""" +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the BSD 3-Clause +# (see guidata/LICENSE for details) + +"""Local automatic sliders preserve exact values and parameter declarations. + +Run this script directly to visually check which bounded items receive a +slider, and that dragging never truncates the value shown in the text field. +""" + +# guitest: show from __future__ import annotations @@ -7,6 +18,7 @@ import guidata.dataset as gds from guidata.dataset.qtwidgets import DataSetEditLayout +from guidata.env import execenv from guidata.qthelpers import qt_app_context @@ -188,3 +200,24 @@ class Constrained(gds.DataSet): assert editor.widgets[1].slider is None assert editor.widgets[2].slider is None parent.close() + + +def test_auto_sliders(): + """Show a form opting into the local automatic slider policy.""" + with qt_app_context(exec_loop=True): + window = QWidget() + window.setWindowTitle("Automatic sliders (local layout policy)") + DataSetEditLayout( + window, + Parameters(), + QGridLayout(window), + auto_sliders=True, + slider_steps=1000, + ) + window.resize(560, 320) + window.show() + execenv.print("OK") + + +if __name__ == "__main__": + test_auto_sliders() diff --git a/guidata/tests/dataset/test_histogram_range_item.py b/guidata/tests/dataset/test_histogram_range_item.py index aa7bd01..ad37b4e 100644 --- a/guidata/tests/dataset/test_histogram_range_item.py +++ b/guidata/tests/dataset/test_histogram_range_item.py @@ -1,4 +1,15 @@ -"""Tests for the portable histogram-backed range editor.""" +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the BSD 3-Clause +# (see guidata/LICENSE for details) + +"""Tests for the portable histogram-backed range editor. + +Run this script directly to visually check both presentations, the live +dependent field and the disabled state when no rendering context is supplied. +""" + +# guitest: show from __future__ import annotations @@ -7,11 +18,12 @@ import pytest from qtpy.QtCore import Qt from qtpy.QtTest import QTest -from qtpy.QtWidgets import QGridLayout, QWidget +from qtpy.QtWidgets import QGridLayout, QGroupBox, QHBoxLayout, QWidget import guidata.dataset as gds from guidata.dataset.qtitemwidgets import HistogramRangeWidget from guidata.dataset.qtwidgets import DataSetEditLayout, DataSetShowLayout +from guidata.env import execenv from guidata.qthelpers import qt_app_context @@ -42,7 +54,6 @@ def make_parameters() -> Parameters: param = Parameters() param.histogram = { "counts": [1, 4, 2, 1], - "bin_edges": [0.0, 63.75, 127.5, 191.25, 255.0], "domain": [0.0, 255.0], "y_max": 4, "minimum_width": 1.0, @@ -490,3 +501,49 @@ def test_histogram_range_invalid_context_is_inert(payload): layout.accept_changes() assert (param.minimum, param.maximum) == (0.0, 255.0) parent.close() + + +@pytest.mark.parametrize("factory", [make_parameters, DurationRange]) +def test_histogram_range_canvas_paints_every_branch(factory): + """Rendering covers both presentations, an empty selection and a bad domain.""" + with qt_app_context(): + parent = QWidget() + param = factory() + layout = DataSetEditLayout(parent, param, QGridLayout(parent)) + widget = next( + item for item in layout.widgets if isinstance(item, HistogramRangeWidget) + ) + canvas = widget.canvas + canvas.resize(200, 100) + + for payload, minimum, maximum in ( + (widget._payload(), *widget._range()), + (widget._payload(), 1.0, 1.0), + ({"domain": [1.0, 0.0]}, 0.0, 1.0), + ): + canvas.set_data(payload, minimum, maximum) + assert not canvas.grab().isNull() + parent.close() + + +def test_histogram_range_item(): + """Show every presentation and the unavailable-context state side by side.""" + with qt_app_context(exec_loop=True): + window = QWidget() + window.setWindowTitle("Histogram-backed range editor") + columns = QHBoxLayout(window) + for title, param in ( + ("Range presentation", DurationRange()), + ("Brightness/contrast presentation", make_parameters()), + ("Unavailable rendering context", Parameters()), + ): + group = QGroupBox(title) + DataSetEditLayout(group, param, QGridLayout(group)) + columns.addWidget(group) + window.resize(1100, 500) + window.show() + execenv.print("OK") + + +if __name__ == "__main__": + test_histogram_range_item() diff --git a/guidata/tests/dataset/test_jsonschema.py b/guidata/tests/dataset/test_jsonschema.py index 38bfad6..7dd63f0 100644 --- a/guidata/tests/dataset/test_jsonschema.py +++ b/guidata/tests/dataset/test_jsonschema.py @@ -78,7 +78,7 @@ class P(gds.DataSet): P.histogram.set_prop("display", presentation=presentation) instance.histogram = { "counts": [1, 2], - "bin_edges": [0.0, 127.5, 255.0], + "source_id": "caller-specific extra key", "domain": [0.0, 255.0], } snapshot = dataset_to_schema_with_values(instance) diff --git a/guidata/tests/dataset/test_loadsave_json.py b/guidata/tests/dataset/test_loadsave_json.py index 0b1c879..fab9862 100644 --- a/guidata/tests/dataset/test_loadsave_json.py +++ b/guidata/tests/dataset/test_loadsave_json.py @@ -49,7 +49,11 @@ def test_loadsave_json(): def test_transient_item_is_not_serialized(): + """Transient items are skipped by JSON serialization and deserialization.""" + class ParametersWithTransientItem(gds.DataSet): + """Dataset holding a transient rendering payload beside persisted bounds.""" + minimum = gds.FloatItem("Minimum", default=0.0) maximum = gds.FloatItem("Maximum", default=1.0) histogram = gds.HistogramRangeItem(