setiastrosuitepro 1.6.0__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.
Files changed (174) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/saspro/__init__.py +20 -0
  3. setiastro/saspro/__main__.py +784 -0
  4. setiastro/saspro/_generated/__init__.py +7 -0
  5. setiastro/saspro/_generated/build_info.py +2 -0
  6. setiastro/saspro/abe.py +1295 -0
  7. setiastro/saspro/abe_preset.py +196 -0
  8. setiastro/saspro/aberration_ai.py +694 -0
  9. setiastro/saspro/aberration_ai_preset.py +224 -0
  10. setiastro/saspro/accel_installer.py +218 -0
  11. setiastro/saspro/accel_workers.py +30 -0
  12. setiastro/saspro/add_stars.py +621 -0
  13. setiastro/saspro/astrobin_exporter.py +1007 -0
  14. setiastro/saspro/astrospike.py +153 -0
  15. setiastro/saspro/astrospike_python.py +1839 -0
  16. setiastro/saspro/autostretch.py +196 -0
  17. setiastro/saspro/backgroundneutral.py +560 -0
  18. setiastro/saspro/batch_convert.py +325 -0
  19. setiastro/saspro/batch_renamer.py +519 -0
  20. setiastro/saspro/blemish_blaster.py +488 -0
  21. setiastro/saspro/blink_comparator_pro.py +2923 -0
  22. setiastro/saspro/bundles.py +61 -0
  23. setiastro/saspro/bundles_dock.py +114 -0
  24. setiastro/saspro/cheat_sheet.py +168 -0
  25. setiastro/saspro/clahe.py +342 -0
  26. setiastro/saspro/comet_stacking.py +1377 -0
  27. setiastro/saspro/config.py +38 -0
  28. setiastro/saspro/config_bootstrap.py +40 -0
  29. setiastro/saspro/config_manager.py +316 -0
  30. setiastro/saspro/continuum_subtract.py +1617 -0
  31. setiastro/saspro/convo.py +1397 -0
  32. setiastro/saspro/convo_preset.py +414 -0
  33. setiastro/saspro/copyastro.py +187 -0
  34. setiastro/saspro/cosmicclarity.py +1564 -0
  35. setiastro/saspro/cosmicclarity_preset.py +407 -0
  36. setiastro/saspro/crop_dialog_pro.py +948 -0
  37. setiastro/saspro/crop_preset.py +189 -0
  38. setiastro/saspro/curve_editor_pro.py +2544 -0
  39. setiastro/saspro/curves_preset.py +375 -0
  40. setiastro/saspro/debayer.py +670 -0
  41. setiastro/saspro/debug_utils.py +29 -0
  42. setiastro/saspro/dnd_mime.py +35 -0
  43. setiastro/saspro/doc_manager.py +2634 -0
  44. setiastro/saspro/exoplanet_detector.py +2166 -0
  45. setiastro/saspro/file_utils.py +284 -0
  46. setiastro/saspro/fitsmodifier.py +744 -0
  47. setiastro/saspro/free_torch_memory.py +48 -0
  48. setiastro/saspro/frequency_separation.py +1343 -0
  49. setiastro/saspro/function_bundle.py +1594 -0
  50. setiastro/saspro/ghs_dialog_pro.py +660 -0
  51. setiastro/saspro/ghs_preset.py +284 -0
  52. setiastro/saspro/graxpert.py +634 -0
  53. setiastro/saspro/graxpert_preset.py +287 -0
  54. setiastro/saspro/gui/__init__.py +0 -0
  55. setiastro/saspro/gui/main_window.py +8494 -0
  56. setiastro/saspro/gui/mixins/__init__.py +33 -0
  57. setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
  58. setiastro/saspro/gui/mixins/file_mixin.py +445 -0
  59. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  60. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  61. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  62. setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
  63. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  64. setiastro/saspro/gui/mixins/toolbar_mixin.py +1324 -0
  65. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  66. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  67. setiastro/saspro/halobgon.py +462 -0
  68. setiastro/saspro/header_viewer.py +445 -0
  69. setiastro/saspro/headless_utils.py +88 -0
  70. setiastro/saspro/histogram.py +753 -0
  71. setiastro/saspro/history_explorer.py +939 -0
  72. setiastro/saspro/image_combine.py +414 -0
  73. setiastro/saspro/image_peeker_pro.py +1596 -0
  74. setiastro/saspro/imageops/__init__.py +37 -0
  75. setiastro/saspro/imageops/mdi_snap.py +292 -0
  76. setiastro/saspro/imageops/scnr.py +36 -0
  77. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  78. setiastro/saspro/imageops/stretch.py +244 -0
  79. setiastro/saspro/isophote.py +1179 -0
  80. setiastro/saspro/layers.py +208 -0
  81. setiastro/saspro/layers_dock.py +714 -0
  82. setiastro/saspro/lazy_imports.py +193 -0
  83. setiastro/saspro/legacy/__init__.py +2 -0
  84. setiastro/saspro/legacy/image_manager.py +2226 -0
  85. setiastro/saspro/legacy/numba_utils.py +3659 -0
  86. setiastro/saspro/legacy/xisf.py +1071 -0
  87. setiastro/saspro/linear_fit.py +534 -0
  88. setiastro/saspro/live_stacking.py +1830 -0
  89. setiastro/saspro/log_bus.py +5 -0
  90. setiastro/saspro/logging_config.py +460 -0
  91. setiastro/saspro/luminancerecombine.py +309 -0
  92. setiastro/saspro/main_helpers.py +201 -0
  93. setiastro/saspro/mask_creation.py +928 -0
  94. setiastro/saspro/masks_core.py +56 -0
  95. setiastro/saspro/mdi_widgets.py +353 -0
  96. setiastro/saspro/memory_utils.py +666 -0
  97. setiastro/saspro/metadata_patcher.py +75 -0
  98. setiastro/saspro/mfdeconv.py +3826 -0
  99. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  100. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  101. setiastro/saspro/mfdeconvsport.py +2382 -0
  102. setiastro/saspro/minorbodycatalog.py +567 -0
  103. setiastro/saspro/morphology.py +382 -0
  104. setiastro/saspro/multiscale_decomp.py +1290 -0
  105. setiastro/saspro/nbtorgb_stars.py +531 -0
  106. setiastro/saspro/numba_utils.py +3044 -0
  107. setiastro/saspro/numba_warmup.py +141 -0
  108. setiastro/saspro/ops/__init__.py +9 -0
  109. setiastro/saspro/ops/command_help_dialog.py +623 -0
  110. setiastro/saspro/ops/command_runner.py +217 -0
  111. setiastro/saspro/ops/commands.py +1594 -0
  112. setiastro/saspro/ops/script_editor.py +1102 -0
  113. setiastro/saspro/ops/scripts.py +1413 -0
  114. setiastro/saspro/ops/settings.py +560 -0
  115. setiastro/saspro/parallel_utils.py +554 -0
  116. setiastro/saspro/pedestal.py +121 -0
  117. setiastro/saspro/perfect_palette_picker.py +1053 -0
  118. setiastro/saspro/pipeline.py +110 -0
  119. setiastro/saspro/pixelmath.py +1600 -0
  120. setiastro/saspro/plate_solver.py +2435 -0
  121. setiastro/saspro/project_io.py +797 -0
  122. setiastro/saspro/psf_utils.py +136 -0
  123. setiastro/saspro/psf_viewer.py +549 -0
  124. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  125. setiastro/saspro/remove_green.py +314 -0
  126. setiastro/saspro/remove_stars.py +1625 -0
  127. setiastro/saspro/remove_stars_preset.py +404 -0
  128. setiastro/saspro/resources.py +472 -0
  129. setiastro/saspro/rgb_combination.py +207 -0
  130. setiastro/saspro/rgb_extract.py +19 -0
  131. setiastro/saspro/rgbalign.py +723 -0
  132. setiastro/saspro/runtime_imports.py +7 -0
  133. setiastro/saspro/runtime_torch.py +754 -0
  134. setiastro/saspro/save_options.py +72 -0
  135. setiastro/saspro/selective_color.py +1552 -0
  136. setiastro/saspro/sfcc.py +1425 -0
  137. setiastro/saspro/shortcuts.py +2807 -0
  138. setiastro/saspro/signature_insert.py +1099 -0
  139. setiastro/saspro/stacking_suite.py +17712 -0
  140. setiastro/saspro/star_alignment.py +7420 -0
  141. setiastro/saspro/star_alignment_preset.py +329 -0
  142. setiastro/saspro/star_metrics.py +49 -0
  143. setiastro/saspro/star_spikes.py +681 -0
  144. setiastro/saspro/star_stretch.py +470 -0
  145. setiastro/saspro/stat_stretch.py +502 -0
  146. setiastro/saspro/status_log_dock.py +78 -0
  147. setiastro/saspro/subwindow.py +3267 -0
  148. setiastro/saspro/supernovaasteroidhunter.py +1712 -0
  149. setiastro/saspro/swap_manager.py +99 -0
  150. setiastro/saspro/torch_backend.py +89 -0
  151. setiastro/saspro/torch_rejection.py +434 -0
  152. setiastro/saspro/view_bundle.py +1555 -0
  153. setiastro/saspro/wavescale_hdr.py +624 -0
  154. setiastro/saspro/wavescale_hdr_preset.py +100 -0
  155. setiastro/saspro/wavescalede.py +657 -0
  156. setiastro/saspro/wavescalede_preset.py +228 -0
  157. setiastro/saspro/wcs_update.py +374 -0
  158. setiastro/saspro/whitebalance.py +456 -0
  159. setiastro/saspro/widgets/__init__.py +48 -0
  160. setiastro/saspro/widgets/common_utilities.py +305 -0
  161. setiastro/saspro/widgets/graphics_views.py +122 -0
  162. setiastro/saspro/widgets/image_utils.py +518 -0
  163. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  164. setiastro/saspro/widgets/spinboxes.py +275 -0
  165. setiastro/saspro/widgets/themed_buttons.py +13 -0
  166. setiastro/saspro/widgets/wavelet_utils.py +299 -0
  167. setiastro/saspro/window_shelf.py +185 -0
  168. setiastro/saspro/xisf.py +1123 -0
  169. setiastrosuitepro-1.6.0.dist-info/METADATA +266 -0
  170. setiastrosuitepro-1.6.0.dist-info/RECORD +174 -0
  171. setiastrosuitepro-1.6.0.dist-info/WHEEL +4 -0
  172. setiastrosuitepro-1.6.0.dist-info/entry_points.txt +6 -0
  173. setiastrosuitepro-1.6.0.dist-info/licenses/LICENSE +674 -0
  174. setiastrosuitepro-1.6.0.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,280 @@
