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.
Potentially problematic release.
This version of setiastrosuitepro might be problematic. Click here for more details.
- setiastro/__init__.py +2 -0
- setiastro/saspro/__init__.py +20 -0
- setiastro/saspro/__main__.py +784 -0
- setiastro/saspro/_generated/__init__.py +7 -0
- setiastro/saspro/_generated/build_info.py +2 -0
- setiastro/saspro/abe.py +1295 -0
- setiastro/saspro/abe_preset.py +196 -0
- setiastro/saspro/aberration_ai.py +694 -0
- setiastro/saspro/aberration_ai_preset.py +224 -0
- setiastro/saspro/accel_installer.py +218 -0
- setiastro/saspro/accel_workers.py +30 -0
- setiastro/saspro/add_stars.py +621 -0
- setiastro/saspro/astrobin_exporter.py +1007 -0
- setiastro/saspro/astrospike.py +153 -0
- setiastro/saspro/astrospike_python.py +1839 -0
- setiastro/saspro/autostretch.py +196 -0
- setiastro/saspro/backgroundneutral.py +560 -0
- setiastro/saspro/batch_convert.py +325 -0
- setiastro/saspro/batch_renamer.py +519 -0
- setiastro/saspro/blemish_blaster.py +488 -0
- setiastro/saspro/blink_comparator_pro.py +2923 -0
- setiastro/saspro/bundles.py +61 -0
- setiastro/saspro/bundles_dock.py +114 -0
- setiastro/saspro/cheat_sheet.py +168 -0
- setiastro/saspro/clahe.py +342 -0
- setiastro/saspro/comet_stacking.py +1377 -0
- setiastro/saspro/config.py +38 -0
- setiastro/saspro/config_bootstrap.py +40 -0
- setiastro/saspro/config_manager.py +316 -0
- setiastro/saspro/continuum_subtract.py +1617 -0
- setiastro/saspro/convo.py +1397 -0
- setiastro/saspro/convo_preset.py +414 -0
- setiastro/saspro/copyastro.py +187 -0
- setiastro/saspro/cosmicclarity.py +1564 -0
- setiastro/saspro/cosmicclarity_preset.py +407 -0
- setiastro/saspro/crop_dialog_pro.py +948 -0
- setiastro/saspro/crop_preset.py +189 -0
- setiastro/saspro/curve_editor_pro.py +2544 -0
- setiastro/saspro/curves_preset.py +375 -0
- setiastro/saspro/debayer.py +670 -0
- setiastro/saspro/debug_utils.py +29 -0
- setiastro/saspro/dnd_mime.py +35 -0
- setiastro/saspro/doc_manager.py +2634 -0
- setiastro/saspro/exoplanet_detector.py +2166 -0
- setiastro/saspro/file_utils.py +284 -0
- setiastro/saspro/fitsmodifier.py +744 -0
- setiastro/saspro/free_torch_memory.py +48 -0
- setiastro/saspro/frequency_separation.py +1343 -0
- setiastro/saspro/function_bundle.py +1594 -0
- setiastro/saspro/ghs_dialog_pro.py +660 -0
- setiastro/saspro/ghs_preset.py +284 -0
- setiastro/saspro/graxpert.py +634 -0
- setiastro/saspro/graxpert_preset.py +287 -0
- setiastro/saspro/gui/__init__.py +0 -0
- setiastro/saspro/gui/main_window.py +8494 -0
- setiastro/saspro/gui/mixins/__init__.py +33 -0
- setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
- setiastro/saspro/gui/mixins/file_mixin.py +445 -0
- setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
- setiastro/saspro/gui/mixins/header_mixin.py +441 -0
- setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
- setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
- setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +1324 -0
- setiastro/saspro/gui/mixins/update_mixin.py +309 -0
- setiastro/saspro/gui/mixins/view_mixin.py +435 -0
- setiastro/saspro/halobgon.py +462 -0
- setiastro/saspro/header_viewer.py +445 -0
- setiastro/saspro/headless_utils.py +88 -0
- setiastro/saspro/histogram.py +753 -0
- setiastro/saspro/history_explorer.py +939 -0
- setiastro/saspro/image_combine.py +414 -0
- setiastro/saspro/image_peeker_pro.py +1596 -0
- setiastro/saspro/imageops/__init__.py +37 -0
- setiastro/saspro/imageops/mdi_snap.py +292 -0
- setiastro/saspro/imageops/scnr.py +36 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
- setiastro/saspro/imageops/stretch.py +244 -0
- setiastro/saspro/isophote.py +1179 -0
- setiastro/saspro/layers.py +208 -0
- setiastro/saspro/layers_dock.py +714 -0
- setiastro/saspro/lazy_imports.py +193 -0
- setiastro/saspro/legacy/__init__.py +2 -0
- setiastro/saspro/legacy/image_manager.py +2226 -0
- setiastro/saspro/legacy/numba_utils.py +3659 -0
- setiastro/saspro/legacy/xisf.py +1071 -0
- setiastro/saspro/linear_fit.py +534 -0
- setiastro/saspro/live_stacking.py +1830 -0
- setiastro/saspro/log_bus.py +5 -0
- setiastro/saspro/logging_config.py +460 -0
- setiastro/saspro/luminancerecombine.py +309 -0
- setiastro/saspro/main_helpers.py +201 -0
- setiastro/saspro/mask_creation.py +928 -0
- setiastro/saspro/masks_core.py +56 -0
- setiastro/saspro/mdi_widgets.py +353 -0
- setiastro/saspro/memory_utils.py +666 -0
- setiastro/saspro/metadata_patcher.py +75 -0
- setiastro/saspro/mfdeconv.py +3826 -0
- setiastro/saspro/mfdeconv_earlystop.py +71 -0
- setiastro/saspro/mfdeconvcudnn.py +3263 -0
- setiastro/saspro/mfdeconvsport.py +2382 -0
- setiastro/saspro/minorbodycatalog.py +567 -0
- setiastro/saspro/morphology.py +382 -0
- setiastro/saspro/multiscale_decomp.py +1290 -0
- setiastro/saspro/nbtorgb_stars.py +531 -0
- setiastro/saspro/numba_utils.py +3044 -0
- setiastro/saspro/numba_warmup.py +141 -0
- setiastro/saspro/ops/__init__.py +9 -0
- setiastro/saspro/ops/command_help_dialog.py +623 -0
- setiastro/saspro/ops/command_runner.py +217 -0
- setiastro/saspro/ops/commands.py +1594 -0
- setiastro/saspro/ops/script_editor.py +1102 -0
- setiastro/saspro/ops/scripts.py +1413 -0
- setiastro/saspro/ops/settings.py +560 -0
- setiastro/saspro/parallel_utils.py +554 -0
- setiastro/saspro/pedestal.py +121 -0
- setiastro/saspro/perfect_palette_picker.py +1053 -0
- setiastro/saspro/pipeline.py +110 -0
- setiastro/saspro/pixelmath.py +1600 -0
- setiastro/saspro/plate_solver.py +2435 -0
- setiastro/saspro/project_io.py +797 -0
- setiastro/saspro/psf_utils.py +136 -0
- setiastro/saspro/psf_viewer.py +549 -0
- setiastro/saspro/pyi_rthook_astroquery.py +95 -0
- setiastro/saspro/remove_green.py +314 -0
- setiastro/saspro/remove_stars.py +1625 -0
- setiastro/saspro/remove_stars_preset.py +404 -0
- setiastro/saspro/resources.py +472 -0
- setiastro/saspro/rgb_combination.py +207 -0
- setiastro/saspro/rgb_extract.py +19 -0
- setiastro/saspro/rgbalign.py +723 -0
- setiastro/saspro/runtime_imports.py +7 -0
- setiastro/saspro/runtime_torch.py +754 -0
- setiastro/saspro/save_options.py +72 -0
- setiastro/saspro/selective_color.py +1552 -0
- setiastro/saspro/sfcc.py +1425 -0
- setiastro/saspro/shortcuts.py +2807 -0
- setiastro/saspro/signature_insert.py +1099 -0
- setiastro/saspro/stacking_suite.py +17712 -0
- setiastro/saspro/star_alignment.py +7420 -0
- setiastro/saspro/star_alignment_preset.py +329 -0
- setiastro/saspro/star_metrics.py +49 -0
- setiastro/saspro/star_spikes.py +681 -0
- setiastro/saspro/star_stretch.py +470 -0
- setiastro/saspro/stat_stretch.py +502 -0
- setiastro/saspro/status_log_dock.py +78 -0
- setiastro/saspro/subwindow.py +3267 -0
- setiastro/saspro/supernovaasteroidhunter.py +1712 -0
- setiastro/saspro/swap_manager.py +99 -0
- setiastro/saspro/torch_backend.py +89 -0
- setiastro/saspro/torch_rejection.py +434 -0
- setiastro/saspro/view_bundle.py +1555 -0
- setiastro/saspro/wavescale_hdr.py +624 -0
- setiastro/saspro/wavescale_hdr_preset.py +100 -0
- setiastro/saspro/wavescalede.py +657 -0
- setiastro/saspro/wavescalede_preset.py +228 -0
- setiastro/saspro/wcs_update.py +374 -0
- setiastro/saspro/whitebalance.py +456 -0
- setiastro/saspro/widgets/__init__.py +48 -0
- setiastro/saspro/widgets/common_utilities.py +305 -0
- setiastro/saspro/widgets/graphics_views.py +122 -0
- setiastro/saspro/widgets/image_utils.py +518 -0
- setiastro/saspro/widgets/preview_dialogs.py +280 -0
- setiastro/saspro/widgets/spinboxes.py +275 -0
- setiastro/saspro/widgets/themed_buttons.py +13 -0
- setiastro/saspro/widgets/wavelet_utils.py +299 -0
- setiastro/saspro/window_shelf.py +185 -0
- setiastro/saspro/xisf.py +1123 -0
- setiastrosuitepro-1.6.0.dist-info/METADATA +266 -0
- setiastrosuitepro-1.6.0.dist-info/RECORD +174 -0
- setiastrosuitepro-1.6.0.dist-info/WHEEL +4 -0
- setiastrosuitepro-1.6.0.dist-info/entry_points.txt +6 -0
- setiastrosuitepro-1.6.0.dist-info/licenses/LICENSE +674 -0
- setiastrosuitepro-1.6.0.dist-info/licenses/license.txt +2580 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# pro/masks_core.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
import cv2
|
|
8
|
+
except Exception:
|
|
9
|
+
cv2 = None
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class MaskLayer:
|
|
13
|
+
id: str
|
|
14
|
+
name: str
|
|
15
|
+
data: np.ndarray # HxW float32 in [0..1]
|
|
16
|
+
invert: bool = False
|
|
17
|
+
opacity: float = 1.0 # 0..1
|
|
18
|
+
mode: str = "affect" # "affect" or "protect"
|
|
19
|
+
visible: bool = True
|
|
20
|
+
|
|
21
|
+
def _resize_mask(mask: np.ndarray, shape_hw: tuple[int,int]) -> np.ndarray:
|
|
22
|
+
h, w = shape_hw
|
|
23
|
+
if mask.shape[:2] == (h, w):
|
|
24
|
+
return mask.astype(np.float32, copy=False)
|
|
25
|
+
if cv2 is not None:
|
|
26
|
+
return cv2.resize(mask.astype(np.float32, copy=False), (w, h), interpolation=cv2.INTER_LINEAR)
|
|
27
|
+
# Pure-numpy fallback (nearest)
|
|
28
|
+
y = (np.linspace(0, mask.shape[0]-1, h)).astype(np.int32)
|
|
29
|
+
x = (np.linspace(0, mask.shape[1]-1, w)).astype(np.int32)
|
|
30
|
+
return mask[y][:, x].astype(np.float32, copy=False)
|
|
31
|
+
|
|
32
|
+
def blend_with_mask(original: np.ndarray,
|
|
33
|
+
edited: np.ndarray,
|
|
34
|
+
layer: MaskLayer | None) -> np.ndarray:
|
|
35
|
+
if layer is None:
|
|
36
|
+
return edited
|
|
37
|
+
m = _resize_mask(layer.data, original.shape[:2])
|
|
38
|
+
m = np.clip(m, 0.0, 1.0)
|
|
39
|
+
if layer.mode == "protect":
|
|
40
|
+
m = 1.0 - m
|
|
41
|
+
if layer.invert:
|
|
42
|
+
m = 1.0 - m
|
|
43
|
+
m = m * float(max(0.0, min(1.0, layer.opacity)))
|
|
44
|
+
|
|
45
|
+
# Shape/broadcast safety
|
|
46
|
+
o = original
|
|
47
|
+
e = edited
|
|
48
|
+
if e.ndim == 2 and o.ndim == 3:
|
|
49
|
+
e = np.repeat(e[..., None], o.shape[2], axis=2)
|
|
50
|
+
if o.ndim == 2 and e.ndim == 3:
|
|
51
|
+
o = np.repeat(o[..., None], e.shape[2], axis=2)
|
|
52
|
+
if m.ndim == 2 and e.ndim == 3:
|
|
53
|
+
m = m[..., None]
|
|
54
|
+
|
|
55
|
+
return (e.astype(np.float32, copy=False) * m +
|
|
56
|
+
o.astype(np.float32, copy=False) * (1.0 - m)).astype(e.dtype, copy=False)
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# pro/mdi_widgets.py
|
|
2
|
+
"""
|
|
3
|
+
MDI-related widgets and supporting classes extracted from main file.
|
|
4
|
+
|
|
5
|
+
Contains:
|
|
6
|
+
- MdiArea: Custom QMdiArea with drag-and-drop support
|
|
7
|
+
- ViewLinkController: Synchronizes view transforms across linked windows
|
|
8
|
+
- ConsoleListWidget: QListWidget with context menu for console output
|
|
9
|
+
- QtLogStream: QObject to mirror stdout/stderr to Qt signals
|
|
10
|
+
- _DocProxy: Lightweight proxy for ROI/preview document resolution
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import weakref
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from PyQt6.QtWidgets import QMdiArea, QListWidget, QMenu, QApplication
|
|
18
|
+
from PyQt6.QtCore import Qt, pyqtSignal, QObject
|
|
19
|
+
|
|
20
|
+
from setiastro.saspro.dnd_mime import (
|
|
21
|
+
MIME_VIEWSTATE, MIME_CMD, MIME_MASK, MIME_ASTROMETRY, MIME_LINKVIEW
|
|
22
|
+
)
|
|
23
|
+
from setiastro.saspro.shortcuts import _unpack_cmd_payload
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MdiArea(QMdiArea):
|
|
27
|
+
"""
|
|
28
|
+
Custom QMdiArea with support for drag-and-drop of:
|
|
29
|
+
- View states (for duplicating views)
|
|
30
|
+
- Commands with presets (from shortcut toolbar)
|
|
31
|
+
- Masks
|
|
32
|
+
- Astrometry data
|
|
33
|
+
- Link view payloads
|
|
34
|
+
"""
|
|
35
|
+
backgroundDoubleClicked = pyqtSignal()
|
|
36
|
+
viewStateDropped = pyqtSignal(dict, object) # (state_dict, target_subwindow or None)
|
|
37
|
+
commandDropped = pyqtSignal(dict, object) # ({"command_id","preset"}, target_subwindow or None)
|
|
38
|
+
maskDropped = pyqtSignal(dict, object) # (payload, target_subwindow or None)
|
|
39
|
+
astrometryDropped = pyqtSignal(dict, object)
|
|
40
|
+
linkViewDropped = pyqtSignal(dict, object)
|
|
41
|
+
|
|
42
|
+
def __init__(self, *args, **kwargs):
|
|
43
|
+
super().__init__(*args, **kwargs)
|
|
44
|
+
self.setAcceptDrops(True)
|
|
45
|
+
|
|
46
|
+
def dragEnterEvent(self, e):
|
|
47
|
+
md = e.mimeData()
|
|
48
|
+
if (md.hasFormat(MIME_VIEWSTATE)
|
|
49
|
+
or md.hasFormat(MIME_CMD)
|
|
50
|
+
or md.hasFormat(MIME_MASK)
|
|
51
|
+
or md.hasFormat(MIME_ASTROMETRY)
|
|
52
|
+
or md.hasFormat(MIME_LINKVIEW)):
|
|
53
|
+
e.acceptProposedAction()
|
|
54
|
+
else:
|
|
55
|
+
super().dragEnterEvent(e)
|
|
56
|
+
|
|
57
|
+
def dropEvent(self, e):
|
|
58
|
+
pos = e.position().toPoint()
|
|
59
|
+
|
|
60
|
+
# Map the event position from the MdiArea into the viewport's coords
|
|
61
|
+
vp = self.viewport()
|
|
62
|
+
vp_pos = vp.mapFrom(self, pos) if vp is not None else pos
|
|
63
|
+
|
|
64
|
+
# Get subwindows in real z-order (back → front)
|
|
65
|
+
try:
|
|
66
|
+
order_enum = getattr(QMdiArea, "WindowOrder", None)
|
|
67
|
+
subwins = self.subWindowList(order_enum.StackingOrder) if order_enum else self.subWindowList()
|
|
68
|
+
except Exception:
|
|
69
|
+
subwins = self.subWindowList()
|
|
70
|
+
|
|
71
|
+
# Pick the visually top-most window under the cursor
|
|
72
|
+
target = None
|
|
73
|
+
for sw in reversed(subwins): # reversed: front-most first
|
|
74
|
+
if sw.isVisible() and sw.geometry().contains(vp_pos):
|
|
75
|
+
target = sw
|
|
76
|
+
break
|
|
77
|
+
|
|
78
|
+
# 1) View-state payload
|
|
79
|
+
if e.mimeData().hasFormat(MIME_VIEWSTATE):
|
|
80
|
+
try:
|
|
81
|
+
raw = bytes(e.mimeData().data(MIME_VIEWSTATE))
|
|
82
|
+
state = json.loads(raw.decode("utf-8"))
|
|
83
|
+
except Exception:
|
|
84
|
+
e.ignore()
|
|
85
|
+
return
|
|
86
|
+
self.viewStateDropped.emit(state, target)
|
|
87
|
+
e.acceptProposedAction()
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
# 2) Command + preset payload (from shortcuts)
|
|
91
|
+
if e.mimeData().hasFormat(MIME_CMD):
|
|
92
|
+
try:
|
|
93
|
+
payload = _unpack_cmd_payload(bytes(e.mimeData().data(MIME_CMD)))
|
|
94
|
+
except Exception:
|
|
95
|
+
e.ignore()
|
|
96
|
+
return
|
|
97
|
+
self.commandDropped.emit(payload, target)
|
|
98
|
+
e.acceptProposedAction()
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
# 3) Mask payload (from subwindow DnD)
|
|
102
|
+
if e.mimeData().hasFormat(MIME_MASK):
|
|
103
|
+
try:
|
|
104
|
+
raw = bytes(e.mimeData().data(MIME_MASK))
|
|
105
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
106
|
+
except Exception:
|
|
107
|
+
e.ignore()
|
|
108
|
+
return
|
|
109
|
+
self.maskDropped.emit(payload, target)
|
|
110
|
+
e.acceptProposedAction()
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
# 4) Astrometric payload (from subwindow DnD)
|
|
114
|
+
if e.mimeData().hasFormat(MIME_ASTROMETRY):
|
|
115
|
+
try:
|
|
116
|
+
raw = bytes(e.mimeData().data(MIME_ASTROMETRY))
|
|
117
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
118
|
+
except Exception:
|
|
119
|
+
e.ignore()
|
|
120
|
+
return
|
|
121
|
+
self.astrometryDropped.emit(payload, target)
|
|
122
|
+
e.acceptProposedAction()
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
# 5) Link view payload
|
|
126
|
+
if e.mimeData().hasFormat(MIME_LINKVIEW):
|
|
127
|
+
try:
|
|
128
|
+
raw = bytes(e.mimeData().data(MIME_LINKVIEW))
|
|
129
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
130
|
+
except Exception:
|
|
131
|
+
e.ignore()
|
|
132
|
+
return
|
|
133
|
+
self.linkViewDropped.emit(payload, target)
|
|
134
|
+
e.acceptProposedAction()
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
# Fallback
|
|
138
|
+
super().dropEvent(e)
|
|
139
|
+
|
|
140
|
+
def mouseDoubleClickEvent(self, event):
|
|
141
|
+
pt = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
|
142
|
+
for sw in self.subWindowList():
|
|
143
|
+
if sw.geometry().contains(pt):
|
|
144
|
+
return super().mouseDoubleClickEvent(event)
|
|
145
|
+
self.backgroundDoubleClicked.emit()
|
|
146
|
+
event.accept()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ViewLinkController:
|
|
150
|
+
"""
|
|
151
|
+
Controller to synchronize view transforms (zoom, scroll) across linked windows.
|
|
152
|
+
|
|
153
|
+
Views can be assigned to named groups. When one view's transform changes,
|
|
154
|
+
all other views in the same group are updated to match.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def __init__(self, mdi: QMdiArea):
|
|
158
|
+
self.mdi = mdi
|
|
159
|
+
self.groups: dict[str, set] = {} # name -> set(views)
|
|
160
|
+
self.by_view: dict = {} # view -> group name
|
|
161
|
+
self._slots: dict = {} # view -> callable
|
|
162
|
+
self._broadcasting = False
|
|
163
|
+
|
|
164
|
+
def attach_view(self, view):
|
|
165
|
+
"""Connect a view's transform change signal to the controller."""
|
|
166
|
+
if view in self._slots:
|
|
167
|
+
return
|
|
168
|
+
slot = lambda scale, h, v, vref=view: self._on_view_transform_from(vref, scale, h, v)
|
|
169
|
+
view.viewTransformChanged.connect(slot)
|
|
170
|
+
self._slots[view] = slot
|
|
171
|
+
|
|
172
|
+
def detach_view(self, view):
|
|
173
|
+
"""Disconnect and remove a view from any group."""
|
|
174
|
+
slot = self._slots.pop(view, None)
|
|
175
|
+
if slot:
|
|
176
|
+
try:
|
|
177
|
+
view.viewTransformChanged.disconnect(slot)
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
g = self.by_view.pop(view, None)
|
|
181
|
+
if g and g in self.groups:
|
|
182
|
+
self.groups[g].discard(view)
|
|
183
|
+
if not self.groups[g]:
|
|
184
|
+
self.groups.pop(g, None)
|
|
185
|
+
|
|
186
|
+
def set_view_group(self, view, name_or_none: Optional[str]):
|
|
187
|
+
"""Assign a view to a named group, or remove from groups if None."""
|
|
188
|
+
old = self.by_view.pop(view, None)
|
|
189
|
+
if old and old in self.groups:
|
|
190
|
+
self.groups[old].discard(view)
|
|
191
|
+
if not self.groups[old]:
|
|
192
|
+
self.groups.pop(old, None)
|
|
193
|
+
if name_or_none:
|
|
194
|
+
self.groups.setdefault(name_or_none, set()).add(view)
|
|
195
|
+
self.by_view[view] = name_or_none
|
|
196
|
+
|
|
197
|
+
def group_of(self, view) -> Optional[str]:
|
|
198
|
+
"""Return the group name for a view, or None."""
|
|
199
|
+
return self.by_view.get(view)
|
|
200
|
+
|
|
201
|
+
def _on_view_transform_from(self, src_view, scale: float, hval: float, vval: float):
|
|
202
|
+
"""Handle transform change from a source view - broadcast to group."""
|
|
203
|
+
if self._broadcasting:
|
|
204
|
+
return
|
|
205
|
+
g = self.by_view.get(src_view)
|
|
206
|
+
if not g:
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
self._broadcasting = True
|
|
210
|
+
try:
|
|
211
|
+
for tgt in tuple(self.groups.get(g, ())):
|
|
212
|
+
if tgt is src_view:
|
|
213
|
+
continue
|
|
214
|
+
try:
|
|
215
|
+
# Skip deleted / half-torn-down views
|
|
216
|
+
from PyQt6 import sip as _sip
|
|
217
|
+
if _sip.isdeleted(tgt):
|
|
218
|
+
continue
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
hb = tgt.scroll.horizontalScrollBar().value()
|
|
223
|
+
vb = tgt.scroll.verticalScrollBar().value()
|
|
224
|
+
if abs(scale - tgt.scale) < 1e-9 and int(hval) == hb and int(vval) == vb:
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
tgt.set_view_transform(scale, hval, vval, from_link=True)
|
|
229
|
+
except Exception as ex:
|
|
230
|
+
print("[link] apply failed:", ex)
|
|
231
|
+
finally:
|
|
232
|
+
self._broadcasting = False
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class ConsoleListWidget(QListWidget):
|
|
236
|
+
"""
|
|
237
|
+
QListWidget with a context menu for console output:
|
|
238
|
+
- Select All
|
|
239
|
+
- Copy Selected
|
|
240
|
+
- Copy All
|
|
241
|
+
- Clear
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
def __init__(self, parent=None):
|
|
245
|
+
super().__init__(parent)
|
|
246
|
+
self.setContextMenuPolicy(Qt.ContextMenuPolicy.DefaultContextMenu)
|
|
247
|
+
|
|
248
|
+
def _selected_lines(self) -> list[str]:
|
|
249
|
+
return [itm.text() for itm in self.selectedItems()]
|
|
250
|
+
|
|
251
|
+
def _all_lines(self) -> list[str]:
|
|
252
|
+
return [self.item(i).text() for i in range(self.count())]
|
|
253
|
+
|
|
254
|
+
def _copy_text(self, lines: list[str]):
|
|
255
|
+
if not lines:
|
|
256
|
+
return
|
|
257
|
+
text = "\n".join(lines)
|
|
258
|
+
cb = QApplication.clipboard()
|
|
259
|
+
cb.setText(text)
|
|
260
|
+
|
|
261
|
+
def contextMenuEvent(self, event):
|
|
262
|
+
menu = QMenu(self)
|
|
263
|
+
|
|
264
|
+
act_select_all = menu.addAction("Select All")
|
|
265
|
+
act_copy_sel = menu.addAction("Copy Selected")
|
|
266
|
+
act_copy_all = menu.addAction("Copy All")
|
|
267
|
+
menu.addSeparator()
|
|
268
|
+
act_clear = menu.addAction("Clear")
|
|
269
|
+
|
|
270
|
+
action = menu.exec(event.globalPos())
|
|
271
|
+
if action is None:
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
if action is act_select_all:
|
|
275
|
+
self.selectAll()
|
|
276
|
+
elif action is act_copy_sel:
|
|
277
|
+
self._copy_text(self._selected_lines())
|
|
278
|
+
elif action is act_copy_all:
|
|
279
|
+
self._copy_text(self._all_lines())
|
|
280
|
+
elif action is act_clear:
|
|
281
|
+
self.clear()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class QtLogStream(QObject):
|
|
285
|
+
"""
|
|
286
|
+
QObject that intercepts writes (e.g., from stdout/stderr) and emits
|
|
287
|
+
them as Qt signals, while still forwarding to the original stream.
|
|
288
|
+
"""
|
|
289
|
+
text_emitted = pyqtSignal(str)
|
|
290
|
+
|
|
291
|
+
def __init__(self, orig_stream, parent=None):
|
|
292
|
+
super().__init__(parent)
|
|
293
|
+
self._orig = orig_stream
|
|
294
|
+
|
|
295
|
+
def write(self, text: str):
|
|
296
|
+
# Still write to the original stream
|
|
297
|
+
try:
|
|
298
|
+
if self._orig is not None:
|
|
299
|
+
self._orig.write(text)
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
|
302
|
+
# Mirror into Qt
|
|
303
|
+
if text:
|
|
304
|
+
self.text_emitted.emit(text)
|
|
305
|
+
|
|
306
|
+
def flush(self):
|
|
307
|
+
try:
|
|
308
|
+
if self._orig is not None:
|
|
309
|
+
self._orig.flush()
|
|
310
|
+
except Exception:
|
|
311
|
+
pass
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class _DocProxy:
|
|
315
|
+
"""
|
|
316
|
+
Lightweight proxy that always resolves to the current document
|
|
317
|
+
for a view (ROI when a Preview/ROI tab is active, else base doc).
|
|
318
|
+
All attribute gets/sets forward to the currently-active target.
|
|
319
|
+
"""
|
|
320
|
+
__slots__ = ("_dm", "_view_ref", "_base_doc")
|
|
321
|
+
|
|
322
|
+
def __init__(self, doc_manager, view, base_doc):
|
|
323
|
+
self._dm = doc_manager
|
|
324
|
+
self._view_ref = weakref.ref(view)
|
|
325
|
+
self._base_doc = base_doc
|
|
326
|
+
|
|
327
|
+
def _target(self):
|
|
328
|
+
view = self._view_ref()
|
|
329
|
+
if view is None:
|
|
330
|
+
return self._base_doc
|
|
331
|
+
doc = self._dm.get_document_for_view(view)
|
|
332
|
+
return doc or self._base_doc
|
|
333
|
+
|
|
334
|
+
def __getattr__(self, name):
|
|
335
|
+
return getattr(self._target(), name)
|
|
336
|
+
|
|
337
|
+
def __setattr__(self, name, value):
|
|
338
|
+
if name in _DocProxy.__slots__:
|
|
339
|
+
object.__setattr__(self, name, value)
|
|
340
|
+
else:
|
|
341
|
+
setattr(self._target(), name, value)
|
|
342
|
+
|
|
343
|
+
def __repr__(self):
|
|
344
|
+
tgt = self._target()
|
|
345
|
+
try:
|
|
346
|
+
dn = tgt.display_name() if hasattr(tgt, "display_name") else "<doc>"
|
|
347
|
+
except Exception:
|
|
348
|
+
dn = "<doc>"
|
|
349
|
+
return f"<DocProxy → {dn}>"
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# Role constant for action data
|
|
353
|
+
ROLE_ACTION = Qt.ItemDataRole.UserRole + 1
|