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,592 @@
1
+ """Shared base for labeled slider widgets (single-value and range sliders)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import contextmanager
6
+ from enum import Flag, auto
7
+ from typing import Any, Dict, Generic, List, Optional, TypeVar
8
+
9
+ from PySide6.QtCore import QEvent, Qt
10
+ from PySide6.QtWidgets import QGridLayout, QLabel, QMenu, QSizePolicy, QVBoxLayout, QWidget
11
+
12
+ from ...formatting import get_numeric_format_field, is_sci_format, value_to_text
13
+ from ...labels import MathLabel
14
+ from ..base import ParameterWidgetBase
15
+ from ._defaults import SLIDER_BORDER_STYLE_SHEET, SLIDER_LABEL_SIZE, SliderBorderState
16
+ from ._right_click_overlay import RightClickOverlay
17
+
18
+ T = TypeVar("T")
19
+
20
+
21
+
22
+ class SliderFeature(Flag):
23
+ """Optional context-menu features of a slider.
24
+
25
+ Each flag decides whether a group of menu items is offered at all.
26
+ Sliders ignore flags that do not apply to them.
27
+
28
+ Attributes:
29
+ NONE: No menu and no default marker.
30
+ DEFAULTS: "Save as Default" / "Reset to Default" and the default marker.
31
+ SET_VALUE: "Set Value..." (or "Set Values..." on range sliders).
32
+ CONFIG: "Edit Configuration...".
33
+ SPECIAL: "Set Value to ±inf" (still subject to limits).
34
+ VALUES: "Show Values..." (``MultiVariableSlider`` only).
35
+ MARGINS: "Use Values" / "Use Margins" / "Set Margins..." (range
36
+ sliders only).
37
+ ALL: All of the above.
38
+ """
39
+ NONE = 0
40
+ DEFAULTS = auto()
41
+ SET_VALUE = auto()
42
+ CONFIG = auto()
43
+ SPECIAL = auto()
44
+ VALUES = auto()
45
+ MARGINS = auto()
46
+ ALL = DEFAULTS | SET_VALUE | CONFIG | SPECIAL | VALUES | MARGINS
47
+
48
+ @classmethod
49
+ def from_json(cls, data: str | list[str] | None) -> "SliderFeature":
50
+ """Build flags from a name, a list of names, or None (= ALL).
51
+
52
+ Args:
53
+ data: A feature name, a list of names, or None.
54
+
55
+ Returns:
56
+ Combined feature flags; ``ALL`` when ``data`` is None.
57
+
58
+ Raises:
59
+ ValueError: If a name is not a known feature.
60
+ """
61
+ if data is None:
62
+ return cls.ALL
63
+ names = [data] if isinstance(data, str) else data
64
+ result = cls.NONE
65
+ for name in names:
66
+ try:
67
+ result |= cls[name.strip().upper()]
68
+ except KeyError:
69
+ valid = ", ".join(m.name for m in cls)
70
+ raise ValueError(f"unknown slider feature {name!r}; valid: {valid}") from None
71
+ return result
72
+
73
+
74
+ class SliderWidgetBase(ParameterWidgetBase[T], Generic[T]):
75
+ """Common frame for labeled sliders: label, inner slider, value display, menu.
76
+
77
+ The base owns everything that does not depend on the kind of slider: the
78
+ border, the context menu, the right-click overlay, the grid layout, range
79
+ labels, format handling and name/value width bookkeeping. Subclasses build
80
+ their inner slider and value display and fill in a few hooks.
81
+
82
+ Subclass ``__init__`` order:
83
+ 1. ``super().__init__(...)``
84
+ 2. set up own state and the value-display widgets
85
+ 3. ``self._attach_slider(inner_slider)``
86
+ 4. ``self.set_format(val_fmt)``
87
+ 5. ``self._make_layout()``
88
+
89
+ Signals ``valueChanged``, ``defaultChanged`` and ``configChanged`` are
90
+ emitted only for user actions (dragging, context menu, dialogs).
91
+ Programmatic setters never emit them.
92
+
93
+ Widths: ``set_name_width`` / ``set_value_width`` store a requested minimum
94
+ (for aligning sliders in a panel); the measured widths are kept separately
95
+ and the layout uses the larger of the two. ``get_name_width`` /
96
+ ``get_value_width`` return the measured widths.
97
+ """
98
+
99
+ _RANGE_MARGIN_H = 8
100
+ """Slider top margin (px) when range labels are shown, horizontal layout."""
101
+ _RANGE_MARGIN_V = 7
102
+ """Slider left margin (px) when range labels are shown, vertical layout."""
103
+
104
+ def __init__(
105
+ self,
106
+ label: str,
107
+ orientation: Qt.Orientation,
108
+ val_fmt: str,
109
+ show_range: bool,
110
+ features: SliderFeature,
111
+ parent: Optional[QWidget],
112
+ ):
113
+ """Initialize the common slider frame.
114
+
115
+ Args:
116
+ label: Slider label shown next to the control.
117
+ orientation: Slider orientation.
118
+ val_fmt: Value display format string.
119
+ show_range: Initial visibility of range labels and ticks; change
120
+ later with ``set_show_range``.
121
+ features: Context-menu features offered by the slider.
122
+ parent: Optional parent widget.
123
+ """
124
+ super().__init__(parent)
125
+
126
+ self.setObjectName("labeled_slider")
127
+ self.setAttribute(Qt.WA_StyledBackground, True)
128
+ self.setStyleSheet(SLIDER_BORDER_STYLE_SHEET)
129
+ self.set_border(SliderBorderState.OFF)
130
+
131
+ self._label_text = label
132
+ self._orient = orientation
133
+ self._show_range = show_range
134
+ self._val_fmt = val_fmt
135
+ self._eval_fmt = get_numeric_format_field(val_fmt)
136
+ self._use_sci_html = False
137
+ self._right_click_enable = True
138
+ self._quiet = 0
139
+ self._features = features
140
+
141
+ self._slider: QWidget | None = None
142
+ self._right_click_overlay: RightClickOverlay | None = None
143
+ self._default_red_marker: QWidget | None = None
144
+ self._gl: QGridLayout | None = None
145
+ self._sl: QVBoxLayout | None = None
146
+
147
+ self._name_label = MathLabel(label, parent=self, font_size=SLIDER_LABEL_SIZE)
148
+ self._min_label = QLabel()
149
+ self._max_label = QLabel()
150
+ self._min_label.setObjectName("slider_range")
151
+ self._max_label.setObjectName("slider_range")
152
+
153
+ self._right_click_items: List[Dict[str, Any]] = []
154
+ if SliderFeature.DEFAULTS in features:
155
+ self._right_click_items += [
156
+ {"id": "save_as_default", "text": "Save as Default"},
157
+ {"id": "reset_to_default", "text": "Reset to Default"},
158
+ ]
159
+
160
+ self._measured_name_width = self._name_label.sizeHint().width()
161
+
162
+ # ----- hooks for subclasses -----
163
+ def _make_default_marker(self) -> QWidget:
164
+ """Create the overlay that marks the default value(s) on the slider.
165
+
166
+ Called by ``_attach_slider`` only when ``DEFAULTS`` is set.
167
+
168
+ Returns:
169
+ Marker overlay widget (must provide ``update()``).
170
+ """
171
+ raise NotImplementedError
172
+
173
+ def _value_widget(self) -> QWidget:
174
+ """Return the widget showing the current value (and unit).
175
+
176
+ Returns:
177
+ Value-display widget placed in the layout.
178
+ """
179
+ raise NotImplementedError
180
+
181
+ def _align_value_labels(self, horizontal: bool) -> None:
182
+ """Set the alignment of the value label(s) for the orientation.
183
+
184
+ Args:
185
+ horizontal: Whether the layout is horizontal.
186
+ """
187
+
188
+ def _value_text_labels(self) -> List[QLabel]:
189
+ """Return the labels that switch between plain and rich text.
190
+
191
+ Returns:
192
+ Labels whose text format follows the value format.
193
+ """
194
+ return [self._min_label, self._max_label]
195
+
196
+ def _round_values_to_format(self) -> None:
197
+ """Round the stored values to the current format (called by ``set_format``)."""
198
+
199
+ def _apply_range_visibility(self, show: bool) -> None:
200
+ """Show or hide the inner slider's ticks.
201
+
202
+ Args:
203
+ show: Whether range ticks should be shown.
204
+ """
205
+
206
+ def _update_value_width(self) -> None:
207
+ """Measure the value display and call ``_set_measured_value_width``."""
208
+ raise NotImplementedError
209
+
210
+ # ----- setup helpers for subclasses -----
211
+ def _attach_slider(self, slider: QWidget) -> None:
212
+ """Register the inner slider and create its overlays.
213
+
214
+ Args:
215
+ slider: The inner slider widget.
216
+ """
217
+ self._slider = slider
218
+ self._right_click_overlay = RightClickOverlay(
219
+ target_widget=slider,
220
+ owner_widget=self,
221
+ parent=self,
222
+ )
223
+ self._right_click_overlay.hide()
224
+ if SliderFeature.DEFAULTS in self._features:
225
+ self._default_red_marker = self._make_default_marker()
226
+
227
+ def _make_layout(self) -> None:
228
+ """Build the grid layout for the current orientation."""
229
+ gl = QGridLayout(self)
230
+ gl.setContentsMargins(0, 0, 0, 0)
231
+
232
+ slider_wrap = QWidget()
233
+ sl = QVBoxLayout(slider_wrap)
234
+ sl.setContentsMargins(0, 0, 0, 0)
235
+ sl.setSpacing(0)
236
+ sl.addWidget(self._slider)
237
+
238
+ value_widget = self._value_widget()
239
+
240
+ if self._orient == Qt.Horizontal:
241
+ gl.setVerticalSpacing(0)
242
+ common = Qt.AlignVCenter
243
+ self._name_label.setAlignment(common | Qt.AlignRight)
244
+ self._align_value_labels(True)
245
+ gl.addWidget(self._name_label, 0, 0, common | Qt.AlignRight)
246
+ gl.addWidget(slider_wrap, 0, 1, 1, 3, common)
247
+ gl.addWidget(value_widget, 0, 4, common | Qt.AlignLeft)
248
+ gl.addWidget(self._min_label, 1, 1)
249
+ gl.addWidget(self._max_label, 1, 3)
250
+
251
+ self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
252
+ gl.setColumnStretch(2, 1)
253
+ else:
254
+ gl.setHorizontalSpacing(0)
255
+ gl.setVerticalSpacing(2)
256
+ common = Qt.AlignHCenter
257
+ self._align_value_labels(False)
258
+ self._name_label.setAlignment(common | Qt.AlignTop)
259
+
260
+ self._max_label.setContentsMargins(5, 0, 0, 0)
261
+ self._min_label.setContentsMargins(5, 0, 0, 1)
262
+
263
+ gl.addWidget(value_widget, 0, 0, 1, 3, common | Qt.AlignBottom)
264
+ gl.addWidget(slider_wrap, 1, 1, 3, 1, common)
265
+ gl.addWidget(self._name_label, 4, 0, 1, 3, common | Qt.AlignTop)
266
+ gl.addWidget(self._max_label, 1, 2, 1, 2)
267
+ gl.addWidget(self._min_label, 3, 2, 1, 2)
268
+
269
+ self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
270
+ gl.setRowStretch(2, 1)
271
+
272
+ self._gl = gl
273
+ self._sl = sl
274
+ self._sync_overlay()
275
+
276
+ def _set_measured_value_width(self, new_w: int) -> None:
277
+ """Store a newly measured value width, relayout, and report changes.
278
+
279
+ Args:
280
+ new_w: Measured width of the value display in pixels.
281
+ """
282
+ old_w = self._measured_value_width
283
+ self._measured_value_width = new_w
284
+ self._update_layout()
285
+ if old_w != new_w:
286
+ self.valueWidthChanged.emit(new_w)
287
+
288
+ # ----- internal helpers -----
289
+ def _sync_overlay(self) -> None:
290
+ """Keep the right-click overlay aligned with the inner slider."""
291
+ if self._right_click_overlay is not None:
292
+ self._right_click_overlay.sync_to_target()
293
+
294
+ @contextmanager
295
+ def _quiet_updates(self):
296
+ """Suppress user-action signals while code moves the slider."""
297
+ self._quiet += 1
298
+ try:
299
+ yield
300
+ finally:
301
+ self._quiet -= 1
302
+
303
+ def _update_eval_fmt(self) -> None:
304
+ """Build a safe numeric format used for internal value normalization."""
305
+ self._eval_fmt = get_numeric_format_field(self._val_fmt)
306
+
307
+ def _value_to_text(self, value: Any) -> str:
308
+ """Format a value for display.
309
+
310
+ Args:
311
+ value: Value to format.
312
+
313
+ Returns:
314
+ Formatted display string.
315
+ """
316
+ return value_to_text(value=value, fmt=self._val_fmt)
317
+
318
+ def _update_layout(self) -> None:
319
+ """Apply range visibility and width constraints to the layout."""
320
+ if self._gl is None:
321
+ return
322
+
323
+ show_range = self._show_range
324
+ self._apply_range_visibility(show_range)
325
+ self._min_label.setVisible(show_range)
326
+ self._max_label.setVisible(show_range)
327
+
328
+ name_w = self._effective_name_width()
329
+ value_w = self._effective_value_width()
330
+
331
+ if self._orient == Qt.Horizontal:
332
+ self._sl.setContentsMargins(0, self._RANGE_MARGIN_H if show_range else 0, 0, 0)
333
+ self._gl.setColumnMinimumWidth(0, name_w)
334
+ self._gl.setColumnMinimumWidth(4, value_w)
335
+ else:
336
+ self._sl.setContentsMargins(self._RANGE_MARGIN_V if show_range else 0, 0, 0, 0)
337
+ width = max(name_w, value_w)
338
+ slider_width = self._sl.sizeHint().width()
339
+ self._gl.setColumnMinimumWidth(0, max(10, (width - slider_width) // 2))
340
+ self._gl.setColumnMinimumWidth(1, slider_width)
341
+ self._gl.setColumnMinimumWidth(2, max(10, (width - slider_width) // 2))
342
+
343
+ self._gl.invalidate()
344
+ widget = self._gl.parentWidget()
345
+ if widget:
346
+ widget.updateGeometry()
347
+ self._sync_overlay()
348
+
349
+ # ----- context menu -----
350
+ def _set_right_click_item_checked(self, act_id, checked: bool) -> bool:
351
+ """Update the stored checked state of a context-menu item.
352
+
353
+ Args:
354
+ act_id: Menu item identifier.
355
+ checked: Checked state to store.
356
+
357
+ Returns:
358
+ ``True`` if the item was found, otherwise ``False``.
359
+ """
360
+ for item in self._right_click_items:
361
+ if item.get("id") == act_id and act_id != "sep":
362
+ item["checkable"] = bool(item.get("checkable", True))
363
+ item["checked"] = bool(checked)
364
+ return True
365
+ return False
366
+
367
+ def _set_right_click_item_enabled(self, act_id, enabled: bool) -> bool:
368
+ """Update the stored enabled state of a context-menu item.
369
+
370
+ Args:
371
+ act_id: Menu item identifier.
372
+ enabled: Enabled state to store.
373
+
374
+ Returns:
375
+ ``True`` if the item was found, otherwise ``False``.
376
+ """
377
+ for item in self._right_click_items:
378
+ if item.get("id") == act_id and act_id != "sep":
379
+ item["enabled"] = bool(enabled)
380
+ return True
381
+ return False
382
+
383
+ def _right_click_requested(self, item_id, checked: bool) -> None:
384
+ """Dispatch common context-menu actions.
385
+
386
+ Args:
387
+ item_id: Triggered menu item identifier.
388
+ checked: Checked state associated with the action.
389
+ """
390
+ if item_id == "save_as_default":
391
+ self.save_current_value_as_default_value()
392
+ elif item_id == "reset_to_default":
393
+ self._reset_to_default_value()
394
+
395
+ def save_current_value_as_default_value(self) -> None:
396
+ """Store the current value as the default value (user action)."""
397
+
398
+ def _reset_to_default_value(self) -> None:
399
+ """Reset the control to its stored default value (user action)."""
400
+
401
+ def _show_context_menu(self, global_pos) -> None:
402
+ """Build and display the context menu.
403
+
404
+ Args:
405
+ global_pos: Global screen position where the menu should open.
406
+ """
407
+ if not self._right_click_enable:
408
+ return
409
+ if not any(item.get("id") != "sep" for item in self._right_click_items):
410
+ return
411
+
412
+ self.set_border(SliderBorderState.NORMAL)
413
+ self.repaint()
414
+ try:
415
+ menu = QMenu()
416
+ menu.setSeparatorsCollapsible(True)
417
+ item_by_id = {}
418
+
419
+ for item in self._right_click_items:
420
+ act_id = item.get("id")
421
+ if act_id == "sep":
422
+ menu.addSeparator()
423
+ continue
424
+
425
+ text = str(item.get("text", act_id if act_id is not None else "None"))
426
+ act = menu.addAction(text)
427
+ act.setEnabled(bool(item.get("enabled", True)))
428
+ checkable = bool(item.get("checkable", False))
429
+ act.setCheckable(checkable)
430
+ if checkable:
431
+ act.setChecked(bool(item.get("checked", False)))
432
+ act.setData(act_id)
433
+ item_by_id[act_id] = item
434
+
435
+ action = menu.exec(global_pos)
436
+ if action is None:
437
+ return
438
+
439
+ act_id = action.data()
440
+ if act_id is None:
441
+ act_id = action.text()
442
+
443
+ new_checked = False
444
+ if action.isCheckable():
445
+ old_checked = bool(item_by_id.get(act_id, {}).get("checked", action.isChecked()))
446
+ new_checked = bool(action.isChecked())
447
+ if new_checked == old_checked:
448
+ new_checked = not old_checked
449
+ action.setChecked(new_checked)
450
+ if act_id in item_by_id:
451
+ item_by_id[act_id]["checked"] = new_checked
452
+
453
+ self._right_click_requested(act_id, new_checked)
454
+ finally:
455
+ self.set_border(SliderBorderState.OFF)
456
+
457
+ # ----- public API -----
458
+ def set_format(self, val_fmt: str) -> None:
459
+ """Set the display format and round stored values to it (no signals).
460
+
461
+ Args:
462
+ val_fmt: New format string.
463
+ """
464
+ self._val_fmt = val_fmt
465
+ self._update_eval_fmt()
466
+ self._round_values_to_format()
467
+
468
+ self._use_sci_html = is_sci_format(val_fmt)
469
+ text_fmt = Qt.RichText if self._use_sci_html else Qt.PlainText
470
+ for label in self._value_text_labels():
471
+ label.setTextFormat(text_fmt)
472
+
473
+ def set_border(self, state: SliderBorderState) -> None:
474
+ """Set the visual border state of the widget.
475
+
476
+ Args:
477
+ state: Border state enum value.
478
+ """
479
+ self.setProperty("borderState", state.value)
480
+ self.style().unpolish(self)
481
+ self.style().polish(self)
482
+ self.update()
483
+
484
+ def set_right_click_enabled(self, state: bool) -> None:
485
+ """Enable or disable the whole context menu at runtime.
486
+
487
+ Args:
488
+ state: Whether right-click actions should be enabled.
489
+ """
490
+ self._right_click_enable = state
491
+
492
+ def set_slider_enabled(self, enabled: bool) -> None:
493
+ """Enable or disable the inner slider widget.
494
+
495
+ Args:
496
+ enabled: Whether the inner slider should be enabled.
497
+ """
498
+ self._slider.setEnabled(enabled)
499
+ self._sync_overlay()
500
+
501
+ def set_show_range(self, show: bool) -> None:
502
+ """Show or hide the range labels and ticks.
503
+
504
+ Args:
505
+ show: Whether range labels and ticks should be shown.
506
+ """
507
+ show = bool(show)
508
+ if show == self._show_range:
509
+ return
510
+ self._show_range = show
511
+ self._update_layout()
512
+
513
+ def set_label(self, label: str) -> None:
514
+ """Set the displayed slider label.
515
+
516
+ Args:
517
+ label: New slider label text.
518
+ """
519
+ self._label_text = label
520
+ self._name_label.set_text(label)
521
+ self._measured_name_width = self._name_label.sizeHint().width()
522
+ self._update_layout()
523
+ self.nameWidthChanged.emit(self._measured_name_width)
524
+
525
+ def get_value_width(self) -> int:
526
+ """Re-measure and return the width of the value display.
527
+
528
+ Returns:
529
+ Width in pixels.
530
+ """
531
+ was_blocked = self.blockSignals(True)
532
+ try:
533
+ self._update_value_width()
534
+ finally:
535
+ self.blockSignals(was_blocked)
536
+ return self._measured_value_width
537
+
538
+ # ----- Qt events -----
539
+ def contextMenuEvent(self, event) -> None:
540
+ """Open the context menu.
541
+
542
+ Args:
543
+ event: Qt context-menu event.
544
+ """
545
+ self._show_context_menu(event.globalPos())
546
+
547
+ def resizeEvent(self, event) -> None:
548
+ """Keep the overlay aligned after a resize.
549
+
550
+ Args:
551
+ event: Qt resize event.
552
+ """
553
+ super().resizeEvent(event)
554
+ self._sync_overlay()
555
+
556
+ def showEvent(self, event) -> None:
557
+ """Keep the overlay aligned after the widget is shown.
558
+
559
+ Args:
560
+ event: Qt show event.
561
+ """
562
+ super().showEvent(event)
563
+ self._sync_overlay()
564
+
565
+ def moveEvent(self, event) -> None:
566
+ """Keep the overlay aligned after a move.
567
+
568
+ Args:
569
+ event: Qt move event.
570
+ """
571
+ super().moveEvent(event)
572
+ self._sync_overlay()
573
+
574
+ def changeEvent(self, event) -> None:
575
+ """Handle enabled-state changes and refresh overlays.
576
+
577
+ The inner slider's enabled state is deliberately not touched here:
578
+ Qt propagates it from the parent, and modes that lock the slider
579
+ (±inf, margins) rely on its explicit disable surviving a parent
580
+ re-enable.
581
+
582
+ Args:
583
+ event: Qt change event.
584
+ """
585
+ super().changeEvent(event)
586
+ if event.type() == QEvent.EnabledChange:
587
+ self._sync_overlay()
588
+ if self._default_red_marker is not None:
589
+ self._default_red_marker.update()
590
+ if not self.isEnabled():
591
+ self.set_border(SliderBorderState.OFF)
592
+ self.update()