Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions doc/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,72 @@ 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.

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
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Expand Down
16 changes: 16 additions & 0 deletions doc/reference/dataset/dataitems.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
:tocdepth: 3

.. automodule:: guidata.dataset.dataitems

Histogram range context
-----------------------

``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.

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.
11 changes: 11 additions & 0 deletions doc/release_notes/release_3.16.md
Original file line number Diff line number Diff line change
@@ -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:
1 change: 1 addition & 0 deletions guidata/dataset/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
FloatArrayItem,
FloatItem,
FontFamilyItem,
HistogramRangeItem,
ImageChoiceItem,
IntItem,
LabeledEnum,
Expand Down
10 changes: 8 additions & 2 deletions guidata/dataset/conv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -396,11 +400,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)
}


Expand Down
91 changes: 91 additions & 0 deletions guidata/dataset/dataitems.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@
.. autoclass:: guidata.dataset.DictItem
:members:

.. autoclass:: guidata.dataset.HistogramRangeItem
:members:

.. autoclass:: guidata.dataset.FontFamilyItem
:members:

Expand Down Expand Up @@ -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 ``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.

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

Expand Down
26 changes: 24 additions & 2 deletions guidata/dataset/jsonschema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading