Skip to content

Commit 7203944

Browse files
Merge pull request #114 from PlotPyStack/feat/automatic-dataset-sliders
feat(dataset): add reactive numeric range editors
2 parents 2cf91cd + 77a7ae6 commit 7203944

15 files changed

Lines changed: 1895 additions & 8 deletions

File tree

‎doc/examples.rst‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,72 @@ Embedding guidata objects in GUI layouts
4848

4949
.. image:: images/screenshots/editgroupbox.png
5050

51+
Local automatic sliders
52+
~~~~~~~~~~~~~~~~~~~~~~~
53+
54+
An embedded ``DataSetEditLayout`` can opt into sliders without changing shared
55+
``DataItem`` declarations or other forms::
56+
57+
editor = DataSetEditLayout(
58+
parent, parameters, grid, change_callback=parameters_changed,
59+
auto_sliders=True, slider_steps=1000,
60+
)
61+
62+
The policy is inherited by nested groups and tabs. Editable numeric items need
63+
finite, ordered, representable bounds; otherwise they remain text-only. Integer
64+
parity is respected, and ranges crossing zero for ``nonzero`` items are not
65+
automatically given sliders. For floats, a usable positive ``step`` is used when
66+
practical, otherwise ``slider_steps`` specifies the normalized resolution. The
67+
text field retains its exact value independently of the slider thumb.
68+
69+
``slider=True`` retains its existing behavior. Set
70+
``item.set_prop("display", auto_slider=False)`` to exclude an item from the local
71+
automatic policy. No bounds are inferred. The optional layout callback
72+
``slider_callback(pressed)`` receives ``True`` at drag start and ``False`` at
73+
release; value changes still use the existing ``change_callback``. Neither
74+
callback automatically validates, accepts or applies the form. Use
75+
``check_all_values()`` before ``accept_changes()`` when collecting a draft.
76+
77+
Histogram-backed interval selection
78+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
79+
80+
``HistogramRangeItem`` edits two distinct ``FloatItem`` fields in the same dataset.
81+
Its default presentation is a generic interval, suitable for measurements such as
82+
durations. The caller supplies histogram counts and range proposals; guidata does
83+
not inspect source arrays or choose an automatic range.
84+
85+
The following tested dataset has bounded durations in seconds and a live dependent
86+
field. Import ``guidata.dataset as gds`` before using it:
87+
88+
.. literalinclude:: ../guidata/tests/dataset/test_histogram_range_item.py
89+
:pyobject: DurationRange
90+
91+
Call ``DurationRange().edit()`` to display it. The hidden numeric fields retain
92+
their constraints, including ``nonzero`` and the ``check=False`` opt-out. A window
93+
outside those constraints stays visible but cannot be accepted. The histogram's
94+
``domain`` is only a drawing/slider domain, not an additional numeric constraint.
95+
If either linked field is read-only or inactive, the entire composite is disabled.
96+
97+
The histogram payload is transient: only the linked numeric values are serialized.
98+
After loading a dataset, restore its histogram context before enabling editing.
99+
An empty context leaves the saved bounds visible and the controls disabled.
100+
Auto and Reset appear only when the caller supplies finite ordered proposals.
101+
102+
The callback receives ``(instance, item, payload)`` once per valid changed pair.
103+
Both bounds are already on the working dataset, and dependent fields are refreshed.
104+
Without callbacks or computed fields, edits remain local until acceptance. With
105+
live dependencies, guidata updates the working dataset before acceptance, just as
106+
for ordinary items. Applications requiring transactional Cancel must edit a copy;
107+
the dialog cannot undo arbitrary callback side effects.
108+
109+
For image applications, explicitly select the brightness/contrast presentation::
110+
111+
DurationRange.histogram.set_prop("display", presentation="brightness_contrast")
112+
113+
This adds brightness/contrast controls and a linear transfer overlay. It changes
114+
only rendering metadata, never the stored parameters. The JSON Schema exporter
115+
emits ``x-guidata-histogram-presentation`` so portable renderers use the same mode.
116+
51117
Data item groups and group selection
52118
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
53119

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
11
:tocdepth: 3
22

33
.. automodule:: guidata.dataset.dataitems
4+
5+
Histogram range context
6+
-----------------------
7+
8+
``HistogramRangeItem`` consumes a plain mapping with histogram ``counts``, a finite
9+
ordered ``domain`` pair, and an ``active`` boolean. Bins are always drawn uniformly
10+
across ``domain``: non-uniform bin edges are not supported, so callers must rebin
11+
beforehand. Optional ``y_max`` and ``minimum_width`` must be positive and finite.
12+
Optional ``auto_range`` and ``reset_range`` are finite ordered pairs. Additional
13+
keys are preserved for the caller. An empty mapping represents unavailable context.
14+
15+
The default ``display.presentation`` is ``"range"``. The only specialization is
16+
``"brightness_contrast"``; unknown modes and links to anything other than two
17+
distinct ``FloatItem`` fields fail explicitly when constructing/exporting the form.
18+
See :ref:`examples` for a complete, tested duration-selection example and the live
19+
callback/Cancel contract.

