sciwidgets 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,81 @@
1
+ """Demo for the params subpackage: ``python -m sciwidgets.params``."""
2
+
3
+ import sys
4
+
5
+ from PySide6.QtWidgets import (
6
+ QApplication, QFormLayout, QLabel, QVBoxLayout, QWidget,
7
+ )
8
+
9
+ from sciwidgets.params.combo_box import ParameterComboBox
10
+ from sciwidgets.params.line_edit import ParameterLineEdit
11
+ from sciwidgets.params.numeric_line_edit import NumericLineEdit
12
+ from sciwidgets.params.toggle import ParameterToggle
13
+ from sciwidgets.style import apply_sci_style
14
+
15
+
16
+ def _demo_main() -> int:
17
+ """Run the params demo window.
18
+
19
+ Returns:
20
+ int: Qt application exit code.
21
+ """
22
+ app = QApplication(sys.argv)
23
+ apply_sci_style(app)
24
+
25
+ win = QWidget()
26
+ win.setWindowTitle("Params demo")
27
+ layout = QVBoxLayout(win)
28
+
29
+ # --- NumericLineEdit ------------------------------------------------
30
+ f_edit = NumericLineEdit(
31
+ init_val=1.0, val_fmt="{:0.3g}", min_limit=-100.0,
32
+ width_chars=11, max_length=10,
33
+ )
34
+ i_edit = NumericLineEdit(
35
+ init_val=1, value_is_int=True, min_limit=-100, max_limit=100,
36
+ width_chars=10, max_length=9,
37
+ )
38
+ committed = QLabel("")
39
+ f_edit.valueChanged.connect(lambda v: committed.setText(f"float: {v}"))
40
+ i_edit.valueChanged.connect(lambda v: committed.setText(f"int: {v}"))
41
+
42
+ form = QFormLayout()
43
+ form.addRow("Integer [-100, 100]:", i_edit)
44
+ form.addRow("Float [-100, ∞):", f_edit)
45
+ form.addRow("Last committed:", committed)
46
+ layout.addLayout(form)
47
+ # --- Simple parameter widgets ----------------------------------------
48
+ t_edit = ParameterLineEdit(
49
+ label="t", init_val=5, val_fmt="{:0.1f}", unit_label=r"\mathrm{s}",
50
+ min_limit=0.0, min_limit_inclusive=False, parent=win,
51
+ )
52
+ n_edit = ParameterLineEdit(label=r"A_\alpha", init_val=5, value_is_int=True, parent=win)
53
+ unit = ParameterComboBox(
54
+ label="t", items=["T₀", "d", "h", "m", "S", "ms", "µs", "ns"], parent=win,
55
+ )
56
+ unit.set_value("ns")
57
+ toggle = ParameterToggle(
58
+ text="Normalize to max",
59
+ label_on=r"F = I_\mathrm{res} / \max(I_\mathrm{res}) \times I_\mathrm{scale}",
60
+ label_off=r"F = I_\mathrm{res} \times I_\mathrm{scale}",
61
+ parent=win,
62
+ )
63
+ plain = ParameterToggle(text="Plain checkbox (no labels)", checked=False, parent=win)
64
+
65
+ # Align the name column the way a panel would
66
+ widgets = [t_edit, n_edit, unit, toggle, plain]
67
+ name_w = max(w.get_name_width() for w in widgets)
68
+ for w in widgets:
69
+ w.set_name_width(name_w)
70
+ w.valueChanged.connect(lambda v, w=w: print(type(w).__name__, v))
71
+ layout.addWidget(w)
72
+
73
+ layout.addStretch(1)
74
+
75
+ win.resize(520, 420)
76
+ win.show()
77
+ return app.exec()
78
+
79
+
80
+ if __name__ == "__main__":
81
+ raise SystemExit(_demo_main())
@@ -0,0 +1,220 @@
1
+ """Common base class for parameter widgets with value and config signals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Generic, TypeVar
6
+
7
+ from PySide6.QtCore import Signal
8
+ from PySide6.QtWidgets import QWidget
9
+
10
+ T = TypeVar("T")
11
+
12
+
13
+ class ParameterWidgetBase(QWidget, Generic[T]):
14
+ """Provide a common interface for parameter widgets.
15
+
16
+ Attributes:
17
+ valueChanged: Emitted when the user changes the value.
18
+ defaultChanged: Emitted when the user changes the default value.
19
+ configChanged: Emitted when the user changes the configuration.
20
+ valueWidthChanged: Emitted when the value display width changes.
21
+ nameWidthChanged: Emitted when the name display width changes.
22
+
23
+ Note:
24
+ ``valueChanged``, ``defaultChanged`` and ``configChanged`` are for
25
+ user actions only; programmatic setters such as ``set_value`` and
26
+ ``set_config`` do not emit them.
27
+ """
28
+
29
+ valueChanged = Signal(object)
30
+ defaultChanged = Signal(object)
31
+ configChanged = Signal(object)
32
+ valueWidthChanged = Signal(int)
33
+ nameWidthChanged = Signal(int)
34
+
35
+ def __init__(self, parent: QWidget | None = None) -> None:
36
+ """Initialize the base parameter widget.
37
+
38
+ Args:
39
+ parent: Optional parent widget.
40
+ """
41
+
42
+ super().__init__(parent)
43
+ self._name_width: int = 0 # requested via set_name_width
44
+ self._value_width: int = 0 # requested via set_value_width
45
+ self._measured_name_width: int = 0 # set by subclasses
46
+ self._measured_value_width: int = 0 # set by subclasses
47
+
48
+ def _update_layout(self) -> None:
49
+ """Update the widget layout to reflect the current internal state.
50
+
51
+ Subclasses should override this method when name or value width changes
52
+ require a visual layout update.
53
+ """
54
+
55
+ pass
56
+
57
+ def get_value(self) -> T:
58
+ """Return the current widget value.
59
+
60
+ Returns:
61
+ The current typed value stored by the widget.
62
+
63
+ Raises:
64
+ NotImplementedError: If the subclass does not implement value
65
+ retrieval.
66
+ """
67
+
68
+ raise NotImplementedError
69
+
70
+ def set_value(self, value: T) -> None:
71
+ """Validate and apply a new widget value.
72
+
73
+ Args:
74
+ value: New value to store in the widget.
75
+ """
76
+
77
+ validated = self._validate_value(value)
78
+ self._apply_value(validated)
79
+
80
+ def _validate_value(self, value: T) -> T:
81
+ """Validate a candidate value before applying it.
82
+
83
+ The default implementation returns the value unchanged. Subclasses can
84
+ override this method to enforce type, range, or domain-specific checks.
85
+
86
+ Args:
87
+ value: Candidate value to validate.
88
+
89
+ Returns:
90
+ Validated value, possibly normalized.
91
+ """
92
+
93
+ return value
94
+
95
+ def _apply_value(self, value: T) -> None:
96
+ """Apply a validated value to the widget state.
97
+
98
+ Args:
99
+ value: Previously validated value to store.
100
+
101
+ Raises:
102
+ NotImplementedError: If the subclass does not implement value
103
+ application.
104
+ """
105
+
106
+ raise NotImplementedError
107
+
108
+ def get_config(self) -> dict[str, Any]:
109
+ """Return the current configuration mapping.
110
+
111
+ Returns:
112
+ Configuration dictionary for the widget. The base implementation
113
+ returns an empty mapping.
114
+ """
115
+
116
+ return {}
117
+
118
+ def set_config(self, config: dict[str, Any]) -> None:
119
+ """Validate and apply configuration values.
120
+
121
+ Args:
122
+ config: Partial or complete configuration mapping to apply.
123
+ """
124
+
125
+ validated = self._validate_config(config)
126
+ self._apply_config(validated)
127
+
128
+ def _validate_config(self, config: dict[str, Any]) -> dict[str, Any]:
129
+ """Validate and normalize a configuration mapping.
130
+
131
+ The base implementation requires ``config`` to be a dictionary and
132
+ merges it with the current configuration, preserving existing values for
133
+ missing keys.
134
+
135
+ Args:
136
+ config: Candidate configuration mapping.
137
+
138
+ Returns:
139
+ Normalized configuration dictionary.
140
+
141
+ Raises:
142
+ TypeError: If ``config`` is not a dictionary.
143
+ """
144
+
145
+ if not isinstance(config, dict):
146
+ raise TypeError(f"config must be dict, got {type(config).__name__}")
147
+ old_config = self.get_config()
148
+ new_config = {}
149
+ for k, v in old_config.items():
150
+ if k not in config:
151
+ new_config[k] = v
152
+ else:
153
+ new_config[k] = config[k]
154
+ return new_config
155
+
156
+ def _apply_config(self, config: dict[str, Any]) -> None:
157
+ """Apply a validated configuration mapping.
158
+
159
+ Subclasses can override this method to store configuration state and
160
+ update the widget accordingly.
161
+
162
+ Args:
163
+ config: Previously validated configuration mapping.
164
+ """
165
+
166
+ _ = config
167
+
168
+ def get_name_width(self) -> int:
169
+ """Return the measured width of the name label.
170
+
171
+ Returns:
172
+ Width in pixels.
173
+ """
174
+ return self._measured_name_width
175
+
176
+ def get_value_width(self) -> int:
177
+ """Return the measured width of the value area.
178
+
179
+ Returns:
180
+ Width in pixels.
181
+ """
182
+ return self._measured_value_width
183
+
184
+ def _effective_name_width(self) -> int:
185
+ """Return the width the layout should reserve for the name label."""
186
+ return max(self._name_width, self._measured_name_width)
187
+
188
+ def _effective_value_width(self) -> int:
189
+ """Return the width the layout should reserve for the value area."""
190
+ return max(self._value_width, self._measured_value_width)
191
+
192
+ def set_name_width(self, width: int) -> None:
193
+ """Set a requested minimum width for the name label.
194
+
195
+ Used to align several widgets in a panel. The layout reserves the
196
+ larger of this value and the label's own measured width, so a smaller
197
+ request never cuts the label. ``get_name_width`` still returns the
198
+ measured width.
199
+
200
+ Args:
201
+ width: Requested minimum width in pixels.
202
+ """
203
+
204
+ self._name_width = int(width)
205
+ self._update_layout()
206
+
207
+ def set_value_width(self, width: int) -> None:
208
+ """Set a requested minimum width for the value area.
209
+
210
+ Used to align several widgets in a panel. The layout reserves the
211
+ larger of this value and the value area's own measured width, so a
212
+ smaller request never cuts the value. ``get_value_width`` still
213
+ returns the measured width.
214
+
215
+ Args:
216
+ width: Requested minimum width in pixels.
217
+ """
218
+
219
+ self._value_width = int(width)
220
+ self._update_layout()
@@ -0,0 +1,106 @@
1
+ """Discrete parameter widget backed by a combo box."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List
6
+
7
+ from PySide6.QtCore import Qt
8
+ from PySide6.QtWidgets import QComboBox, QGridLayout, QWidget
9
+
10
+ from ..labels import MathLabel
11
+ from .base import ParameterWidgetBase
12
+
13
+
14
+ class ParameterComboBox(ParameterWidgetBase[str]):
15
+ """Parameter widget that exposes a discrete choice through a combo box."""
16
+
17
+ def __init__(
18
+ self,
19
+ label: str,
20
+ items: List[str],
21
+ parent: QWidget | None = None,
22
+ ) -> None:
23
+ """Initialize the parameter combo-box widget.
24
+
25
+ Args:
26
+ label: Parameter name displayed to the left of the combo box.
27
+ items: Ordered list of selectable string values.
28
+ parent: Optional parent widget.
29
+ """
30
+
31
+ super().__init__(parent)
32
+
33
+ self._label = MathLabel(f"{label}=", alignment=Qt.AlignRight | Qt.AlignVCenter)
34
+ self._measured_name_width = self._label.sizeHint().width()
35
+ combo = QComboBox(self)
36
+ for v in items:
37
+ combo.addItem(v)
38
+ combo.activated.connect(self._on_activated)
39
+ self._combo = combo
40
+ self._last_value = combo.currentText()
41
+
42
+ self._make_layout()
43
+
44
+ def _make_layout(self) -> None:
45
+ """Create and assign the grid layout used by this widget."""
46
+
47
+ gl = QGridLayout(self)
48
+ gl.setSpacing(0)
49
+ gl.addWidget(self._label, 0, 0)
50
+ gl.addWidget(self._combo, 0, 1)
51
+ gl.setColumnStretch(0, 0)
52
+ gl.setColumnStretch(1, 0)
53
+ gl.setColumnStretch(2, 1)
54
+ self._gl = gl
55
+ self._update_layout()
56
+
57
+ def _update_layout(self) -> None:
58
+ """Reserve the effective name width for the label column."""
59
+
60
+ self._gl.setColumnMinimumWidth(0, self._effective_name_width())
61
+
62
+ def _on_activated(self, _index: int) -> None:
63
+ """Emit the value after the user picks a different item."""
64
+ value = self.get_value()
65
+ if value != self._last_value:
66
+ self._last_value = value
67
+ self.valueChanged.emit(value)
68
+
69
+ def get_value(self) -> str:
70
+ """Return the currently selected combo-box text.
71
+
72
+ Returns:
73
+ Selected string value.
74
+ """
75
+
76
+ return self._combo.currentText()
77
+
78
+ def _validate_value(self, value: str) -> str:
79
+ """Validate a value before applying it to the combo box.
80
+
81
+ Args:
82
+ value: Proposed combo-box text.
83
+
84
+ Returns:
85
+ Validated combo-box text.
86
+
87
+ Raises:
88
+ TypeError: If ``value`` is not a string.
89
+ ValueError: If no combo-box item matches ``value``.
90
+ """
91
+
92
+ if not isinstance(value, str):
93
+ raise TypeError("'value' must be a string")
94
+ if self._combo.findText(value) < 0:
95
+ raise ValueError(f"No combo item found for text={value!r}")
96
+ return value
97
+
98
+ def _apply_value(self, value: str) -> None:
99
+ """Apply a validated value by selecting the matching combo item.
100
+
101
+ Args:
102
+ value: Combo-box text to select.
103
+ """
104
+ self._combo.setCurrentIndex(self._combo.findText(value))
105
+ self._last_value = value
106
+
@@ -0,0 +1,132 @@
1
+ """Text and scalar parameter widget backed by a line edit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numbers
6
+ from typing import Optional, Union
7
+
8
+ from PySide6.QtCore import Qt
9
+ from PySide6.QtWidgets import QWidget, QGridLayout
10
+
11
+ from ..labels import MathLabel
12
+ from .base import ParameterWidgetBase
13
+ from .numeric_line_edit import NumericLineEdit
14
+
15
+ Num = Union[int, float]
16
+
17
+
18
+ class ParameterLineEdit(ParameterWidgetBase[Num]):
19
+ """Parameter widget that exposes a numeric value through a line edit."""
20
+
21
+ def __init__(
22
+ self,
23
+ label: str,
24
+ init_val: Num,
25
+ val_fmt: str = "{:0.6g}",
26
+ value_is_int: bool = False,
27
+ unit_label: str = "",
28
+ min_limit: Num | None = None,
29
+ max_limit: Num | None = None,
30
+ min_limit_inclusive: bool = True,
31
+ max_limit_inclusive: bool = True,
32
+ max_length: Optional[int] = None,
33
+ width_chars: Optional[int] = None,
34
+ parent: Optional[QWidget] = None,
35
+ ) -> None:
36
+ """Initialize the parameter line edit.
37
+
38
+ Args:
39
+ label: Parameter name displayed to the left of the line edit.
40
+ init_val: Initial value.
41
+ val_fmt: Display format string.
42
+ value_is_int: Whether the value is an integer.
43
+ unit_label: Optional unit shown to the right, as LaTeX.
44
+ min_limit: Optional lower hard limit.
45
+ max_limit: Optional upper hard limit.
46
+ min_limit_inclusive: Whether the lower hard limit is inclusive.
47
+ max_limit_inclusive: Whether the upper hard limit is inclusive.
48
+ max_length: Optional maximum number of characters.
49
+ width_chars: Optional width of the line edit in characters.
50
+ parent: Optional parent widget.
51
+ """
52
+
53
+ super().__init__(parent)
54
+
55
+ self._value_is_int = value_is_int
56
+ self._parse = int if value_is_int else float
57
+ self._label = MathLabel(f"{label}=", alignment=Qt.AlignRight | Qt.AlignVCenter)
58
+ self._measured_name_width = self._label.sizeHint().width()
59
+ self._unit_label: MathLabel | None = None
60
+ if unit_label:
61
+ self._unit_label = MathLabel(f"({unit_label})")
62
+ self._line_edit = NumericLineEdit(
63
+ init_val=init_val,
64
+ value_is_int=value_is_int,
65
+ val_fmt=val_fmt,
66
+ min_limit=min_limit,
67
+ max_limit=max_limit,
68
+ min_limit_inclusive=min_limit_inclusive,
69
+ max_limit_inclusive=max_limit_inclusive,
70
+ place_holder_text="Enter an int" if value_is_int else "Enter a float",
71
+ max_length=max_length,
72
+ width_chars=width_chars,
73
+ )
74
+ self._line_edit.valueChanged.connect(self._on_values_changed)
75
+
76
+ self._make_layout()
77
+
78
+ def _make_layout(self) -> None:
79
+ """Create the layout used by this widget."""
80
+
81
+ gl = QGridLayout(self)
82
+ gl.setSpacing(0)
83
+ gl.addWidget(self._label, 0, 0)
84
+ gl.addWidget(self._line_edit, 0, 1)
85
+ if self._unit_label is not None:
86
+ gl.addWidget(self._unit_label, 0, 2)
87
+ gl.setColumnStretch(3, 1)
88
+ else:
89
+ gl.setColumnStretch(2, 1)
90
+ self._gl = gl
91
+ self._update_layout()
92
+
93
+ def _update_layout(self) -> None:
94
+ """Reserve the effective name width for the label column."""
95
+
96
+ self._gl.setColumnMinimumWidth(0, self._effective_name_width())
97
+
98
+ # ----- internal helpers -----
99
+ def _on_values_changed(self) -> None:
100
+ """Handle the values changed event."""
101
+
102
+ self.valueChanged.emit(self.get_value())
103
+
104
+ # ----- public API -----
105
+ def get_value(self) -> Num:
106
+ """Return the current value."""
107
+
108
+ return self._line_edit.get_value()
109
+
110
+ def _validate_value(self, value: Num) -> Num:
111
+ """Validate a value before applying it.
112
+
113
+ Args:
114
+ value: Proposed value.
115
+
116
+ Returns:
117
+ Validated value.
118
+
119
+ Raises:
120
+ TypeError: If ``value`` is not a number, or not an integer on an
121
+ integer line edit. ``bool`` is rejected.
122
+ """
123
+ if isinstance(value, bool) or not isinstance(value, numbers.Real):
124
+ raise TypeError(f"value must be a number, not {type(value).__name__}")
125
+ if self._value_is_int and not isinstance(value, numbers.Integral):
126
+ raise TypeError(f"value must be an int, not {type(value).__name__}")
127
+ return value
128
+
129
+ def _apply_value(self, value: Num) -> None:
130
+ """Apply a validated value to the widget state."""
131
+
132
+ self._line_edit.set_value(self._parse(value))