Skip to content

Commit b75e0ea

Browse files
committed
Merge branch 'develop' into feature/env
2 parents b0f0a75 + d928ad9 commit b75e0ea

File tree

9 files changed

+286
-17
lines changed

9 files changed

+286
-17
lines changed

CHANGELOG.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,25 @@
22

33
## Version 3.13.0 ##
44

5-
💥 New features:
5+
✨ New features:
6+
7+
* `guidata.configtools.get_icon`:
8+
* This function retrieves a QIcon from the specified image file.
9+
* Now supports Qt standard icons (e.g. "MessageBoxInformation" or "DialogApplyButton").
10+
11+
* Added a `readonly` parameter to `StringItem` and `TextItem` in `guidata.dataset.dataitems`:
12+
* This allows these items to be set as read-only, preventing user edits in the GUI.
13+
* The `readonly` property is now respected in the corresponding widgets (see `guidata.dataset.qtitemwidgets`).
14+
* Example usage:
15+
16+
```python
17+
text = gds.TextItem("Text", default="Multi-line text", readonly=True)
18+
string = gds.StringItem("String", readonly=True)
19+
```
20+
21+
* Note: Any other item type can also be turned into read-only mode by using `set_prop("display", readonly=True)`. This is a generic mechanism, but the main use case is for `StringItem` and `TextItem` (hence the dedicated input parameter for convenience).
22+
23+
* [Issue #94](https://github.com/PlotPyStack/guidata/issues/94) - Make dataset description text selectable
624

725
* New validation modes for `DataItem` objects:
826
* Validation modes allow you to control how `DataItem` values are validated when they are set.
@@ -28,6 +46,12 @@
2846
* If `allow_none` is set to `False`, `None` is considered an invalid value.
2947
* The default value for `allow_none` is `False`, except for `FloatArrayItem`, `ColorItem` and `ChoiceItem` and its subclasses, where it is set to `True` by default.
3048

49+
* Enhanced default value handling for `DataItem` objects:
50+
* Default values can now be `None` even when `allow_none=False` is set on the item.
51+
* This allows developers to use `None` as a sensible default value while still preventing users from setting `None` at runtime.
52+
* This feature provides better flexibility for data item initialization without compromising runtime validation.
53+
* The implementation uses a clean internal architecture that separates default value setting from regular value setting, maintaining the standard Python descriptor protocol.
54+
3155
* Improved type handling in `IntItem` and `FloatItem`:
3256
* `IntItem` and `FloatItem` now automatically convert NumPy numeric types (like `np.int32` or `np.float64`) to native Python types (`int` or `float`) during validation
3357
* `FloatItem` now accepts integer values and silently converts them to float values

guidata/configtools.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,8 +197,19 @@ def get_icon(name: str, default: str = "not_found.png") -> QG.QIcon:
197197
try:
198198
return ICON_CACHE[name]
199199
except KeyError:
200-
# Importing Qt here because this module should be independent from it
201-
from qtpy import QtGui as QG # pylint: disable=import-outside-toplevel
200+
# Importing Qt objects here because this module should not depend on them
201+
# pylint: disable=import-outside-toplevel
202+
203+
# Try to get standard icon first
204+
from guidata.qthelpers import get_std_icon
205+
206+
try:
207+
return get_std_icon(name)
208+
except AttributeError:
209+
pass
210+
211+
# Retrieve icon from file (original implementation)
212+
from qtpy import QtGui as QG
202213

203214
icon = QG.QIcon(get_image_file_path(name, default))
204215
ICON_CACHE[name] = icon

guidata/dataset/dataitems.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -278,8 +278,10 @@ def __init__(
278278
self.set_prop("display", slider=slider)
279279
self.set_prop("data", step=step)
280280

281-
def __set__(self, instance: Any, value: Any) -> None:
282-
"""Override DataItem.__set__ to convert integers to float"""
281+
def _set_value_with_validation(
282+
self, instance: Any, value: Any, force_allow_none: bool = False
283+
) -> None:
284+
"""Override DataItem._set_value_with_validation to convert integers to float"""
283285
# Try to convert NumPy numeric types to Python float
284286
# (will convert silently either floating point or integer types to float)
285287
try:
@@ -291,7 +293,7 @@ def __set__(self, instance: Any, value: Any) -> None:
291293
# (no more NumPy types at this point)
292294
if isinstance(value, int):
293295
value = float(value)
294-
super().__set__(instance, value)
296+
super()._set_value_with_validation(instance, value, force_allow_none)
295297

296298
def get_value_from_reader(
297299
self, reader: HDF5Reader | JSONReader | INIReader
@@ -362,16 +364,19 @@ def get_auto_help(self, instance: DataSet) -> str:
362364
auto_help += ", " + _("odd")
363365
return auto_help
364366

365-
def __set__(self, instance: Any, value: Any) -> None:
366-
"""Override DataItem.__set__ to convert NumPy numeric types to int"""
367+
def _set_value_with_validation(
368+
self, instance: Any, value: Any, force_allow_none: bool = False
369+
) -> None:
370+
"""Override DataItem._set_value_with_validation
371+
to convert NumPy numeric types to int"""
367372
# Try to convert NumPy integer types to Python int
368373
# (will convert silently only integer types to int)
369374
try:
370375
if isinstance(value, np.integer):
371376
value = self.type(value)
372377
except (TypeError, ValueError):
373378
pass
374-
super().__set__(instance, value)
379+
super()._set_value_with_validation(instance, value, force_allow_none)
375380

376381
def check_value(self, value: int, raise_exception: bool = False) -> bool:
377382
"""Override DataItem method"""
@@ -410,6 +415,7 @@ class StringItem(DataItem):
410415
check: if False, value is not checked (ineffective for strings)
411416
allow_none: if True, None is a valid value regardless of other constraints
412417
(optional, default=False)
418+
readonly: if True, the item is read-only (optional, default=False)
413419
"""
414420

415421
type: Any = str
@@ -425,12 +431,15 @@ def __init__(
425431
help: str = "",
426432
check: bool = True,
427433
allow_none: bool = False,
434+
readonly: bool = False,
428435
) -> None:
429436
super().__init__(
430437
label, default=default, help=help, check=check, allow_none=allow_none
431438
)
432439
self.set_prop("data", notempty=notempty, regexp=regexp)
433-
self.set_prop("display", wordwrap=wordwrap, password=password)
440+
self.set_prop(
441+
"display", wordwrap=wordwrap, password=password, readonly=readonly
442+
)
434443

435444
def get_auto_help(self, instance: DataSet) -> str:
436445
"""Override DataItem method"""
@@ -492,6 +501,7 @@ class TextItem(StringItem):
492501
help: text shown in tooltip (optional)
493502
allow_none: if True, None is a valid value regardless of other constraints
494503
(optional, default=False)
504+
readonly: if True, the item is read-only (optional, default=False)
495505
"""
496506

497507
def __init__(
@@ -502,6 +512,7 @@ def __init__(
502512
wordwrap: bool = True,
503513
help: str = "",
504514
allow_none: bool = False,
515+
readonly: bool = False,
505516
) -> None:
506517
super().__init__(
507518
label,
@@ -510,6 +521,7 @@ def __init__(
510521
wordwrap=wordwrap,
511522
help=help,
512523
allow_none=allow_none,
524+
readonly=readonly,
513525
)
514526

515527

@@ -1015,11 +1027,13 @@ def get_value(self, instance: DataSet) -> str | None:
10151027
# guidata internals should call this; keep it returning the raw key
10161028
return super().__get__(instance, instance.__class__) # string (or None)
10171029

1018-
def __set__(self, instance: DataSet, value: Any) -> None:
1019-
"""Override DataItem.__set__ to accept Enum members"""
1030+
def _set_value_with_validation(
1031+
self, instance: Any, value: Any, force_allow_none: bool = False
1032+
) -> None:
1033+
"""Override DataItem._set_value_with_validation to accept Enum members"""
10201034
if self._enum_cls is not None and value is not None:
10211035
value = self._enum_coerce_in(value) # → member.name
1022-
super().__set__(instance, value)
1036+
super()._set_value_with_validation(instance, value, force_allow_none)
10231037

10241038
def _normalize_choice(
10251039
self, idx: int, choice_tuple: tuple[Any, ...]

guidata/dataset/datatypes.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -550,7 +550,8 @@ def set_default(self, instance: DataSet) -> None:
550550
instance (DataSet): instance of the DataSet
551551
"""
552552
try:
553-
self.__set__(instance, deepcopy(self._default))
553+
value = deepcopy(self._default)
554+
self._set_value_with_validation(instance, value, force_allow_none=True)
554555
except ValueError as exc:
555556
# Convert generic ValueError to a more specific DataItemValidationError
556557
# to provide clearer context when setting default values fails
@@ -585,12 +586,26 @@ def __set__(self, instance: Any, value: Any) -> None:
585586
instance (Any): instance of the DataSet
586587
value (Any): value to set
587588
"""
589+
self._set_value_with_validation(instance, value, force_allow_none=False)
590+
591+
def _set_value_with_validation(
592+
self, instance: Any, value: Any, force_allow_none: bool = False
593+
) -> None:
594+
"""Internal method to set data item's value with validation
595+
596+
Args:
597+
instance (Any): instance of the DataSet
598+
value (Any): value to set
599+
force_allow_none (bool): if True, allow None values even when
600+
allow_none is False (used for default values)
601+
"""
588602
vmode = get_validation_mode()
589603

590-
# If value is None and allow_none is True, skip validation
604+
# Determine if validation should be skipped
591605
allow_none = self.get_prop("data", "allow_none", False)
606+
skip_validation = value is None and (allow_none or force_allow_none)
592607

593-
if vmode != ValidationMode.DISABLED and not (value is None and allow_none):
608+
if vmode != ValidationMode.DISABLED and not skip_validation:
594609
try:
595610
self.check_value(value, raise_exception=True)
596611
except NotImplementedError:

guidata/dataset/qtitemwidgets.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,9 @@ def is_readonly(self) -> bool:
152152
Returns:
153153
True if associated dataset is readonly
154154
"""
155-
return self.item.instance.is_readonly()
155+
return self.item.instance.is_readonly() or self.item.get_prop_value(
156+
"display", "readonly", False
157+
)
156158

157159
def check(self) -> bool:
158160
"""Item validator

guidata/dataset/qtwidgets.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ def __init__(
126126
self._layout = QVBoxLayout()
127127
if instance.get_comment():
128128
label = QLabel(instance.get_comment())
129+
label.setTextInteractionFlags(Qt.TextSelectableByMouse)
129130
label.setWordWrap(wordwrap)
130131
self._layout.addWidget(label)
131132
self.instance = instance
@@ -275,6 +276,7 @@ def setup_instance(self, instance: DataSetGroup) -> None:
275276
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
276277
if dataset.get_comment():
277278
label = QLabel(dataset.get_comment())
279+
label.setTextInteractionFlags(Qt.TextSelectableByMouse)
278280
label.setWordWrap(self.wordwrap)
279281
layout.addWidget(label)
280282
grid = QGridLayout()
@@ -772,6 +774,7 @@ def __init__(
772774
self._layout = QVBoxLayout()
773775
if self.dataset.get_comment():
774776
label = QLabel(self.dataset.get_comment())
777+
label.setTextInteractionFlags(Qt.TextSelectableByMouse)
775778
label.setWordWrap(wordwrap)
776779
self._layout.addWidget(label)
777780
self.grid_layout = QGridLayout()
@@ -1067,6 +1070,7 @@ def __init__(
10671070
self._layout = QVBoxLayout()
10681071
if instance.get_comment():
10691072
label = QLabel(instance.get_comment())
1073+
label.setTextInteractionFlags(Qt.TextSelectableByMouse)
10701074
label.setWordWrap(wordwrap)
10711075
self._layout.addWidget(label)
10721076
self.instance = instance
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Licensed under the terms of the BSD 3-Clause
4+
# (see guidata/LICENSE for details)
5+
6+
"""
7+
Button item test
8+
9+
This item is tested separately from other items because it is special: contrary to
10+
other items, it is purely GUI oriented and has no sense in a non-GUI context.
11+
"""
12+
13+
# guitest: show
14+
15+
from __future__ import annotations
16+
17+
import os.path as osp
18+
import re
19+
20+
from qtpy import QtCore as QC
21+
from qtpy import QtWidgets as QW
22+
23+
import guidata.dataset as gds
24+
from guidata.env import execenv
25+
from guidata.qthelpers import qt_app_context
26+
27+
28+
def information_selectable(parent: QW.QWidget, title: str, text: str) -> None:
29+
"""Show an information message box with selectable text.
30+
Dialog box is *not* modal."""
31+
box = QW.QMessageBox(parent)
32+
box.setIcon(QW.QMessageBox.Information)
33+
box.setWindowTitle(title)
34+
if re.search(r"<[a-zA-Z/][^>]*>", text):
35+
box.setTextFormat(QC.Qt.RichText)
36+
box.setTextInteractionFlags(QC.Qt.TextBrowserInteraction)
37+
else:
38+
box.setTextFormat(QC.Qt.PlainText)
39+
box.setTextInteractionFlags(
40+
QC.Qt.TextSelectableByMouse | QC.Qt.TextSelectableByKeyboard
41+
)
42+
box.setText(text)
43+
box.setStandardButtons(QW.QMessageBox.Close)
44+
box.setDefaultButton(QW.QMessageBox.Close)
45+
box.setWindowFlags(QC.Qt.Window) # This is necessary only on non-Windows platforms
46+
box.setModal(False)
47+
box.show()
48+
49+
50+
class Parameters(gds.DataSet):
51+
"""
52+
DataSet test
53+
The following text is the DataSet 'comment': <br>Plain text or
54+
<b>rich text<sup>2</sup></b> are both supported,
55+
as well as special characters (α, β, γ, δ, ...)
56+
"""
57+
58+
def button_cb(
59+
dataset: Parameters, item: gds.ButtonItem, value: None, parent: QW.QWidget
60+
) -> None:
61+
"""Button callback"""
62+
execenv.print(f"Button clicked: {dataset}, {item}, {value}, {parent}")
63+
text = "<br>".join(
64+
[
65+
f"<b>Dataset</b>: {'<br>' + '<br>'.join(str(dataset).splitlines())}",
66+
f"<b>Item</b>: {item}",
67+
f"<b>Value</b>: {value}",
68+
]
69+
)
70+
information_selectable(parent, "Button Clicked", text)
71+
72+
dir = gds.DirectoryItem("Directory", osp.dirname(__file__))
73+
pattern = gds.StringItem("File pattern", "*.py")
74+
button = gds.ButtonItem("Help", button_cb, "MessageBoxInformation").set_pos(col=1)
75+
preview = gds.TextItem("File names preview")
76+
77+
78+
def test_button_item():
79+
"""Test button item"""
80+
with qt_app_context():
81+
prm = Parameters()
82+
execenv.print(prm)
83+
if prm.edit():
84+
execenv.print(prm)
85+
86+
87+
if __name__ == "__main__":
88+
test_button_item()

0 commit comments

Comments
 (0)