‎doc/release_notes/release_3.16.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Version 3.16 #
2+
3+
## guidata Version 3.16.0 ##
4+
5+
✨ New features:
6+
7+
* **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.
8+
9+
* **`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.
10+
11+
🛠 Bug fixes:

‎guidata/dataset/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
FloatArrayItem,
3737
FloatItem,
3838
FontFamilyItem,
39+
HistogramRangeItem,
3940
ImageChoiceItem,
4041
IntItem,
4142
LabeledEnum,

‎guidata/dataset/conv.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
Serialize datasets as JSON
2525
--------------------------
2626
27+
Items whose ``data.transient`` property is ``True`` are omitted from the
28+
serialized output and left untouched when loading: they carry presentation
29+
context supplied by the host application, not dataset state.
30+
2731
.. autofunction:: guidata.dataset.dataset_to_json
2832
2933
.. autofunction:: guidata.dataset.json_to_dataset
@@ -396,11 +400,13 @@ def _resolve_dataset_class(
396400

397401

398402
def _dataset_items(dataset_class: type[gdt.DataSet]) -> dict[str, gdt.DataItem]:
399-
"""Return persisted DataItems, excluding structural and callback items."""
403+
"""Return persisted DataItems, excluding structural and transient items."""
400404
return {
401405
item.get_name(): item
402406
for item in dataset_class._items
403-
if item.get_name() and not isinstance(item, _NON_SERIALIZED_ITEM_TYPES)
407+
if item.get_name()
408+
and not isinstance(item, _NON_SERIALIZED_ITEM_TYPES)
409+
and not item.get_prop("data", "transient", False)
404410
}
405411

406412

‎guidata/dataset/dataitems.py‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@
8686
.. autoclass:: guidata.dataset.DictItem
8787
:members:
8888
89+
.. autoclass:: guidata.dataset.HistogramRangeItem
90+
:members:
91+
8992
.. autoclass:: guidata.dataset.FontFamilyItem
9093
:members:
9194
@@ -1531,6 +1534,94 @@ def get_value_from_reader(self, reader):
15311534
return reader.read_dict()
15321535

15331536

1537+
class HistogramRangeItem(DataItem):
1538+
"""Histogram-backed editor for two distinct FloatItem fields in one dataset.
1539+
1540+
The item value is a JSON-compatible rendering payload. It is deliberately
1541+
transient: only the linked minimum and maximum fields belong to the
1542+
persistent dataset state.
1543+
1544+
Set ``display.presentation`` to ``"brightness_contrast"`` to enable the
1545+
brightness/contrast controls and linear transfer display. The default
1546+
``"range"`` presentation only selects an interval. Linked fields should be
1547+
hidden from the ordinary form; their validation and readonly properties
1548+
still apply. The histogram domain does not constrain their values.
1549+
1550+
The payload describes ``counts`` spread uniformly over a finite ordered
1551+
``domain``, plus optional positive ``y_max`` and ``minimum_width``. Bin
1552+
positions are always derived from ``domain``: non-uniform bin edges are not
1553+
supported, so callers must rebin beforehand. ``active`` enables editing;
1554+
``auto_range`` and ``reset_range`` optionally supply button targets.
1555+
An empty payload disables editing until context is provided. Extra keys
1556+
are allowed, but no image objects or computation callbacks belong here.
1557+
1558+
A ``display.callback(instance, item, value)`` receives the unchanged payload;
1559+
both edited bounds are available on the working instance. Like other
1560+
guidata live callbacks, it updates the working dataset before acceptance.
1561+
Hosts needing transactional cancellation must edit a copy of their data.
1562+
1563+
Args:
1564+
label: Item label
1565+
minimum: Name of the linked minimum field
1566+
maximum: Name of the linked maximum field
1567+
default: Initial rendering payload
1568+
help: Text shown in the tooltip
1569+
"""
1570+
1571+
type: type[dict[str, Any]] = dict
1572+
1573+
def __init__(
1574+
self,
1575+
label: str,
1576+
minimum: str,
1577+
maximum: str,
1578+
default: dict[str, Any] | None = None,
1579+
help: str = "",
1580+
) -> None:
1581+
super().__init__(label, default=default or {}, help=help, check=False)
1582+
self.set_prop(
1583+
"data",
1584+
transient=True,
1585+
minimum_field=minimum,
1586+
maximum_field=maximum,
1587+
)
1588+
self.set_prop("display", presentation="range")
1589+
1590+
def get_presentation(self) -> str:
1591+
"""Return the validated portable presentation name."""
1592+
presentation = self.get_prop("display", "presentation", "range")
1593+
if presentation not in ("range", "brightness_contrast"):
1594+
raise ValueError(f"Unknown histogram presentation: {presentation!r}")
1595+
return presentation
1596+
1597+
def get_range_items(self, instance: DataSet) -> tuple[FloatItem, FloatItem]:
1598+
"""Resolve and validate the two linked fields without changing them."""
1599+
minimum = self.get_prop("data", "minimum_field")
1600+
maximum = self.get_prop("data", "maximum_field")
1601+
items = {item.get_name(): item for item in instance.get_items()}
1602+
if minimum == maximum or any(
1603+
not isinstance(items.get(name), FloatItem) for name in (minimum, maximum)
1604+
):
1605+
raise ValueError(
1606+
"HistogramRangeItem requires two distinct FloatItem fields"
1607+
)
1608+
return items[minimum], items[maximum]
1609+
1610+
def serialize(
1611+
self,
1612+
instance: DataSet,
1613+
writer: HDF5Writer | JSONWriter | INIWriter,
1614+
) -> None:
1615+
"""Skip the renderer payload when persisting the dataset."""
1616+
1617+
def deserialize(
1618+
self,
1619+
instance: DataSet,
1620+
reader: HDF5Reader | JSONReader | INIReader,
1621+
) -> None:
1622+
"""Keep the current renderer payload when loading persisted values."""
1623+
1624+
15341625
class ButtonItem(DataItem):
15351626
"""Construct a simple button that calls a method when hit
15361627

‎guidata/dataset/jsonschema.py‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@
7070
7171
``int``, ``float``, ``bool``, ``string``, ``text``, ``choice``,
7272
``multiple_choice``, ``image_choice``, ``color``, ``date``, ``datetime``,
73-
``file``, ``float_array``, ``dict``.
73+
``file``, ``float_array``, ``dict``, ``histogram_range``.
7474
7575
Items not supported (raise :class:`NotImplementedError`):
7676
``ButtonItem`` (callbacks cannot cross JSON), and conditional visibility
@@ -140,6 +140,9 @@ def dataset_to_schema(dataset_cls: type[gdt.DataSet]) -> dict[str, Any]:
140140
# Use a transient instance to obtain the title/comment computed by
141141
# ``DataSetMeta`` from the docstring, without paying for it twice.
142142
instance = dataset_cls()
143+
for item in instance.get_items():
144+
if isinstance(item, gdi.HistogramRangeItem):
145+
item.get_range_items(instance)
143146
title = instance.get_title()
144147
comment = instance.get_comment()
145148

@@ -455,7 +458,9 @@ def _item_to_property(item: gdt.DataItem, order: int) -> dict[str, Any]:
455458
)
456459

457460
# Dispatch in MRO-friendly order (most specific first).
458-
if isinstance(item, gdi.FloatArrayItem):
461+
if isinstance(item, gdi.HistogramRangeItem):
462+
prop = _histogram_range_to_property(item)
463+
elif isinstance(item, gdi.FloatArrayItem):
459464
prop = _float_array_to_property(item)
460465
elif isinstance(item, gdi.DictItem):
461466
prop = _dict_to_property(item)
@@ -514,6 +519,8 @@ def _numeric_to_property(item: gdi.NumericTypeItem, kind: str) -> dict[str, Any]
514519
"type": json_type,
515520
"x-guidata-kind": kind,
516521
}
522+
if item.get_prop("data", "check_value", True) is False:
523+
prop["x-guidata-check-value"] = False
517524
minv = item.get_prop("data", "min", None)
518525
maxv = item.get_prop("data", "max", None)
519526
if minv is not None:
@@ -687,6 +694,21 @@ def _dict_to_property(item: gdi.DictItem) -> dict[str, Any]:
687694
}
688695

689696

697+
def _histogram_range_to_property(
698+
item: gdi.HistogramRangeItem,
699+
) -> dict[str, Any]:
700+
"""Return the portable contract for a histogram-backed range editor."""
701+
return {
702+
"type": "object",
703+
"additionalProperties": True,
704+
"x-guidata-kind": "histogram_range",
705+
"x-guidata-transient": True,
706+
"x-guidata-minimum-field": item.get_prop("data", "minimum_field"),
707+
"x-guidata-maximum-field": item.get_prop("data", "maximum_field"),
708+
"x-guidata-histogram-presentation": item.get_presentation(),
709+
}
710+
711+
690712
# ---------------------------------------------------------------------------
691713
# Common per-property keys and helpers
692714
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)