1
+ # pro/widgets/preview_dialogs.py
2
+ """
3
+ Centralized preview dialog widgets for Seti Astro Suite Pro.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import numpy as np
8
+ from PyQt6.QtCore import Qt, QTimer, QPoint, QEvent
9
+ from PyQt6.QtGui import QImage, QPixmap, QIcon
10
+ from PyQt6.QtWidgets import (
11
+ QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
12
+ QScrollArea, QToolButton
13
+ )
14
+
15
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
16
+
17
+ # Import stretch functions - these are core to the app
18
+ from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
19
+
20
+ def _as_float01(img: np.ndarray) -> np.ndarray:
21
+ a = np.asarray(img)
22
+
23
+ # Integers: scale by dtype max
24
+ if a.dtype.kind in "ui":
25
+ maxv = float(np.iinfo(a.dtype).max)
26
+ if maxv <= 0:
27
+ return np.zeros_like(a, dtype=np.float32)
28
+ return (a.astype(np.float32) / maxv).clip(0.0, 1.0)
29
+
30
+ # Floats: if already 0..1, keep; otherwise scale down
31
+ af = a.astype(np.float32, copy=False)
32
+
33
+ mx = float(np.nanmax(af)) if af.size else 0.0
34
+ if not np.isfinite(mx) or mx <= 1.0:
35
+ return np.clip(af, 0.0, 1.0)
36
+
37
+ # Common “display buffers”: 0..255 or 0..65535 stored as float
38
+ if mx <= 255.5:
39
+ return np.clip(af / 255.0, 0.0, 1.0)
40
+ if mx <= 65535.5:
41
+ return np.clip(af / 65535.0, 0.0, 1.0)
42
+
43
+ # Fallback: normalize by max
44
+ return np.clip(af / mx, 0.0, 1.0)
45
+
46
+
47
+ class ImagePreviewDialog(QDialog):
48
+ """
49
+ A dialog for previewing images with autostretch and zoom capabilities.
50
+
51
+ Features:
52
+ - AutoStretch toggle
53
+ - Zoom in/out (buttons and mouse wheel)
54
+ - Scroll area for panning
55
+ - Supports mono and RGB images
56
+
57
+ Usage:
58
+ dialog = ImagePreviewDialog(np_image, is_mono=False)
59
+ dialog.exec()
60
+ """
61
+
62
+ def __init__(self, np_image: np.ndarray, is_mono: bool = False, parent=None):
63
+ super().__init__(parent)
64
+ self.setWindowTitle("Image Preview")
65
+ self.resize(640, 480)
66
+
67
+ self.autostretch_enabled = False
68
+ self.is_mono = is_mono
69
+ self.zoom_factor = 1.0
70
+ # Drag-to-pan state
71
+ self._panning = False
72
+ self._pan_last = QPoint()
73
+
74
+ # Store the image, ensure float32 [0,1]
75
+ self.np_image = _as_float01(np_image)
76
+
77
+ # Store the image, ensure float32 [0,1]
78
+ self.np_image = _as_float01(np_image)
79
+
80
+ # Layout
81
+ layout = QVBoxLayout(self)
82
+
83
+ # Toolbar row (themed)
84
+ bar = QHBoxLayout()
85
+
86
+ self.autostretch_button = QToolButton()
87
+ self.autostretch_button.setText("AutoStretch (Off)")
88
+ self.autostretch_button.setToolTip("Toggle AutoStretch")
89
+ self.autostretch_button.setCheckable(True)
90
+ self.autostretch_button.toggled.connect(self._toggle_autostretch)
91
+ bar.addWidget(self.autostretch_button)
92
+
93
+ bar.addStretch(1)
94
+
95
+ self.zoom_in_button = themed_toolbtn("zoom-in", "Zoom In")
96
+ self.zoom_out_button = themed_toolbtn("zoom-out", "Zoom Out")
97
+ self.zoom_1to1_button = themed_toolbtn("zoom-original", "1:1 (100%)")
98
+ self.fit_button = themed_toolbtn("zoom-fit-best", "Fit to Preview")
99
+
100
+ self.zoom_in_button.clicked.connect(self._zoom_in)
101
+ self.zoom_out_button.clicked.connect(self._zoom_out)
102
+ self.zoom_1to1_button.clicked.connect(self._one_to_one)
103
+ self.fit_button.clicked.connect(self._fit_to_preview)
104
+
105
+ bar.addWidget(self.zoom_in_button)
106
+ bar.addWidget(self.zoom_out_button)
107
+ bar.addWidget(self.zoom_1to1_button)
108
+ bar.addWidget(self.fit_button)
109
+
110
+ layout.addLayout(bar)
111
+
112
+ # Scroll area
113
+ self.scroll_area = QScrollArea(self)
114
+ self.scroll_area.setWidgetResizable(True)
115
+ layout.addWidget(self.scroll_area)
116
+
117
+ # Image label
118
+ self.image_label = QLabel()
119
+ self.scroll_area.setWidget(self.image_label)
120
+ self.image_label.installEventFilter(self)
121
+ self.scroll_area.viewport().installEventFilter(self)
122
+ self.image_label.setText("")
123
+ self.image_label.setMouseTracking(True)
124
+ # Display initial image
125
+ self._display_image(self.np_image)
126
+
127
+ # Enable mouse wheel zoom
128
+ self.image_label.installEventFilter(self)
129
+
130
+ # Center scrollbars after layout
131
+ QTimer.singleShot(0, self._fit_to_preview)
132
+
133
+ def _display_image(self, np_img: np.ndarray):
134
+ """Convert numpy array to QImage and display at current zoom."""
135
+ # Convert to uint8
136
+ arr = (np.clip(np_img, 0, 1) * 255).astype(np.uint8)
137
+
138
+ if arr.ndim == 3 and arr.shape[2] == 3:
139
+ h, w, _ = arr.shape
140
+ qimg = QImage(arr.tobytes(), w, h, 3 * w, QImage.Format.Format_RGB888)
141
+ elif arr.ndim == 2:
142
+ h, w = arr.shape
143
+ qimg = QImage(arr.tobytes(), w, h, w, QImage.Format.Format_Grayscale8)
144
+ elif arr.ndim == 3 and arr.shape[2] == 1:
145
+ arr = arr.squeeze()
146
+ h, w = arr.shape
147
+ qimg = QImage(arr.tobytes(), w, h, w, QImage.Format.Format_Grayscale8)
148
+ else:
149
+ raise ValueError(f"Unexpected image shape: {arr.shape}")
150
+
151
+ # Apply zoom
152
+ pixmap = QPixmap.fromImage(qimg)
153
+ scaled_w = int(pixmap.width() * self.zoom_factor)
154
+ scaled_h = int(pixmap.height() * self.zoom_factor)
155
+ scaled_pixmap = pixmap.scaled(
156
+ scaled_w, scaled_h,
157
+ Qt.AspectRatioMode.KeepAspectRatio,
158
+ Qt.TransformationMode.SmoothTransformation
159
+ )
160
+ self.image_label.setPixmap(scaled_pixmap)
161
+ self.image_label.adjustSize()
162
+
163
+ def _toggle_autostretch(self, checked: bool):
164
+ """Toggle autostretch on/off."""
165
+ self.autostretch_enabled = checked
166
+ self.autostretch_button.setText(
167
+ "AutoStretch (On)" if checked else "AutoStretch (Off)"
168
+ )
169
+ self._apply_display()
170
+
171
+ def _one_to_one(self):
172
+ self.zoom_factor = 1.0
173
+ self._apply_display()
174
+
175
+ def _fit_to_preview(self):
176
+ # Fit image into the scroll viewport
177
+ if self.image_label.pixmap() is None or self.image_label.pixmap().isNull():
178
+ return
179
+ vp = self.scroll_area.viewport().size()
180
+ pm = self.image_label.pixmap()
181
+ if pm.width() <= 0 or pm.height() <= 0:
182
+ return
183
+
184
+ # Compute zoom that fits current *source image* (not already scaled label)
185
+ # So, recompute based on original image dims:
186
+ base_h, base_w = self.np_image.shape[:2]
187
+ if base_w <= 0 or base_h <= 0:
188
+ return
189
+
190
+ zx = vp.width() / float(base_w)
191
+ zy = vp.height() / float(base_h)
192
+ self.zoom_factor = max(0.01, min(zx, zy))
193
+ self._apply_display()
194
+
195
+
196
+ def _apply_display(self):
197
+ """Apply current display settings (autostretch, zoom)."""
198
+ target_median = 0.25
199
+
200
+ if self.autostretch_enabled:
201
+ if self.np_image.ndim == 2:
202
+ stretched = stretch_mono_image(self.np_image, target_median)
203
+ display_img = np.stack([stretched] * 3, axis=-1)
204
+ elif self.np_image.ndim == 3 and self.np_image.shape[2] == 3:
205
+ display_img = stretch_color_image(self.np_image, target_median, linked=False)
206
+ elif self.np_image.ndim == 3 and self.np_image.shape[2] == 1:
207
+ stretched = stretch_mono_image(self.np_image.squeeze(), target_median)
208
+ display_img = np.stack([stretched] * 3, axis=-1)
209
+ else:
210
+ display_img = self.np_image
211
+ else:
212
+ if self.np_image.ndim == 2:
213
+ display_img = np.stack([self.np_image] * 3, axis=-1)
214
+ elif self.np_image.ndim == 3 and self.np_image.shape[2] == 1:
215
+ display_img = np.repeat(self.np_image, 3, axis=2)
216
+ else:
217
+ display_img = self.np_image
218
+
219
+ self._display_image(display_img)
220
+
221
+ def _zoom_in(self):
222
+ """Zoom in by 20%."""
223
+ self.zoom_factor *= 1.2
224
+ self._apply_display()
225
+
226
+ def _zoom_out(self):
227
+ """Zoom out by 20%."""
228
+ self.zoom_factor /= 1.2
229
+ self._apply_display()
230
+
231
+ def _center_scrollbars(self):
232
+ """Center the scroll area on the image."""
233
+ h_bar = self.scroll_area.horizontalScrollBar()
234
+ v_bar = self.scroll_area.verticalScrollBar()
235
+ h_bar.setValue((h_bar.maximum() + h_bar.minimum()) // 2)
236
+ v_bar.setValue((v_bar.maximum() + v_bar.minimum()) // 2)
237
+
238
+ def eventFilter(self, source, event):
239
+ # --- wheel zoom (keep exactly as you had) ---
240
+ if source in (self.image_label, self.scroll_area.viewport()) and event.type() == QEvent.Type.Wheel:
241
+ if event.angleDelta().y() > 0:
242
+ self._zoom_in()
243
+ else:
244
+ self._zoom_out()
245
+ return True
246
+
247
+ # --- drag-to-pan ---
248
+ if source in (self.image_label, self.scroll_area.viewport()):
249
+
250
+ if event.type() == QEvent.Type.MouseButtonPress and event.button() == Qt.MouseButton.LeftButton:
251
+ self._panning = True
252
+ self._pan_last = event.globalPosition().toPoint()
253
+ # nice UX
254
+ self.setCursor(Qt.CursorShape.ClosedHandCursor)
255
+ return True
256
+
257
+ if event.type() == QEvent.Type.MouseMove and self._panning:
258
+ cur = event.globalPosition().toPoint()
259
+ delta = cur - self._pan_last
260
+ self._pan_last = cur
261
+
262
+ h = self.scroll_area.horizontalScrollBar()
263
+ v = self.scroll_area.verticalScrollBar()
264
+ h.setValue(h.value() - delta.x())
265
+ v.setValue(v.value() - delta.y())
266
+ return True
267
+
268
+ if event.type() == QEvent.Type.MouseButtonRelease and event.button() == Qt.MouseButton.LeftButton and self._panning:
269
+ self._panning = False
270
+ self.unsetCursor()
271
+ return True
272
+
273
+ # if mouse leaves while dragging, stop panning so it doesn't get "stuck"
274
+ if event.type() == QEvent.Type.Leave and self._panning:
275
+ self._panning = False
276
+ self.unsetCursor()
277
+ return True
278
+
279
+ return super().eventFilter(source, event)
280
+
@@ -0,0 +1,275 @@
1
+ # pro/widgets/spinboxes.py
2
+ """
3
+ Custom spinbox widgets for Seti Astro Suite Pro.
4
+
5
+ Provides enhanced spinbox widgets with consistent styling and behavior.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from PyQt6.QtCore import Qt, pyqtSignal
10
+ from PyQt6.QtWidgets import (
11
+ QWidget, QHBoxLayout, QVBoxLayout, QLineEdit, QToolButton
12
+ )
13
+ from PyQt6.QtGui import QIntValidator, QDoubleValidator
14
+
15
+
16
+ class CustomSpinBox(QWidget):
17
+ """
18
+ A custom integer spin box widget with up/down buttons.
19
+
20
+ Emits valueChanged(int) when the value changes.
21
+
22
+ Usage:
23
+ spin = CustomSpinBox(minimum=0, maximum=100, initial=50, step=1)
24
+ spin.valueChanged.connect(my_handler)
25
+ """
26
+ valueChanged = pyqtSignal(int)
27
+
28
+ def __init__(
29
+ self,
30
+ minimum: int = 0,
31
+ maximum: int = 100,
32
+ initial: int = 0,
33
+ step: int = 1,
34
+ parent: QWidget | None = None
35
+ ):
36
+ super().__init__(parent)
37
+ self.minimum = minimum
38
+ self.maximum = maximum
39
+ self.step = step
40
+ self._value = initial
41
+
42
+ # Line edit for value display/entry
43
+ self.lineEdit = QLineEdit(str(initial))
44
+ self.lineEdit.setAlignment(Qt.AlignmentFlag.AlignRight)
45
+ self.lineEdit.setValidator(QIntValidator(self.minimum, self.maximum, self))
46
+ self.lineEdit.editingFinished.connect(self._on_editing_finished)
47
+
48
+ # Up/down buttons
49
+ self.upButton = QToolButton()
50
+ self.upButton.setText("▲")
51
+ self.upButton.setAutoRepeat(True)
52
+ self.upButton.setAutoRepeatInterval(50)
53
+ self.upButton.setAutoRepeatDelay(300)
54
+ self.upButton.clicked.connect(self._increase_value)
55
+
56
+ self.downButton = QToolButton()
57
+ self.downButton.setText("▼")
58
+ self.downButton.setAutoRepeat(True)
59
+ self.downButton.setAutoRepeatInterval(50)
60
+ self.downButton.setAutoRepeatDelay(300)
61
+ self.downButton.clicked.connect(self._decrease_value)
62
+
63
+ # Layout buttons vertically
64
+ button_layout = QVBoxLayout()
65
+ button_layout.addWidget(self.upButton)
66
+ button_layout.addWidget(self.downButton)
67
+ button_layout.setSpacing(0)
68
+ button_layout.setContentsMargins(0, 0, 0, 0)
69
+
70
+ # Main horizontal layout
71
+ main_layout = QHBoxLayout()
72
+ main_layout.addWidget(self.lineEdit)
73
+ main_layout.addLayout(button_layout)
74
+ main_layout.setSpacing(0)
75
+ main_layout.setContentsMargins(0, 0, 0, 0)
76
+ self.setLayout(main_layout)
77
+
78
+ self._update_button_states()
79
+
80
+ @property
81
+ def value(self) -> int:
82
+ """Get the current value."""
83
+ return self._value
84
+
85
+ def setValue(self, val: int) -> None:
86
+ """Set the value, clamping to min/max."""
87
+ val = max(self.minimum, min(self.maximum, val))
88
+ if val != self._value:
89
+ self._value = val
90
+ self.lineEdit.setText(str(val))
91
+ self.valueChanged.emit(val)
92
+ self._update_button_states()
93
+
94
+ def setMinimum(self, minimum: int) -> None:
95
+ """Set the minimum value."""
96
+ self.minimum = minimum
97
+ self.lineEdit.setValidator(QIntValidator(self.minimum, self.maximum, self))
98
+ if self._value < minimum:
99
+ self.setValue(minimum)
100
+ self._update_button_states()
101
+
102
+ def setMaximum(self, maximum: int) -> None:
103
+ """Set the maximum value."""
104
+ self.maximum = maximum
105
+ self.lineEdit.setValidator(QIntValidator(self.minimum, self.maximum, self))
106
+ if self._value > maximum:
107
+ self.setValue(maximum)
108
+ self._update_button_states()
109
+
110
+ def setRange(self, minimum: int, maximum: int) -> None:
111
+ """Set both minimum and maximum values."""
112
+ self.minimum = minimum
113
+ self.maximum = maximum
114
+ self.lineEdit.setValidator(QIntValidator(self.minimum, self.maximum, self))
115
+ self.setValue(max(minimum, min(maximum, self._value)))
116
+
117
+ def setSingleStep(self, step: int) -> None:
118
+ """Set the step value for up/down buttons."""
119
+ self.step = step
120
+
121
+ def _on_editing_finished(self) -> None:
122
+ """Handle manual text entry."""
123
+ try:
124
+ val = int(self.lineEdit.text())
125
+ self.setValue(val)
126
+ except ValueError:
127
+ self.lineEdit.setText(str(self._value))
128
+
129
+ def _increase_value(self) -> None:
130
+ """Increase value by step."""
131
+ self.setValue(self._value + self.step)
132
+
133
+ def _decrease_value(self) -> None:
134
+ """Decrease value by step."""
135
+ self.setValue(self._value - self.step)
136
+
137
+ def _update_button_states(self) -> None:
138
+ """Enable/disable buttons at limits."""
139
+ self.upButton.setEnabled(self._value < self.maximum)
140
+ self.downButton.setEnabled(self._value > self.minimum)
141
+
142
+
143
+ class CustomDoubleSpinBox(QWidget):
144
+ """
145
+ A custom double (float) spin box widget with up/down buttons.
146
+
147
+ Emits valueChanged(float) when the value changes.
148
+
149
+ Usage:
150
+ spin = CustomDoubleSpinBox(minimum=0.0, maximum=1.0, initial=0.5, step=0.1, decimals=2)
151
+ spin.valueChanged.connect(my_handler)
152
+ """
153
+ valueChanged = pyqtSignal(float)
154
+
155
+ def __init__(
156
+ self,
157
+ minimum: float = 0.0,
158
+ maximum: float = 100.0,
159
+ initial: float = 0.0,
160
+ step: float = 1.0,
161
+ decimals: int = 2,
162
+ parent: QWidget | None = None
163
+ ):
164
+ super().__init__(parent)
165
+ self.minimum = minimum
166
+ self.maximum = maximum
167
+ self.step = step
168
+ self.decimals = decimals
169
+ self._value = initial
170
+
171
+ # Line edit for value display/entry
172
+ self.lineEdit = QLineEdit(f"{initial:.{decimals}f}")
173
+ self.lineEdit.setAlignment(Qt.AlignmentFlag.AlignRight)
174
+ self.lineEdit.setValidator(QDoubleValidator(self.minimum, self.maximum, decimals, self))
175
+ self.lineEdit.editingFinished.connect(self._on_editing_finished)
176
+
177
+ # Up/down buttons
178
+ self.upButton = QToolButton()
179
+ self.upButton.setText("▲")
180
+ self.upButton.setAutoRepeat(True)
181
+ self.upButton.setAutoRepeatInterval(50)
182
+ self.upButton.setAutoRepeatDelay(300)
183
+ self.upButton.clicked.connect(self._increase_value)
184
+
185
+ self.downButton = QToolButton()
186
+ self.downButton.setText("▼")
187
+ self.downButton.setAutoRepeat(True)
188
+ self.downButton.setAutoRepeatInterval(50)
189
+ self.downButton.setAutoRepeatDelay(300)
190
+ self.downButton.clicked.connect(self._decrease_value)
191
+
192
+ # Layout buttons vertically
193
+ button_layout = QVBoxLayout()
194
+ button_layout.addWidget(self.upButton)
195
+ button_layout.addWidget(self.downButton)
196
+ button_layout.setSpacing(0)
197
+ button_layout.setContentsMargins(0, 0, 0, 0)
198
+
199
+ # Main horizontal layout
200
+ main_layout = QHBoxLayout()
201
+ main_layout.addWidget(self.lineEdit)
202
+ main_layout.addLayout(button_layout)
203
+ main_layout.setSpacing(0)
204
+ main_layout.setContentsMargins(0, 0, 0, 0)
205
+ self.setLayout(main_layout)
206
+
207
+ self._update_button_states()
208
+
209
+ @property
210
+ def value(self) -> float:
211
+ """Get the current value."""
212
+ return self._value
213
+
214
+ def setValue(self, val: float) -> None:
215
+ """Set the value, clamping to min/max."""
216
+ val = max(self.minimum, min(self.maximum, val))
217
+ if abs(val - self._value) > 1e-10:
218
+ self._value = val
219
+ self.lineEdit.setText(f"{val:.{self.decimals}f}")
220
+ self.valueChanged.emit(val)
221
+ self._update_button_states()
222
+
223
+ def setMinimum(self, minimum: float) -> None:
224
+ """Set the minimum value."""
225
+ self.minimum = minimum
226
+ self.lineEdit.setValidator(QDoubleValidator(self.minimum, self.maximum, self.decimals, self))
227
+ if self._value < minimum:
228
+ self.setValue(minimum)
229
+ self._update_button_states()
230
+
231
+ def setMaximum(self, maximum: float) -> None:
232
+ """Set the maximum value."""
233
+ self.maximum = maximum
234
+ self.lineEdit.setValidator(QDoubleValidator(self.minimum, self.maximum, self.decimals, self))
235
+ if self._value > maximum:
236
+ self.setValue(maximum)
237
+ self._update_button_states()
238
+
239
+ def setRange(self, minimum: float, maximum: float) -> None:
240
+ """Set both minimum and maximum values."""
241
+ self.minimum = minimum
242
+ self.maximum = maximum
243
+ self.lineEdit.setValidator(QDoubleValidator(self.minimum, self.maximum, self.decimals, self))
244
+ self.setValue(max(minimum, min(maximum, self._value)))
245
+
246
+ def setSingleStep(self, step: float) -> None:
247
+ """Set the step value for up/down buttons."""
248
+ self.step = step
249
+
250
+ def setDecimals(self, decimals: int) -> None:
251
+ """Set the number of decimal places."""
252
+ self.decimals = decimals
253
+ self.lineEdit.setText(f"{self._value:.{decimals}f}")
254
+ self.lineEdit.setValidator(QDoubleValidator(self.minimum, self.maximum, decimals, self))
255
+
256
+ def _on_editing_finished(self) -> None:
257
+ """Handle manual text entry."""
258
+ try:
259
+ val = float(self.lineEdit.text())
260
+ self.setValue(val)
261
+ except ValueError:
262
+ self.lineEdit.setText(f"{self._value:.{self.decimals}f}")
263
+
264
+ def _increase_value(self) -> None:
265
+ """Increase value by step."""
266
+ self.setValue(self._value + self.step)
267
+
268
+ def _decrease_value(self) -> None:
269
+ """Decrease value by step."""
270
+ self.setValue(self._value - self.step)
271
+
272
+ def _update_button_states(self) -> None:
273
+ """Enable/disable buttons at limits."""
274
+ self.upButton.setEnabled(self._value < self.maximum - 1e-10)
275
+ self.downButton.setEnabled(self._value > self.minimum + 1e-10)
@@ -0,0 +1,13 @@
1
+ # pro/widgets/themed_buttons.py
2
+ from __future__ import annotations
3
+ from PyQt6.QtGui import QIcon
4
+ from PyQt6.QtWidgets import QToolButton
5
+
6
+ def themed_toolbtn(icon_name: str, tip: str, *, on_click=None) -> QToolButton:
7
+ b = QToolButton()
8
+ b.setIcon(QIcon.fromTheme(icon_name))
9
+ b.setToolTip(tip)
10
+ b.setAutoRaise(True) # nice flat toolbar look
11
+ if on_click is not None:
12
+ b.clicked.connect(on_click)
13
+ return b