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,2807 @@
|
|
|
1
|
+
# pro/shortcuts.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Dict, Optional
|
|
7
|
+
import uuid
|
|
8
|
+
|
|
9
|
+
from PyQt6.QtCore import (Qt, QPoint, QRect, QMimeData, QSettings, QByteArray,
|
|
10
|
+
QDataStream, QIODevice, QEvent, QSize)
|
|
11
|
+
from PyQt6.QtGui import (QAction, QDrag, QIcon, QMouseEvent, QPixmap, QKeyEvent, QKeyEvent, QCursor, QKeySequence)
|
|
12
|
+
from PyQt6.QtWidgets import (QToolBar, QWidget, QToolButton, QMenu, QApplication, QVBoxLayout, QHBoxLayout, QComboBox, QGroupBox, QGridLayout, QDoubleSpinBox, QSpinBox,
|
|
13
|
+
QInputDialog, QMessageBox, QDialog, QSlider,
|
|
14
|
+
QFormLayout, QDialogButtonBox, QDoubleSpinBox, QCheckBox, QLabel, QRubberBand, QRadioButton, QPlainTextEdit, QTabWidget, QLineEdit, QPushButton, QFileDialog)
|
|
15
|
+
|
|
16
|
+
from PyQt6.QtWidgets import QMdiArea, QMdiSubWindow
|
|
17
|
+
# _LinearFitPresetDialog loaded on demand (see line ~334)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from PyQt6 import sip
|
|
23
|
+
except Exception:
|
|
24
|
+
sip = None
|
|
25
|
+
|
|
26
|
+
from setiastro.saspro.dnd_mime import MIME_VIEWSTATE, MIME_CMD, MIME_MASK, MIME_ACTION
|
|
27
|
+
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
import os # ← NEW
|
|
30
|
+
|
|
31
|
+
SASS_KIND = "sas.shortcuts"
|
|
32
|
+
SASS_VER = 1
|
|
33
|
+
|
|
34
|
+
# Accept these endings (case-insensitive)
|
|
35
|
+
OPENABLE_ENDINGS = (
|
|
36
|
+
".png", ".jpg", ".jpeg",
|
|
37
|
+
".tif", ".tiff",
|
|
38
|
+
".fits", ".fit",
|
|
39
|
+
".fits.gz", ".fit.gz", ".fz",
|
|
40
|
+
".xisf",
|
|
41
|
+
".cr2", ".cr3", ".nef", ".arw", ".dng", ".raf", ".orf", ".rw2", ".pef",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_ICONS = None
|
|
45
|
+
|
|
46
|
+
def _get_icons():
|
|
47
|
+
"""Lazy-load icons so shortcuts.py can be imported early without circular deps."""
|
|
48
|
+
global _ICONS
|
|
49
|
+
if _ICONS is not None:
|
|
50
|
+
return _ICONS
|
|
51
|
+
|
|
52
|
+
# Find where get_icons() lives in your project and import it here.
|
|
53
|
+
# Try a couple common locations; keep the first that exists in your tree.
|
|
54
|
+
try:
|
|
55
|
+
from setiastro.saspro.resources import get_icons as _gi
|
|
56
|
+
except Exception:
|
|
57
|
+
_gi = None
|
|
58
|
+
|
|
59
|
+
_ICONS = _gi() if _gi else None
|
|
60
|
+
return _ICONS
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _is_dead(w) -> bool:
|
|
64
|
+
"""True if widget is None or its C++ has been destroyed."""
|
|
65
|
+
if w is None:
|
|
66
|
+
return True
|
|
67
|
+
if sip is not None:
|
|
68
|
+
try:
|
|
69
|
+
return sip.isdeleted(w)
|
|
70
|
+
except Exception:
|
|
71
|
+
return False
|
|
72
|
+
# sip not available: best-effort heuristic
|
|
73
|
+
try:
|
|
74
|
+
_ = w.parent() # will raise on dead wrappers
|
|
75
|
+
return False
|
|
76
|
+
except RuntimeError:
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
# ---------- constants / helpers ----------
|
|
80
|
+
|
|
81
|
+
SET_KEY_V1 = "Shortcuts/v1" # legacy (id-less)
|
|
82
|
+
SET_KEY_V2 = "Shortcuts/v2" # new: stores id, label, etc.
|
|
83
|
+
SET_KEY = SET_KEY_V2
|
|
84
|
+
KEYBINDS_KEY = "Keybinds/v1" # JSON dict: {command_id: "Ctrl+Alt+S"}
|
|
85
|
+
|
|
86
|
+
# Used when dragging a DESKTOP shortcut onto a view for headless run
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _pack_cmd_payload(command_id: str, preset: dict | None = None) -> bytes:
|
|
90
|
+
return json.dumps({"command_id": command_id, "preset": preset or {}}).encode("utf-8")
|
|
91
|
+
|
|
92
|
+
def _unpack_cmd_payload(b: bytes) -> dict:
|
|
93
|
+
return json.loads(b.decode("utf-8"))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class ShortcutEntry:
|
|
98
|
+
shortcut_id: str
|
|
99
|
+
command_id: str
|
|
100
|
+
x: int
|
|
101
|
+
y: int
|
|
102
|
+
label: str
|
|
103
|
+
|
|
104
|
+
# ---------- a QToolBar that supports Alt+drag to create shortcuts ----------
|
|
105
|
+
class DraggableToolBar(QToolBar):
|
|
106
|
+
"""
|
|
107
|
+
Alt/Ctrl/Shift + Left-drag a toolbar button to create a desktop shortcut.
|
|
108
|
+
We hook QToolButton children (not the toolbar itself), because
|
|
109
|
+
mouse events go to the buttons.
|
|
110
|
+
"""
|
|
111
|
+
def __init__(self, *a, **k):
|
|
112
|
+
super().__init__(*a, **k)
|
|
113
|
+
self._press_pos: dict[QToolButton, QPoint] = {}
|
|
114
|
+
self._dragging_from: QToolButton | None = None
|
|
115
|
+
self._press_had_mod: dict[QToolButton, bool] = {}
|
|
116
|
+
self._suppress_release: set[QToolButton] = set()
|
|
117
|
+
self._settings_key: str | None = None
|
|
118
|
+
|
|
119
|
+
# NEW: called by main window / mixin
|
|
120
|
+
def setSettingsKey(self, key: str):
|
|
121
|
+
"""Set the settings key for persisting toolbar state."""
|
|
122
|
+
self._settings_key = str(key)
|
|
123
|
+
|
|
124
|
+
def _mods_ok(self, mods: Qt.KeyboardModifiers) -> bool:
|
|
125
|
+
return bool(mods & (
|
|
126
|
+
Qt.KeyboardModifier.AltModifier |
|
|
127
|
+
Qt.KeyboardModifier.ControlModifier |
|
|
128
|
+
Qt.KeyboardModifier.ShiftModifier
|
|
129
|
+
))
|
|
130
|
+
|
|
131
|
+
# install/remove our event filter when actions are added/removed
|
|
132
|
+
def actionEvent(self, e):
|
|
133
|
+
super().actionEvent(e)
|
|
134
|
+
t = e.type()
|
|
135
|
+
if t == QEvent.Type.ActionAdded:
|
|
136
|
+
act = e.action()
|
|
137
|
+
btn = self.widgetForAction(act)
|
|
138
|
+
if isinstance(btn, QToolButton):
|
|
139
|
+
btn.installEventFilter(self)
|
|
140
|
+
elif t == QEvent.Type.ActionRemoved:
|
|
141
|
+
act = e.action()
|
|
142
|
+
btn = self.widgetForAction(act)
|
|
143
|
+
if isinstance(btn, QToolButton):
|
|
144
|
+
try:
|
|
145
|
+
btn.removeEventFilter(self)
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
def eventFilter(self, obj, ev):
|
|
150
|
+
if isinstance(obj, QToolButton):
|
|
151
|
+
# RIGHT CLICK → show "Create Desktop Shortcut"
|
|
152
|
+
if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.RightButton:
|
|
153
|
+
act = self._find_action_for_button(obj)
|
|
154
|
+
if act:
|
|
155
|
+
self._show_toolbutton_context_menu(obj, act, ev.globalPosition().toPoint())
|
|
156
|
+
return True # consume
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
# Keyboard/trackpad context menu event
|
|
160
|
+
if ev.type() == QEvent.Type.ContextMenu:
|
|
161
|
+
act = self._find_action_for_button(obj)
|
|
162
|
+
if act:
|
|
163
|
+
self._show_toolbutton_context_menu(obj, act, ev.globalPos())
|
|
164
|
+
return True
|
|
165
|
+
return False
|
|
166
|
+
# L-press: remember start + whether a drag-modifier was held
|
|
167
|
+
if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
|
|
168
|
+
self._press_pos[obj] = ev.globalPosition().toPoint()
|
|
169
|
+
self._press_had_mod[obj] = self._mods_ok(QApplication.keyboardModifiers())
|
|
170
|
+
return False # allow normal press visuals
|
|
171
|
+
|
|
172
|
+
# Move with L held: if (had-mod at press OR has-mod now) AND moved enough → start drag
|
|
173
|
+
if ev.type() == QEvent.Type.MouseMove and (ev.buttons() & Qt.MouseButton.LeftButton):
|
|
174
|
+
start = self._press_pos.get(obj)
|
|
175
|
+
if start is not None:
|
|
176
|
+
delta = ev.globalPosition().toPoint() - start
|
|
177
|
+
if ((self._press_had_mod.get(obj, False) or self._mods_ok(QApplication.keyboardModifiers()))
|
|
178
|
+
and delta.manhattanLength() > QApplication.startDragDistance()):
|
|
179
|
+
# find the QAction backing this button
|
|
180
|
+
act = next((a for a in self.actions() if self.widgetForAction(a) is obj), None)
|
|
181
|
+
if act:
|
|
182
|
+
self._start_drag_for_action(act)
|
|
183
|
+
# eat subsequent release so the action doesn't trigger
|
|
184
|
+
self._suppress_release.add(obj)
|
|
185
|
+
# clear press tracking
|
|
186
|
+
self._press_pos.pop(obj, None)
|
|
187
|
+
self._press_had_mod.pop(obj, None)
|
|
188
|
+
return True # consume the move (prevents click)
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
# Release: if we started a drag, swallow the release so click won't fire
|
|
192
|
+
if ev.type() == QEvent.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
|
|
193
|
+
self._press_pos.pop(obj, None)
|
|
194
|
+
self._press_had_mod.pop(obj, None)
|
|
195
|
+
if obj in self._suppress_release:
|
|
196
|
+
self._suppress_release.discard(obj)
|
|
197
|
+
return True # eat release → no click
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
return super().eventFilter(obj, ev)
|
|
201
|
+
|
|
202
|
+
def _start_drag_for_action(self, act: QAction):
|
|
203
|
+
act_id = act.property("command_id") or act.objectName()
|
|
204
|
+
if not act_id:
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
mods = QApplication.keyboardModifiers()
|
|
208
|
+
alt = bool(mods & Qt.KeyboardModifier.AltModifier)
|
|
209
|
+
|
|
210
|
+
md = QMimeData()
|
|
211
|
+
if alt:
|
|
212
|
+
# Put BOTH payloads on the drag:
|
|
213
|
+
# 1) MIME_CMD → lets you drop directly on a View or Function Bundle chip
|
|
214
|
+
# (use per-command preset if available, else empty dict)
|
|
215
|
+
s = QSettings()
|
|
216
|
+
raw = s.value(f"presets/{act_id}", "", type=str) or ""
|
|
217
|
+
try:
|
|
218
|
+
preset = json.loads(raw) if raw else {}
|
|
219
|
+
except Exception:
|
|
220
|
+
preset = {}
|
|
221
|
+
md.setData(MIME_CMD, _pack_cmd_payload(act_id, preset))
|
|
222
|
+
|
|
223
|
+
# 2) MIME_ACTION → canvas still interprets this to create a desktop shortcut
|
|
224
|
+
md.setData(MIME_ACTION, act_id.encode("utf-8"))
|
|
225
|
+
else:
|
|
226
|
+
# Ctrl/Shift (legacy): only create a desktop shortcut
|
|
227
|
+
md.setData(MIME_ACTION, act_id.encode("utf-8"))
|
|
228
|
+
|
|
229
|
+
drag = QDrag(self)
|
|
230
|
+
drag.setMimeData(md)
|
|
231
|
+
pm = act.icon().pixmap(32, 32) if not act.icon().isNull() else QPixmap(32, 32)
|
|
232
|
+
if pm.isNull():
|
|
233
|
+
pm = QPixmap(32, 32); pm.fill(Qt.GlobalColor.darkGray)
|
|
234
|
+
drag.setPixmap(pm)
|
|
235
|
+
drag.setHotSpot(pm.rect().center())
|
|
236
|
+
drag.exec(Qt.DropAction.CopyAction)
|
|
237
|
+
|
|
238
|
+
def _find_action_for_button(self, btn: QToolButton) -> QAction | None:
|
|
239
|
+
# Find the QAction that owns this toolbutton
|
|
240
|
+
for a in self.actions():
|
|
241
|
+
if self.widgetForAction(a) is btn:
|
|
242
|
+
return a
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
def _add_shortcut_for_action(self, act: QAction):
|
|
246
|
+
# Resolve command id
|
|
247
|
+
act_id = act.property("command_id") or act.objectName()
|
|
248
|
+
if not act_id:
|
|
249
|
+
return
|
|
250
|
+
# Find ShortcutManager on the main window
|
|
251
|
+
mw = self.window()
|
|
252
|
+
mgr = getattr(mw, "shortcuts", None)
|
|
253
|
+
mdi = getattr(mw, "mdi", None)
|
|
254
|
+
if mgr is None or mdi is None:
|
|
255
|
+
return
|
|
256
|
+
# Map current cursor pos (global) into the viewport
|
|
257
|
+
gpos = QCursor.pos()
|
|
258
|
+
vp = mdi.viewport()
|
|
259
|
+
pos = vp.mapFromGlobal(gpos)
|
|
260
|
+
# Clamp into viewport rect (center if way out of bounds)
|
|
261
|
+
rect = vp.rect()
|
|
262
|
+
if not rect.contains(pos):
|
|
263
|
+
pos = rect.center()
|
|
264
|
+
mgr.add_shortcut(str(act_id), pos)
|
|
265
|
+
|
|
266
|
+
def _show_toolbutton_context_menu(self, btn: QToolButton, act: QAction, gpos: QPoint):
|
|
267
|
+
m = QMenu(btn)
|
|
268
|
+
m.addAction("Create Desktop Shortcut", lambda: self._add_shortcut_for_action(act))
|
|
269
|
+
# (Optional) teach users about Alt+Drag:
|
|
270
|
+
m.addSeparator()
|
|
271
|
+
m.addAction("Tip: Alt+Drag to create", lambda: None).setEnabled(False)
|
|
272
|
+
m.exec(gpos)
|
|
273
|
+
|
|
274
|
+
_PRESET_UI_IDS = {
|
|
275
|
+
"stat_stretch","star_stretch","crop","curves","ghs","abe","graxpert",
|
|
276
|
+
"remove_stars","cosmic_clarity","cosmic","cosmicclarity",
|
|
277
|
+
"convo","convolution","deconvolution","convo_deconvo",
|
|
278
|
+
"linear_fit","wavescale_hdr","wavescale_dark_enhance","wavescale_dark_enhancer",
|
|
279
|
+
"remove_green","star_align","background_neutral","white_balance","clahe",
|
|
280
|
+
"morphology","pixel_math","rgb_align","signature_insert","signature_adder",
|
|
281
|
+
"signature","halo_b_gon","geom_rescale","rescale","debayer","image_combine",
|
|
282
|
+
"star_spikes","diffraction_spikes", "multiscale_decomp",
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
def _has_preset_editor_for_command(command_id: str) -> bool:
|
|
286
|
+
"""Return True if we have a bespoke UI for this command_id."""
|
|
287
|
+
return command_id in _PRESET_UI_IDS
|
|
288
|
+
|
|
289
|
+
# ---- Shared preset editor helper for other modules (e.g. Function Bundles) ----
|
|
290
|
+
def _open_preset_editor_for_command(parent, command_id: str, initial: dict | None):
|
|
291
|
+
"""
|
|
292
|
+
Open the same command-specific preset editor UIs used by ShortcutButton.
|
|
293
|
+
Returns a dict on success (OK), or None if cancelled / no editor available.
|
|
294
|
+
"""
|
|
295
|
+
cur = initial or {}
|
|
296
|
+
|
|
297
|
+
# Keep each branch self-contained with local imports to avoid heavy module churn.
|
|
298
|
+
if command_id == "stat_stretch":
|
|
299
|
+
dlg = _StatStretchPresetDialog(parent, initial=cur)
|
|
300
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
301
|
+
|
|
302
|
+
if command_id == "star_stretch":
|
|
303
|
+
dlg = _StarStretchPresetDialog(parent, initial=cur)
|
|
304
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
305
|
+
|
|
306
|
+
if command_id == "crop":
|
|
307
|
+
from setiastro.saspro.shortcuts import _CropPresetDialog
|
|
308
|
+
dlg = _CropPresetDialog(parent, initial=cur or {
|
|
309
|
+
"mode": "margins",
|
|
310
|
+
"margins": {"top": 0, "right": 0, "bottom": 0, "left": 0},
|
|
311
|
+
"create_new_view": False
|
|
312
|
+
})
|
|
313
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
314
|
+
|
|
315
|
+
if command_id == "curves":
|
|
316
|
+
dlg = _CurvesPresetDialog(parent, initial=cur or {"shape":"linear","amount":0.5,"mode":"K (Brightness)"})
|
|
317
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
318
|
+
|
|
319
|
+
if command_id == "ghs":
|
|
320
|
+
dlg = _GHSPresetDialog(parent, initial=cur or {
|
|
321
|
+
"alpha":1.0,"beta":1.0,"gamma":1.0,"pivot":0.5,"lp":0.0,"hp":0.0,"channel":"K (Brightness)"
|
|
322
|
+
})
|
|
323
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
324
|
+
|
|
325
|
+
if command_id == "abe":
|
|
326
|
+
dlg = _ABEPresetDialog(parent, initial=cur or {
|
|
327
|
+
"degree":2, "samples":120, "downsample":6, "patch":15, "rbf":True, "rbf_smooth":1.0, "make_background_doc":False
|
|
328
|
+
})
|
|
329
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
330
|
+
|
|
331
|
+
if command_id == "graxpert":
|
|
332
|
+
from setiastro.saspro.graxpert_preset import GraXpertPresetDialog
|
|
333
|
+
dlg = GraXpertPresetDialog(parent, initial=cur or {"smoothing":0.10,"gpu":True})
|
|
334
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
335
|
+
|
|
336
|
+
if command_id == "remove_stars":
|
|
337
|
+
from setiastro.saspro.remove_stars_preset import RemoveStarsPresetDialog
|
|
338
|
+
dlg = RemoveStarsPresetDialog(parent, initial=cur or {"tool":"starnet","linear":True})
|
|
339
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
340
|
+
|
|
341
|
+
if command_id in ("cosmic_clarity","cosmic","cosmicclarity"):
|
|
342
|
+
from setiastro.saspro.cosmicclarity_preset import _CosmicClarityPresetDialog
|
|
343
|
+
dlg = _CosmicClarityPresetDialog(parent, initial=cur or {
|
|
344
|
+
"mode":"sharpen","gpu":True,"create_new_view":False,"sharpening_mode":"Both",
|
|
345
|
+
"auto_psf":True,"nonstellar_psf":3.0,"stellar_amount":0.50,"nonstellar_amount":0.50,
|
|
346
|
+
"denoise_luma":0.50,"denoise_color":0.50,"denoise_mode":"full","separate_channels":False,"scale":2
|
|
347
|
+
})
|
|
348
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
349
|
+
|
|
350
|
+
if command_id in ("convo","convolution","deconvolution","convo_deconvo"):
|
|
351
|
+
from setiastro.saspro.convo_preset import ConvoPresetDialog
|
|
352
|
+
dlg = ConvoPresetDialog(parent, initial=cur or {
|
|
353
|
+
"op":"convolution","radius":5.0,"kurtosis":2.0,"aspect":1.0,"rotation":0.0,"strength":1.0
|
|
354
|
+
})
|
|
355
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
356
|
+
|
|
357
|
+
if command_id == "linear_fit":
|
|
358
|
+
from setiastro.saspro.linear_fit import _LinearFitPresetDialog
|
|
359
|
+
dlg = _LinearFitPresetDialog(parent, initial=cur)
|
|
360
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
361
|
+
|
|
362
|
+
if command_id == "wavescale_hdr":
|
|
363
|
+
from setiastro.saspro.wavescale_hdr_preset import WaveScaleHDRPresetDialog
|
|
364
|
+
dlg = WaveScaleHDRPresetDialog(parent, initial=cur or {"n_scales":5,"compression_factor":1.5,"mask_gamma":5.0})
|
|
365
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
366
|
+
|
|
367
|
+
if command_id in ("wavescale_dark_enhance","wavescale_dark_enhancer"):
|
|
368
|
+
from setiastro.saspro.wavescalede_preset import WaveScaleDSEPresetDialog
|
|
369
|
+
dlg = WaveScaleDSEPresetDialog(parent, initial=cur or {
|
|
370
|
+
"n_scales":6,"boost_factor":5.0,"mask_gamma":1.0,"iterations":2
|
|
371
|
+
})
|
|
372
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
373
|
+
|
|
374
|
+
if command_id == "multiscale_decomp":
|
|
375
|
+
from setiastro.saspro.multiscale_decomp import _MultiScaleDecompPresetDialog
|
|
376
|
+
dlg = _MultiScaleDecompPresetDialog(parent, initial=cur or {
|
|
377
|
+
"layers": 4,
|
|
378
|
+
"base_sigma": 1.0,
|
|
379
|
+
"linked_rgb": True,
|
|
380
|
+
"layers_cfg": [],
|
|
381
|
+
})
|
|
382
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
383
|
+
|
|
384
|
+
if command_id == "remove_green":
|
|
385
|
+
dlg = _RemoveGreenPresetDialog(parent, initial=cur)
|
|
386
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
387
|
+
|
|
388
|
+
if command_id == "star_align":
|
|
389
|
+
from setiastro.saspro.star_alignment_preset import StarAlignmentPresetDialog
|
|
390
|
+
dlg = StarAlignmentPresetDialog(parent, initial=cur or {"ref_mode":"active","overwrite":False,"downsample":2})
|
|
391
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
392
|
+
|
|
393
|
+
if command_id == "background_neutral":
|
|
394
|
+
dlg = _BackgroundNeutralPresetDialog(parent, initial=cur)
|
|
395
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
396
|
+
|
|
397
|
+
if command_id == "white_balance":
|
|
398
|
+
dlg = _WhiteBalancePresetDialog(parent, initial=cur)
|
|
399
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
400
|
+
|
|
401
|
+
if command_id == "clahe":
|
|
402
|
+
dlg = _CLAHEPresetDialog(parent, initial=cur)
|
|
403
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
404
|
+
|
|
405
|
+
if command_id == "morphology":
|
|
406
|
+
dlg = _MorphologyPresetDialog(parent, initial=cur)
|
|
407
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
408
|
+
|
|
409
|
+
if command_id == "pixel_math":
|
|
410
|
+
dlg = _PixelMathPresetDialog(parent, initial=cur)
|
|
411
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
412
|
+
|
|
413
|
+
if command_id == "rgb_align":
|
|
414
|
+
dlg = _RGBAlignPresetDialog(parent, initial=cur or {"model":"homography","new_doc":True})
|
|
415
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
416
|
+
|
|
417
|
+
if command_id in ("signature_insert","signature_adder","signature"):
|
|
418
|
+
dlg = _SignatureInsertPresetDialog(parent, initial=cur)
|
|
419
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
420
|
+
|
|
421
|
+
if command_id == "halo_b_gon":
|
|
422
|
+
dlg = _HaloBGonPresetDialog(parent, initial=cur)
|
|
423
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
424
|
+
|
|
425
|
+
if command_id in ("geom_rescale","rescale"):
|
|
426
|
+
dlg = _RescalePresetDialog(parent, initial=cur or {"factor":1.0})
|
|
427
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
428
|
+
|
|
429
|
+
if command_id == "debayer":
|
|
430
|
+
dlg = _DebayerPresetDialog(parent, initial=cur or {"pattern":"auto"})
|
|
431
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
432
|
+
|
|
433
|
+
if command_id == "image_combine":
|
|
434
|
+
dlg = _ImageCombinePresetDialog(parent, initial=cur or {
|
|
435
|
+
"mode":"Blend","opacity":1.0,"luma_only":False,"output":"replace","docB_title":""
|
|
436
|
+
})
|
|
437
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
438
|
+
|
|
439
|
+
if command_id in ("star_spikes","diffraction_spikes"):
|
|
440
|
+
dlg = _StarSpikesPresetDialog(parent, initial=cur)
|
|
441
|
+
return dlg.result_dict() if dlg.exec() == QDialog.DialogCode.Accepted else None
|
|
442
|
+
|
|
443
|
+
# Unknown / no bespoke UI
|
|
444
|
+
return None
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# ---------- the button that sits on the MDI desktop ----------
|
|
448
|
+
class ShortcutButton(QToolButton):
|
|
449
|
+
def __init__(self,
|
|
450
|
+
manager: "ShortcutManager",
|
|
451
|
+
sid: str, # NEW
|
|
452
|
+
command_id: str,
|
|
453
|
+
icon: QIcon,
|
|
454
|
+
label: str, # NEW (display text)
|
|
455
|
+
parent: QWidget):
|
|
456
|
+
super().__init__(parent)
|
|
457
|
+
self._mgr = manager
|
|
458
|
+
self.sid = sid # NEW
|
|
459
|
+
self.command_id = command_id
|
|
460
|
+
self.setIcon(icon)
|
|
461
|
+
self.setText(label) # use label instead of action text
|
|
462
|
+
self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon)
|
|
463
|
+
self.setIconSize(QPixmap(32, 32).size())
|
|
464
|
+
self.setAutoRaise(True)
|
|
465
|
+
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
466
|
+
self.customContextMenuRequested.connect(self._context_menu)
|
|
467
|
+
self._dragging = False
|
|
468
|
+
self._press_pos = None
|
|
469
|
+
self._start_geom = None
|
|
470
|
+
self._did_command_drag = False
|
|
471
|
+
self.setToolTip(
|
|
472
|
+
f"{label}\n• Double-click: open\n• Drag: move\n• Alt/Ctrl+Drag onto a view: headless apply"
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
# --- Preset helpers (QSettings) -------------------------------------
|
|
476
|
+
def _preset_key(self) -> str:
|
|
477
|
+
# per-instance key
|
|
478
|
+
return f"presets/shortcuts/{self.sid}"
|
|
479
|
+
|
|
480
|
+
def _load_preset(self) -> Optional[dict]:
|
|
481
|
+
s = getattr(self._mgr, "settings", QSettings())
|
|
482
|
+
raw = s.value(self._preset_key(), "", type=str) or ""
|
|
483
|
+
if raw:
|
|
484
|
+
try:
|
|
485
|
+
return json.loads(raw)
|
|
486
|
+
except Exception:
|
|
487
|
+
pass
|
|
488
|
+
# fallback: legacy per-command preset if instance hasn’t been saved yet
|
|
489
|
+
legacy = s.value(f"presets/{self.command_id}", "", type=str) or ""
|
|
490
|
+
if legacy:
|
|
491
|
+
try:
|
|
492
|
+
return json.loads(legacy)
|
|
493
|
+
except Exception:
|
|
494
|
+
pass
|
|
495
|
+
return None
|
|
496
|
+
|
|
497
|
+
def _save_preset(self, preset: Optional[dict]):
|
|
498
|
+
s = getattr(self._mgr, "settings", QSettings())
|
|
499
|
+
if preset is None:
|
|
500
|
+
s.remove(self._preset_key())
|
|
501
|
+
else:
|
|
502
|
+
s.setValue(self._preset_key(), json.dumps(preset))
|
|
503
|
+
s.sync()
|
|
504
|
+
|
|
505
|
+
# --- Context menu (run / preset / delete) ----------------------------
|
|
506
|
+
def _context_menu(self, pos):
|
|
507
|
+
m = QMenu(self)
|
|
508
|
+
m.addAction("Run", lambda: self._mgr.trigger(self.command_id))
|
|
509
|
+
m.addSeparator()
|
|
510
|
+
m.addAction("Edit Preset…", self._edit_preset_ui)
|
|
511
|
+
m.addAction("Clear Preset", lambda: self._save_preset(None))
|
|
512
|
+
m.addAction("Rename…", self._rename) # ← NEW
|
|
513
|
+
m.addSeparator()
|
|
514
|
+
m.addAction("Delete", self._delete)
|
|
515
|
+
m.exec(self.mapToGlobal(pos))
|
|
516
|
+
|
|
517
|
+
def _rename(self):
|
|
518
|
+
current = self.text()
|
|
519
|
+
new_name, ok = QInputDialog.getText(self, "Rename Shortcut", "Name:", text=current)
|
|
520
|
+
if not ok or not new_name.strip():
|
|
521
|
+
return
|
|
522
|
+
self.setText(new_name.strip())
|
|
523
|
+
self._mgr.update_label(self.sid, new_name.strip()) # ← was self.shortcut_id
|
|
524
|
+
|
|
525
|
+
def _edit_preset_ui(self):
|
|
526
|
+
cid = self.command_id
|
|
527
|
+
cur = self._load_preset() or {}
|
|
528
|
+
result = _open_preset_editor_for_command(self, cid, cur)
|
|
529
|
+
if result is not None:
|
|
530
|
+
self._save_preset(result)
|
|
531
|
+
QMessageBox.information(self, "Preset saved", "Preset stored on shortcut.")
|
|
532
|
+
return
|
|
533
|
+
|
|
534
|
+
# Fallback: JSON editor
|
|
535
|
+
raw = json.dumps(cur or {}, indent=2)
|
|
536
|
+
text, ok = QInputDialog.getMultiLineText(self, "Edit Preset (JSON)", "Preset:", raw)
|
|
537
|
+
if ok:
|
|
538
|
+
try:
|
|
539
|
+
preset = json.loads(text or "{}")
|
|
540
|
+
if not isinstance(preset, dict):
|
|
541
|
+
raise ValueError("Preset must be a JSON object")
|
|
542
|
+
self._save_preset(preset)
|
|
543
|
+
QMessageBox.information(self, "Preset saved", "Preset stored on shortcut.")
|
|
544
|
+
except Exception as e:
|
|
545
|
+
QMessageBox.warning(self, "Invalid JSON", str(e))
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _start_command_drag(self):
|
|
549
|
+
md = QMimeData()
|
|
550
|
+
|
|
551
|
+
md.setData(MIME_CMD, _pack_cmd_payload(self.command_id, self._load_preset() or {}))
|
|
552
|
+
drag = QDrag(self)
|
|
553
|
+
drag.setMimeData(md)
|
|
554
|
+
pm = self.icon().pixmap(32, 32)
|
|
555
|
+
if pm.isNull():
|
|
556
|
+
pm = QPixmap(32, 32); pm.fill(Qt.GlobalColor.darkGray)
|
|
557
|
+
drag.setPixmap(pm)
|
|
558
|
+
drag.setHotSpot(pm.rect().center())
|
|
559
|
+
drag.exec(Qt.DropAction.CopyAction)
|
|
560
|
+
self._did_command_drag = True
|
|
561
|
+
|
|
562
|
+
# --- Mouse handlers --------------------------------------------------
|
|
563
|
+
def _mods_mean_command_drag(self) -> bool:
|
|
564
|
+
# Use ALT only for headless drag so Ctrl/Shift can be used for multiselect
|
|
565
|
+
return bool(QApplication.keyboardModifiers() & Qt.KeyboardModifier.AltModifier)
|
|
566
|
+
|
|
567
|
+
def mousePressEvent(self, e: QMouseEvent):
|
|
568
|
+
if e.button() == Qt.MouseButton.LeftButton:
|
|
569
|
+
mods = QApplication.keyboardModifiers()
|
|
570
|
+
|
|
571
|
+
if mods & (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier):
|
|
572
|
+
self._mgr.toggle_select(self.sid) # ← was self.shortcut_id
|
|
573
|
+
return
|
|
574
|
+
|
|
575
|
+
if self.sid not in self._mgr.selected: # ← was self.shortcut_id
|
|
576
|
+
self._mgr.select_only(self.sid)
|
|
577
|
+
|
|
578
|
+
self._dragging = True
|
|
579
|
+
self._press_pos = e.globalPosition().toPoint()
|
|
580
|
+
self._last_drag_pos = self._press_pos
|
|
581
|
+
self._did_command_drag = False
|
|
582
|
+
|
|
583
|
+
super().mousePressEvent(e)
|
|
584
|
+
|
|
585
|
+
def mouseMoveEvent(self, e: QMouseEvent):
|
|
586
|
+
if self._dragging and self._press_pos is not None:
|
|
587
|
+
cur = e.globalPosition().toPoint()
|
|
588
|
+
step = cur - self._last_drag_pos
|
|
589
|
+
if step.manhattanLength() < QApplication.startDragDistance():
|
|
590
|
+
return super().mouseMoveEvent(e)
|
|
591
|
+
|
|
592
|
+
# If exactly 1 selected and ALT held → command drag (headless)
|
|
593
|
+
if len(self._mgr.selected) == 1 and self._mods_mean_command_drag():
|
|
594
|
+
self._start_command_drag()
|
|
595
|
+
return
|
|
596
|
+
|
|
597
|
+
# Otherwise: move the whole selection by step delta
|
|
598
|
+
self._mgr.move_selected_by(step.x(), step.y())
|
|
599
|
+
self._last_drag_pos = cur
|
|
600
|
+
return
|
|
601
|
+
|
|
602
|
+
super().mouseMoveEvent(e)
|
|
603
|
+
|
|
604
|
+
def mouseReleaseEvent(self, e: QMouseEvent):
|
|
605
|
+
if self._dragging and e.button() == Qt.MouseButton.LeftButton:
|
|
606
|
+
self._dragging = False
|
|
607
|
+
if not self._did_command_drag:
|
|
608
|
+
self._mgr.save_shortcuts() # persist positions after move
|
|
609
|
+
super().mouseReleaseEvent(e)
|
|
610
|
+
|
|
611
|
+
def mouseDoubleClickEvent(self, e: QMouseEvent):
|
|
612
|
+
# double-click still runs the action (open dialog)
|
|
613
|
+
self._mgr.trigger(self.command_id)
|
|
614
|
+
|
|
615
|
+
def _delete(self):
|
|
616
|
+
self._mgr.delete_by_id(self.sid, persist=True) # ← was command_id
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _open_view_bundles_from_canvas(w):
|
|
620
|
+
try:
|
|
621
|
+
from setiastro.saspro.view_bundle import show_view_bundles
|
|
622
|
+
mw = _find_main_window(w)
|
|
623
|
+
show_view_bundles(mw)
|
|
624
|
+
except Exception:
|
|
625
|
+
pass
|
|
626
|
+
|
|
627
|
+
def _open_function_bundles_from_canvas(w):
|
|
628
|
+
try:
|
|
629
|
+
from setiastro.saspro.function_bundle import show_function_bundles
|
|
630
|
+
mw = _find_main_window(w)
|
|
631
|
+
show_function_bundles(mw)
|
|
632
|
+
except Exception:
|
|
633
|
+
pass
|
|
634
|
+
|
|
635
|
+
def _find_main_window(w):
|
|
636
|
+
p = w.parent()
|
|
637
|
+
while p is not None and not (hasattr(p, "doc_manager") or hasattr(p, "docman")):
|
|
638
|
+
p = p.parent()
|
|
639
|
+
return p
|
|
640
|
+
|
|
641
|
+
# ---------- overlay canvas that sits on top of QMdiArea.viewport() ----------
|
|
642
|
+
class ShortcutCanvas(QWidget):
|
|
643
|
+
def __init__(self, mgr: "ShortcutManager", parent: QWidget):
|
|
644
|
+
super().__init__(parent)
|
|
645
|
+
self._mgr = mgr
|
|
646
|
+
self.setAcceptDrops(True)
|
|
647
|
+
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
|
648
|
+
self.setStyleSheet("background: transparent;")
|
|
649
|
+
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False)
|
|
650
|
+
self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground, True)
|
|
651
|
+
self.setGeometry(parent.rect())
|
|
652
|
+
parent.installEventFilter(self) # keep in sync with viewport size
|
|
653
|
+
|
|
654
|
+
# NEW: rubber-band selection
|
|
655
|
+
self._rubber = QRubberBand(QRubberBand.Shape.Rectangle, self)
|
|
656
|
+
self._rubber_origin = None
|
|
657
|
+
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # to receive Delete/Ctrl+A
|
|
658
|
+
self.setContextMenuPolicy(Qt.ContextMenuPolicy.DefaultContextMenu)
|
|
659
|
+
|
|
660
|
+
def eventFilter(self, obj, ev):
|
|
661
|
+
# keep sized with viewport
|
|
662
|
+
if obj is self.parent() and ev.type() == ev.Type.Resize:
|
|
663
|
+
self.setGeometry(self.parent().rect())
|
|
664
|
+
return super().eventFilter(obj, ev)
|
|
665
|
+
|
|
666
|
+
# --- rubber-band selection on empty space ---
|
|
667
|
+
def mousePressEvent(self, e: QMouseEvent):
|
|
668
|
+
if e.button() == Qt.MouseButton.LeftButton:
|
|
669
|
+
local = e.position().toPoint()
|
|
670
|
+
# If click hits no child (shortcut), start rubber-band
|
|
671
|
+
if self.childAt(local) is None:
|
|
672
|
+
self._rubber_origin = local
|
|
673
|
+
self._rubber.setGeometry(QRect(self._rubber_origin, self._rubber_origin))
|
|
674
|
+
self._rubber.show()
|
|
675
|
+
# if no add/toggle mods, clear selection first
|
|
676
|
+
if not (QApplication.keyboardModifiers() & (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier)):
|
|
677
|
+
self._mgr.clear_selection()
|
|
678
|
+
self.setFocus()
|
|
679
|
+
e.accept()
|
|
680
|
+
return
|
|
681
|
+
super().mousePressEvent(e)
|
|
682
|
+
|
|
683
|
+
def mouseMoveEvent(self, e: QMouseEvent):
|
|
684
|
+
if self._rubber.isVisible() and self._rubber_origin is not None:
|
|
685
|
+
rect = QRect(self._rubber_origin, e.position().toPoint()).normalized()
|
|
686
|
+
self._rubber.setGeometry(rect)
|
|
687
|
+
e.accept()
|
|
688
|
+
return
|
|
689
|
+
super().mouseMoveEvent(e)
|
|
690
|
+
|
|
691
|
+
def mouseReleaseEvent(self, e: QMouseEvent):
|
|
692
|
+
if self._rubber.isVisible() and self._rubber_origin is not None:
|
|
693
|
+
rect = QRect(self._rubber_origin, e.position().toPoint()).normalized()
|
|
694
|
+
mode = "add" if (QApplication.keyboardModifiers() & (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier)) else "replace"
|
|
695
|
+
self._rubber.hide()
|
|
696
|
+
self._rubber_origin = None
|
|
697
|
+
self._mgr.select_in_rect(rect, mode=mode)
|
|
698
|
+
e.accept()
|
|
699
|
+
return
|
|
700
|
+
super().mouseReleaseEvent(e)
|
|
701
|
+
|
|
702
|
+
# --- keyboard: Delete / Backspace / Ctrl+A ---
|
|
703
|
+
def keyPressEvent(self, e: QKeyEvent):
|
|
704
|
+
if e.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace):
|
|
705
|
+
self._mgr.delete_selected()
|
|
706
|
+
e.accept(); return
|
|
707
|
+
if e.key() == Qt.Key.Key_A and (e.modifiers() & Qt.KeyboardModifier.ControlModifier):
|
|
708
|
+
self._mgr.select_in_rect(self.rect(), mode="replace")
|
|
709
|
+
e.accept(); return
|
|
710
|
+
super().keyPressEvent(e)
|
|
711
|
+
|
|
712
|
+
def dragEnterEvent(self, e):
|
|
713
|
+
md = e.mimeData()
|
|
714
|
+
if md.hasFormat(MIME_ACTION) or md.hasFormat(MIME_CMD) or self._md_has_openable_urls(md):
|
|
715
|
+
self.raise_()
|
|
716
|
+
e.acceptProposedAction()
|
|
717
|
+
else:
|
|
718
|
+
e.ignore()
|
|
719
|
+
|
|
720
|
+
def _top_subwindow_at(self, vp_pos: QPoint) -> QMdiSubWindow | None:
|
|
721
|
+
# Use the correct enum for PyQt6, fall back gracefully if unavailable
|
|
722
|
+
try:
|
|
723
|
+
order_enum = QMdiArea.WindowOrder # PyQt6
|
|
724
|
+
swlist = self._mgr.mdi.subWindowList(order_enum.StackingOrder)
|
|
725
|
+
except Exception:
|
|
726
|
+
# Fallback for older bindings
|
|
727
|
+
swlist = self._mgr.mdi.subWindowList()
|
|
728
|
+
|
|
729
|
+
# Iterate from front-most to back-most (StackingOrder is typically back->front)
|
|
730
|
+
for sw in reversed(swlist):
|
|
731
|
+
if not sw.isVisible():
|
|
732
|
+
continue
|
|
733
|
+
# QMdiSubWindow geometry is in the viewport's coordinate space
|
|
734
|
+
if sw.geometry().contains(vp_pos):
|
|
735
|
+
return sw
|
|
736
|
+
return None
|
|
737
|
+
|
|
738
|
+
def _forward_command_drop(self, e) -> bool:
|
|
739
|
+
from PyQt6.QtWidgets import QApplication
|
|
740
|
+
md = e.mimeData()
|
|
741
|
+
if not md.hasFormat(MIME_CMD):
|
|
742
|
+
return False
|
|
743
|
+
sw = self._top_subwindow_at(e.position().toPoint())
|
|
744
|
+
if sw is None:
|
|
745
|
+
print("[ShortcutCanvas] _forward_command_drop: no subwindow under cursor", flush=True)
|
|
746
|
+
QApplication.processEvents()
|
|
747
|
+
return False
|
|
748
|
+
try:
|
|
749
|
+
raw = bytes(md.data(MIME_CMD))
|
|
750
|
+
payload = _unpack_cmd_payload(raw) # your existing helper
|
|
751
|
+
print(f"[ShortcutCanvas] _forward_command_drop → subwin={sw}, payload={payload!r}", flush=True)
|
|
752
|
+
QApplication.processEvents()
|
|
753
|
+
except Exception as ex:
|
|
754
|
+
print(f"[ShortcutCanvas] _forward_command_drop: failed to unpack payload: {ex!r}", flush=True)
|
|
755
|
+
QApplication.processEvents()
|
|
756
|
+
return False
|
|
757
|
+
self._mgr.apply_command_to_subwindow(sw, payload)
|
|
758
|
+
e.acceptProposedAction()
|
|
759
|
+
return True
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def dragMoveEvent(self, e):
|
|
763
|
+
if e.mimeData().hasFormat(MIME_ACTION) or e.mimeData().hasFormat(MIME_CMD) or self._md_has_openable_urls(e.mimeData()):
|
|
764
|
+
e.acceptProposedAction()
|
|
765
|
+
else:
|
|
766
|
+
e.ignore()
|
|
767
|
+
|
|
768
|
+
def dragLeaveEvent(self, e):
|
|
769
|
+
self.lower() # restore
|
|
770
|
+
super().dragLeaveEvent(e)
|
|
771
|
+
|
|
772
|
+
def dropEvent(self, e):
|
|
773
|
+
md = e.mimeData()
|
|
774
|
+
|
|
775
|
+
# 1) route function/preset drops to the front-most subwindow under cursor
|
|
776
|
+
if self._forward_command_drop(e):
|
|
777
|
+
self.lower()
|
|
778
|
+
return
|
|
779
|
+
|
|
780
|
+
# 2) command-only drops (no MIME_ACTION) → create a shortcut with preset
|
|
781
|
+
# This is used by History Explorer Alt+drag.
|
|
782
|
+
if md.hasFormat(MIME_CMD) and not md.hasFormat(MIME_ACTION):
|
|
783
|
+
try:
|
|
784
|
+
raw = bytes(md.data(MIME_CMD))
|
|
785
|
+
payload = _unpack_cmd_payload(raw)
|
|
786
|
+
except Exception:
|
|
787
|
+
payload = None
|
|
788
|
+
|
|
789
|
+
if isinstance(payload, dict) and payload.get("command_id"):
|
|
790
|
+
self._mgr.add_shortcut_from_payload(payload, e.position().toPoint())
|
|
791
|
+
e.acceptProposedAction()
|
|
792
|
+
self.lower()
|
|
793
|
+
return
|
|
794
|
+
|
|
795
|
+
# 3) desktop shortcut creation (MIME_ACTION) → create a button (no preset)
|
|
796
|
+
if md.hasFormat(MIME_ACTION):
|
|
797
|
+
act_id = bytes(md.data(MIME_ACTION)).decode("utf-8")
|
|
798
|
+
self._mgr.add_shortcut(act_id, e.position().toPoint())
|
|
799
|
+
e.acceptProposedAction()
|
|
800
|
+
self.lower()
|
|
801
|
+
return
|
|
802
|
+
|
|
803
|
+
# 4) File / folder open (unchanged)
|
|
804
|
+
if self._md_has_openable_urls(md):
|
|
805
|
+
paths = self._collect_openable_files_from_urls(md)
|
|
806
|
+
if paths:
|
|
807
|
+
opener = getattr(self._mgr.mw, "_handle_external_file_drop", None)
|
|
808
|
+
if callable(opener):
|
|
809
|
+
opener(paths)
|
|
810
|
+
else:
|
|
811
|
+
dm = getattr(self._mgr.mw, "docman", None)
|
|
812
|
+
if dm and hasattr(dm, "open_files") and callable(dm.open_files):
|
|
813
|
+
docs = dm.open_files(paths)
|
|
814
|
+
try:
|
|
815
|
+
for d in (docs or []):
|
|
816
|
+
self._mgr.mw._spawn_subwindow_for(d)
|
|
817
|
+
except Exception:
|
|
818
|
+
pass
|
|
819
|
+
elif dm and hasattr(dm, "open_path") and callable(dm.open_path):
|
|
820
|
+
for p in paths:
|
|
821
|
+
doc = dm.open_path(p)
|
|
822
|
+
if doc is not None:
|
|
823
|
+
self._mgr.mw._spawn_subwindow_for(doc)
|
|
824
|
+
e.acceptProposedAction()
|
|
825
|
+
self.lower()
|
|
826
|
+
return
|
|
827
|
+
self.lower()
|
|
828
|
+
e.ignore()
|
|
829
|
+
|
|
830
|
+
def contextMenuEvent(self, e):
|
|
831
|
+
menu = QMenu(self)
|
|
832
|
+
has_sel = bool(self._mgr.selected)
|
|
833
|
+
a_del = menu.addAction("Delete Selected", self._mgr.delete_selected); a_del.setEnabled(has_sel)
|
|
834
|
+
a_clr = menu.addAction("Clear Selection", self._mgr.clear_selection); a_clr.setEnabled(has_sel)
|
|
835
|
+
menu.addSeparator()
|
|
836
|
+
a_vb = menu.addAction("View Bundles…", lambda: _open_view_bundles_from_canvas(self))
|
|
837
|
+
a_fb = menu.addAction("Function Bundles…", lambda: _open_function_bundles_from_canvas(self))
|
|
838
|
+
menu.exec(e.globalPos())
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def mouseDoubleClickEvent(self, e):
|
|
842
|
+
# If user double-clicks empty canvas area, forward to MDI's handler
|
|
843
|
+
if e.button() == Qt.MouseButton.LeftButton:
|
|
844
|
+
local = e.position().toPoint()
|
|
845
|
+
if self.childAt(local) is None:
|
|
846
|
+
try:
|
|
847
|
+
# Reuse your existing connection: mdi.backgroundDoubleClicked -> open_files
|
|
848
|
+
self._mgr.mdi.backgroundDoubleClicked.emit()
|
|
849
|
+
except Exception:
|
|
850
|
+
pass
|
|
851
|
+
e.accept()
|
|
852
|
+
return
|
|
853
|
+
super().mouseDoubleClickEvent(e)
|
|
854
|
+
|
|
855
|
+
def _is_openable_path(self, path: str) -> bool:
|
|
856
|
+
return path.lower().endswith(OPENABLE_ENDINGS)
|
|
857
|
+
|
|
858
|
+
def _md_has_openable_urls(self, md) -> bool:
|
|
859
|
+
if not md.hasUrls():
|
|
860
|
+
return False
|
|
861
|
+
for u in md.urls():
|
|
862
|
+
if not u.isLocalFile():
|
|
863
|
+
continue
|
|
864
|
+
p = u.toLocalFile()
|
|
865
|
+
if os.path.isdir(p):
|
|
866
|
+
return True # we'll scan it on drop
|
|
867
|
+
if self._is_openable_path(p):
|
|
868
|
+
return True
|
|
869
|
+
return False
|
|
870
|
+
|
|
871
|
+
def _collect_openable_files_from_urls(self, md) -> list[str]:
|
|
872
|
+
files: list[str] = []
|
|
873
|
+
if not md.hasUrls():
|
|
874
|
+
return files
|
|
875
|
+
for u in md.urls():
|
|
876
|
+
if not u.isLocalFile():
|
|
877
|
+
continue
|
|
878
|
+
p = u.toLocalFile()
|
|
879
|
+
if os.path.isdir(p):
|
|
880
|
+
# recurse folder for matching files
|
|
881
|
+
for root, _, names in os.walk(p):
|
|
882
|
+
for name in names:
|
|
883
|
+
fp = os.path.join(root, name)
|
|
884
|
+
if self._is_openable_path(fp):
|
|
885
|
+
files.append(fp)
|
|
886
|
+
else:
|
|
887
|
+
if self._is_openable_path(p):
|
|
888
|
+
files.append(p)
|
|
889
|
+
return files
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
class ShortcutManager:
|
|
893
|
+
def __init__(self, mdi_area, main_window):
|
|
894
|
+
# mdi_area should be your QMdiArea; we attach to its viewport
|
|
895
|
+
self.mdi = mdi_area
|
|
896
|
+
self.mw = main_window
|
|
897
|
+
self.registry: Dict[str, QAction] = {}
|
|
898
|
+
self.canvas = ShortcutCanvas(self, self.mdi.viewport())
|
|
899
|
+
self.canvas.lower() # keep below subwindows (raise() if you want pinned-on-top)
|
|
900
|
+
self.canvas.show()
|
|
901
|
+
self.widgets: Dict[str, ShortcutButton] = {}
|
|
902
|
+
self.settings = QSettings() # shared settings store for positions + presets
|
|
903
|
+
self.selected: set[str] = set() # ← set of shortcut_ids
|
|
904
|
+
|
|
905
|
+
# ---- registry ----
|
|
906
|
+
def register_action(self, command_id: str, action: QAction):
|
|
907
|
+
action.setProperty("command_id", command_id)
|
|
908
|
+
if not action.objectName():
|
|
909
|
+
action.setObjectName(command_id)
|
|
910
|
+
|
|
911
|
+
# Ensure action shortcuts work even if focus is in child widgets / MDI
|
|
912
|
+
action.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
|
|
913
|
+
|
|
914
|
+
self.registry[command_id] = action
|
|
915
|
+
|
|
916
|
+
# Apply saved keybind if present
|
|
917
|
+
kb = self._load_keybinds().get(command_id)
|
|
918
|
+
if kb:
|
|
919
|
+
action.setShortcut(QKeySequence(kb))
|
|
920
|
+
|
|
921
|
+
def find_keybind_conflicts(self) -> dict[str, list[str]]:
|
|
922
|
+
# returns {"Ctrl+Alt+K": ["script:...", "stat_stretch"], ...}
|
|
923
|
+
conflicts = {}
|
|
924
|
+
for cid, act in self.registry.items():
|
|
925
|
+
ks = act.shortcut().toString()
|
|
926
|
+
if not ks:
|
|
927
|
+
continue
|
|
928
|
+
conflicts.setdefault(ks, []).append(cid)
|
|
929
|
+
return {k:v for k,v in conflicts.items() if len(v) > 1}
|
|
930
|
+
|
|
931
|
+
def trigger(self, command_id: str):
|
|
932
|
+
act = self.registry.get(command_id)
|
|
933
|
+
if act:
|
|
934
|
+
act.trigger()
|
|
935
|
+
|
|
936
|
+
def _on_widget_destroyed(self, sid: str):
|
|
937
|
+
# Called from QObject.destroyed — never touch the widget, just clean maps
|
|
938
|
+
self.widgets.pop(sid, None)
|
|
939
|
+
self.selected.discard(sid)
|
|
940
|
+
|
|
941
|
+
def _collect_live_items(self) -> list[dict]:
|
|
942
|
+
"""
|
|
943
|
+
Collect visible shortcut widgets into a serializable list.
|
|
944
|
+
|
|
945
|
+
For each shortcut we also try to inline its preset (if any) so that
|
|
946
|
+
function bundles and other per-instance presets can be exported.
|
|
947
|
+
"""
|
|
948
|
+
data = []
|
|
949
|
+
for sid, w in list(self.widgets.items()):
|
|
950
|
+
if _is_dead(w):
|
|
951
|
+
self.widgets.pop(sid, None)
|
|
952
|
+
self.selected.discard(sid)
|
|
953
|
+
continue
|
|
954
|
+
try:
|
|
955
|
+
if not w.isVisible():
|
|
956
|
+
continue
|
|
957
|
+
|
|
958
|
+
p = w.pos()
|
|
959
|
+
item = {
|
|
960
|
+
"id": sid,
|
|
961
|
+
"command_id": getattr(w, "command_id", None),
|
|
962
|
+
"label": w.text(),
|
|
963
|
+
"x": int(p.x()),
|
|
964
|
+
"y": int(p.y()),
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
# Try to inline per-instance preset
|
|
968
|
+
preset = None
|
|
969
|
+
try:
|
|
970
|
+
if hasattr(w, "_load_preset"):
|
|
971
|
+
preset = w._load_preset()
|
|
972
|
+
except Exception:
|
|
973
|
+
preset = None
|
|
974
|
+
|
|
975
|
+
if isinstance(preset, dict) and preset:
|
|
976
|
+
item["preset"] = preset
|
|
977
|
+
|
|
978
|
+
data.append(item)
|
|
979
|
+
except RuntimeError:
|
|
980
|
+
self.widgets.pop(sid, None)
|
|
981
|
+
self.selected.discard(sid)
|
|
982
|
+
|
|
983
|
+
# Debug: summarize what we collected
|
|
984
|
+
try:
|
|
985
|
+
summary = []
|
|
986
|
+
for it in data:
|
|
987
|
+
cid = it.get("command_id")
|
|
988
|
+
has_preset = "preset" in it
|
|
989
|
+
summary.append(f"{cid!r} preset={has_preset}")
|
|
990
|
+
self._debug(f"_collect_live_items: {len(data)} item(s): " + ", ".join(summary))
|
|
991
|
+
except Exception:
|
|
992
|
+
pass
|
|
993
|
+
|
|
994
|
+
return data
|
|
995
|
+
|
|
996
|
+
def _export_function_bundles_for_shortcuts(self) -> dict | None:
|
|
997
|
+
"""
|
|
998
|
+
Ask pro.function_bundle for function bundle defs + chip layout
|
|
999
|
+
so we can embed them into the .sass export.
|
|
1000
|
+
"""
|
|
1001
|
+
try:
|
|
1002
|
+
from setiastro.saspro.function_bundle import export_function_bundles_payload
|
|
1003
|
+
except Exception:
|
|
1004
|
+
return None
|
|
1005
|
+
try:
|
|
1006
|
+
fb = export_function_bundles_payload()
|
|
1007
|
+
if isinstance(fb, dict):
|
|
1008
|
+
return fb
|
|
1009
|
+
except Exception:
|
|
1010
|
+
pass
|
|
1011
|
+
return None
|
|
1012
|
+
|
|
1013
|
+
def _import_function_bundles_for_shortcuts(self, payload: dict | None, replace_existing: bool):
|
|
1014
|
+
"""
|
|
1015
|
+
Restore function bundle defs + chips after a .sass import.
|
|
1016
|
+
"""
|
|
1017
|
+
if not isinstance(payload, dict):
|
|
1018
|
+
return
|
|
1019
|
+
try:
|
|
1020
|
+
from setiastro.saspro.function_bundle import import_function_bundles_payload
|
|
1021
|
+
except Exception:
|
|
1022
|
+
return
|
|
1023
|
+
try:
|
|
1024
|
+
mw = getattr(self, "mw", None)
|
|
1025
|
+
import_function_bundles_payload(payload, mw, replace_existing=replace_existing)
|
|
1026
|
+
except Exception:
|
|
1027
|
+
pass
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _debug(self, msg: str):
|
|
1031
|
+
"""Best-effort debug logging for shortcuts."""
|
|
1032
|
+
try:
|
|
1033
|
+
# Prefer main window log if available
|
|
1034
|
+
if hasattr(self.mw, "_log") and callable(self.mw._log):
|
|
1035
|
+
self.mw._log(f"[Shortcuts] {msg}")
|
|
1036
|
+
return
|
|
1037
|
+
except Exception:
|
|
1038
|
+
pass
|
|
1039
|
+
# Fallback to stdout
|
|
1040
|
+
try:
|
|
1041
|
+
print(f"[Shortcuts] {msg}")
|
|
1042
|
+
except Exception:
|
|
1043
|
+
pass
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
# ---------- New: export/import ----------
|
|
1047
|
+
def export_to_file(self, file_path: str) -> tuple[bool, str]:
|
|
1048
|
+
try:
|
|
1049
|
+
fp = self._ensure_ext(file_path, ".sass")
|
|
1050
|
+
|
|
1051
|
+
items = self._collect_live_items()
|
|
1052
|
+
payload = {
|
|
1053
|
+
"kind": SASS_KIND,
|
|
1054
|
+
"version": SASS_VER,
|
|
1055
|
+
"exported_at": int(time.time()),
|
|
1056
|
+
"items": items,
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
# NEW: include function bundles + chip layout if available
|
|
1060
|
+
fb_payload = self._export_function_bundles_for_shortcuts()
|
|
1061
|
+
if fb_payload is not None:
|
|
1062
|
+
payload["function_bundles"] = fb_payload
|
|
1063
|
+
|
|
1064
|
+
Path(fp).write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
1065
|
+
|
|
1066
|
+
# optional debug
|
|
1067
|
+
try:
|
|
1068
|
+
self._debug(f"export_to_file → {fp}, shortcuts={len(items)}, "
|
|
1069
|
+
f"fb_bundles={len((fb_payload or {}).get('bundles', []))}")
|
|
1070
|
+
except Exception:
|
|
1071
|
+
pass
|
|
1072
|
+
|
|
1073
|
+
return True, fp
|
|
1074
|
+
except Exception as e:
|
|
1075
|
+
try:
|
|
1076
|
+
self._debug(f"export_to_file FAILED: {e}")
|
|
1077
|
+
except Exception:
|
|
1078
|
+
pass
|
|
1079
|
+
return False, str(e)
|
|
1080
|
+
|
|
1081
|
+
def import_from_file(self, file_path: str, *, replace_existing: bool = False) -> tuple[bool, str]:
|
|
1082
|
+
try:
|
|
1083
|
+
txt = Path(file_path).read_text(encoding="utf-8")
|
|
1084
|
+
obj = json.loads(txt)
|
|
1085
|
+
|
|
1086
|
+
fb_payload = None
|
|
1087
|
+
|
|
1088
|
+
# Basic validation (accepts legacy raw arrays too)
|
|
1089
|
+
if isinstance(obj, dict) and obj.get("kind") == SASS_KIND:
|
|
1090
|
+
items = obj.get("items", [])
|
|
1091
|
+
fb_payload = obj.get("function_bundles")
|
|
1092
|
+
elif isinstance(obj, list):
|
|
1093
|
+
# legacy: straight array of items (no function bundle info)
|
|
1094
|
+
items = obj
|
|
1095
|
+
else:
|
|
1096
|
+
return False, "File is not a SAS shortcuts file."
|
|
1097
|
+
|
|
1098
|
+
# optional debug
|
|
1099
|
+
try:
|
|
1100
|
+
self._debug(
|
|
1101
|
+
f"import_from_file ← {file_path}, items={len(items)}, "
|
|
1102
|
+
f"has_fb={isinstance(fb_payload, dict)}, replace_existing={replace_existing}"
|
|
1103
|
+
)
|
|
1104
|
+
except Exception:
|
|
1105
|
+
pass
|
|
1106
|
+
|
|
1107
|
+
if replace_existing:
|
|
1108
|
+
self.clear() # clears both UI + settings keys, keeps manager ready
|
|
1109
|
+
|
|
1110
|
+
# Build widgets (shortcuts) as before
|
|
1111
|
+
for it in items:
|
|
1112
|
+
cid = it.get("command_id")
|
|
1113
|
+
if not cid:
|
|
1114
|
+
continue
|
|
1115
|
+
sid = it.get("id") or uuid.uuid4().hex
|
|
1116
|
+
x = int(it.get("x", 10))
|
|
1117
|
+
y = int(it.get("y", 10))
|
|
1118
|
+
label = it.get("label") or self._default_label_for(cid)
|
|
1119
|
+
|
|
1120
|
+
self.add_shortcut(cid, QPoint(x, y), label=label, shortcut_id=sid)
|
|
1121
|
+
|
|
1122
|
+
# If you also inline per-instance presets for normal shortcuts,
|
|
1123
|
+
# you can restore them here as well (omitted here for brevity).
|
|
1124
|
+
|
|
1125
|
+
# Persist shortcuts to QSettings
|
|
1126
|
+
self.save_shortcuts()
|
|
1127
|
+
|
|
1128
|
+
# NEW: Restore function bundle definitions + chips
|
|
1129
|
+
self._import_function_bundles_for_shortcuts(fb_payload, replace_existing=replace_existing)
|
|
1130
|
+
|
|
1131
|
+
return True, "OK"
|
|
1132
|
+
except Exception as e:
|
|
1133
|
+
try:
|
|
1134
|
+
self._debug(f"import_from_file FAILED: {e}")
|
|
1135
|
+
except Exception:
|
|
1136
|
+
pass
|
|
1137
|
+
return False, str(e)
|
|
1138
|
+
|
|
1139
|
+
def _icon_for_command(self, command_id: str, act: QAction | None) -> QIcon:
|
|
1140
|
+
# 1) Prefer the QAction icon (works for built-in tools and scripts if set)
|
|
1141
|
+
if act is not None:
|
|
1142
|
+
try:
|
|
1143
|
+
ico = act.icon()
|
|
1144
|
+
if ico is not None and not ico.isNull():
|
|
1145
|
+
return ico
|
|
1146
|
+
except Exception:
|
|
1147
|
+
pass
|
|
1148
|
+
|
|
1149
|
+
# 2) Optional: if the action carries a script_icon_path property, use it
|
|
1150
|
+
try:
|
|
1151
|
+
p = act.property("script_icon_path")
|
|
1152
|
+
if isinstance(p, str) and p.strip() and Path(p).exists():
|
|
1153
|
+
return QIcon(p.strip())
|
|
1154
|
+
except Exception:
|
|
1155
|
+
pass
|
|
1156
|
+
|
|
1157
|
+
# 3) Fallback for scripts: use the generic SCRIPT icon
|
|
1158
|
+
if isinstance(command_id, str) and command_id.startswith("script:"):
|
|
1159
|
+
try:
|
|
1160
|
+
ic = _get_icons()
|
|
1161
|
+
if ic is not None and hasattr(ic, "SCRIPT"):
|
|
1162
|
+
v = ic.SCRIPT
|
|
1163
|
+
return v if isinstance(v, QIcon) else QIcon(str(v))
|
|
1164
|
+
except Exception:
|
|
1165
|
+
pass
|
|
1166
|
+
|
|
1167
|
+
return QIcon()
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
# ---------- utils ----------
|
|
1171
|
+
def _ensure_ext(self, path: str, ext: str) -> str:
|
|
1172
|
+
p = Path(path)
|
|
1173
|
+
if p.suffix.lower() != ext.lower():
|
|
1174
|
+
p = p.with_suffix(ext)
|
|
1175
|
+
return str(p)
|
|
1176
|
+
|
|
1177
|
+
# ---- CRUD for shortcuts --------------------------------------------
|
|
1178
|
+
def _default_label_for(self, command_id: str) -> str:
|
|
1179
|
+
act = self.registry.get(command_id)
|
|
1180
|
+
if not act:
|
|
1181
|
+
return command_id
|
|
1182
|
+
return (act.text() or act.toolTip() or command_id).strip() or command_id
|
|
1183
|
+
|
|
1184
|
+
def add_shortcut(self,
|
|
1185
|
+
command_id: str,
|
|
1186
|
+
pos: QPoint,
|
|
1187
|
+
*,
|
|
1188
|
+
label: Optional[str] = None,
|
|
1189
|
+
shortcut_id: Optional[str] = None):
|
|
1190
|
+
"""
|
|
1191
|
+
Always creates a NEW instance (multiple per command_id allowed).
|
|
1192
|
+
"""
|
|
1193
|
+
act = self.registry.get(command_id)
|
|
1194
|
+
if not act:
|
|
1195
|
+
return
|
|
1196
|
+
|
|
1197
|
+
sid = shortcut_id or uuid.uuid4().hex
|
|
1198
|
+
lbl = (label or self._default_label_for(command_id)).strip() or command_id
|
|
1199
|
+
|
|
1200
|
+
ico = self._icon_for_command(command_id, act)
|
|
1201
|
+
w = ShortcutButton(self, sid, command_id, ico, lbl, self.canvas)
|
|
1202
|
+
w.adjustSize()
|
|
1203
|
+
w.move(pos)
|
|
1204
|
+
w.show()
|
|
1205
|
+
|
|
1206
|
+
# when the C++ object dies, clean maps using the SID
|
|
1207
|
+
w.destroyed.connect(lambda _=None, sid=sid: self._on_widget_destroyed(sid))
|
|
1208
|
+
|
|
1209
|
+
self.widgets[sid] = w
|
|
1210
|
+
self.save_shortcuts()
|
|
1211
|
+
|
|
1212
|
+
def add_shortcut_from_payload(self, payload: dict, pos: QPoint):
|
|
1213
|
+
"""
|
|
1214
|
+
Create a desktop shortcut from a full command payload
|
|
1215
|
+
(e.g. drag from History Explorer with command_id + preset).
|
|
1216
|
+
"""
|
|
1217
|
+
if not isinstance(payload, dict):
|
|
1218
|
+
return
|
|
1219
|
+
|
|
1220
|
+
cid = payload.get("command_id") or payload.get("cid")
|
|
1221
|
+
if not isinstance(cid, str) or not cid:
|
|
1222
|
+
return
|
|
1223
|
+
|
|
1224
|
+
preset = payload.get("preset") or {}
|
|
1225
|
+
if not isinstance(preset, dict):
|
|
1226
|
+
try:
|
|
1227
|
+
preset = dict(preset)
|
|
1228
|
+
except Exception:
|
|
1229
|
+
preset = {}
|
|
1230
|
+
|
|
1231
|
+
# Normal shortcut creation
|
|
1232
|
+
sid = uuid.uuid4().hex
|
|
1233
|
+
label = self._default_label_for(cid)
|
|
1234
|
+
self.add_shortcut(cid, pos, label=label, shortcut_id=sid)
|
|
1235
|
+
|
|
1236
|
+
# Attach preset at instance-level (same mechanism as context menu)
|
|
1237
|
+
w = self.widgets.get(sid)
|
|
1238
|
+
if w and not _is_dead(w):
|
|
1239
|
+
try:
|
|
1240
|
+
w._save_preset(preset)
|
|
1241
|
+
except Exception:
|
|
1242
|
+
pass
|
|
1243
|
+
|
|
1244
|
+
# Persist layout + presets
|
|
1245
|
+
self.save_shortcuts()
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def update_label(self, shortcut_id: str, new_label: str):
|
|
1249
|
+
w = self.widgets.get(shortcut_id)
|
|
1250
|
+
if w and not _is_dead(w):
|
|
1251
|
+
w.setText(new_label.strip()) # in case caller didn't already
|
|
1252
|
+
self.save_shortcuts()
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
# ---- persistence (QSettings JSON blob) ----
|
|
1256
|
+
def save_shortcuts(self):
|
|
1257
|
+
data = []
|
|
1258
|
+
for sid, w in list(self.widgets.items()):
|
|
1259
|
+
if _is_dead(w):
|
|
1260
|
+
self.widgets.pop(sid, None)
|
|
1261
|
+
self.selected.discard(sid)
|
|
1262
|
+
continue
|
|
1263
|
+
try:
|
|
1264
|
+
if not w.isVisible():
|
|
1265
|
+
continue
|
|
1266
|
+
p = w.pos()
|
|
1267
|
+
data.append({
|
|
1268
|
+
"id": sid,
|
|
1269
|
+
"command_id": w.command_id,
|
|
1270
|
+
"label": w.text(),
|
|
1271
|
+
"x": p.x(),
|
|
1272
|
+
"y": p.y(),
|
|
1273
|
+
})
|
|
1274
|
+
except RuntimeError:
|
|
1275
|
+
self.widgets.pop(sid, None)
|
|
1276
|
+
self.selected.discard(sid)
|
|
1277
|
+
|
|
1278
|
+
# Save new format and remove legacy
|
|
1279
|
+
self.settings.setValue(SET_KEY_V2, json.dumps(data))
|
|
1280
|
+
self.settings.remove(SET_KEY_V1)
|
|
1281
|
+
self.settings.sync()
|
|
1282
|
+
|
|
1283
|
+
def load_shortcuts(self):
|
|
1284
|
+
# try v2 first
|
|
1285
|
+
raw_v2 = self.settings.value(SET_KEY_V2, "", type=str) or ""
|
|
1286
|
+
if raw_v2:
|
|
1287
|
+
try:
|
|
1288
|
+
arr = json.loads(raw_v2)
|
|
1289
|
+
for entry in arr:
|
|
1290
|
+
sid = entry.get("id") or uuid.uuid4().hex
|
|
1291
|
+
cid = entry.get("command_id")
|
|
1292
|
+
x = int(entry.get("x", 10))
|
|
1293
|
+
y = int(entry.get("y", 10))
|
|
1294
|
+
label = entry.get("label") or self._default_label_for(cid)
|
|
1295
|
+
self.add_shortcut(cid, QPoint(x, y), label=label, shortcut_id=sid)
|
|
1296
|
+
return
|
|
1297
|
+
except Exception as e:
|
|
1298
|
+
try:
|
|
1299
|
+
self.mw._log(f"Shortcuts v2: failed to load ({e})")
|
|
1300
|
+
except Exception:
|
|
1301
|
+
pass
|
|
1302
|
+
|
|
1303
|
+
# migrate v1 (positions only)
|
|
1304
|
+
raw_v1 = self.settings.value(SET_KEY_V1, "", type=str) or ""
|
|
1305
|
+
if not raw_v1:
|
|
1306
|
+
return
|
|
1307
|
+
try:
|
|
1308
|
+
arr = json.loads(raw_v1)
|
|
1309
|
+
for entry in arr:
|
|
1310
|
+
cid = entry.get("id") or entry.get("command_id") # old key was "id" = command_id
|
|
1311
|
+
x = int(entry.get("x", 10))
|
|
1312
|
+
y = int(entry.get("y", 10))
|
|
1313
|
+
# each old entry becomes its own instance
|
|
1314
|
+
sid = uuid.uuid4().hex
|
|
1315
|
+
label = self._default_label_for(cid)
|
|
1316
|
+
self.add_shortcut(cid, QPoint(x, y), label=label, shortcut_id=sid)
|
|
1317
|
+
# after migrating, persist as v2
|
|
1318
|
+
self.save_shortcuts()
|
|
1319
|
+
except Exception as e:
|
|
1320
|
+
try:
|
|
1321
|
+
self.mw._log(f"Shortcuts v1: failed to migrate ({e})")
|
|
1322
|
+
except Exception:
|
|
1323
|
+
pass
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
def _load_keybinds(self) -> dict:
|
|
1327
|
+
raw = self.settings.value(KEYBINDS_KEY, "", type=str) or ""
|
|
1328
|
+
if not raw:
|
|
1329
|
+
return {}
|
|
1330
|
+
try:
|
|
1331
|
+
obj = json.loads(raw)
|
|
1332
|
+
return obj if isinstance(obj, dict) else {}
|
|
1333
|
+
except Exception:
|
|
1334
|
+
return {}
|
|
1335
|
+
|
|
1336
|
+
def _save_keybinds(self, d: dict):
|
|
1337
|
+
self.settings.setValue(KEYBINDS_KEY, json.dumps(d))
|
|
1338
|
+
self.settings.sync()
|
|
1339
|
+
|
|
1340
|
+
def set_keybind(self, command_id: str, keyseq: str | None):
|
|
1341
|
+
"""
|
|
1342
|
+
keyseq: e.g. "Ctrl+Alt+K". If None/empty => clear binding.
|
|
1343
|
+
Applies immediately if action is registered.
|
|
1344
|
+
"""
|
|
1345
|
+
d = self._load_keybinds()
|
|
1346
|
+
if keyseq and keyseq.strip():
|
|
1347
|
+
d[command_id] = keyseq.strip()
|
|
1348
|
+
else:
|
|
1349
|
+
d.pop(command_id, None)
|
|
1350
|
+
self._save_keybinds(d)
|
|
1351
|
+
|
|
1352
|
+
act = self.registry.get(command_id)
|
|
1353
|
+
if act is not None:
|
|
1354
|
+
if keyseq and keyseq.strip():
|
|
1355
|
+
act.setShortcut(QKeySequence(keyseq.strip()))
|
|
1356
|
+
act.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
|
|
1357
|
+
else:
|
|
1358
|
+
act.setShortcut(QKeySequence())
|
|
1359
|
+
|
|
1360
|
+
def apply_command_to_subwindow(self, subwin, payload):
|
|
1361
|
+
"""Apply a dragged command (or bundle) to the specific subwindow."""
|
|
1362
|
+
from PyQt6.QtWidgets import QApplication
|
|
1363
|
+
|
|
1364
|
+
# --- normalize payload to a dict ---
|
|
1365
|
+
if isinstance(payload, (bytes, bytearray)):
|
|
1366
|
+
try:
|
|
1367
|
+
payload = json.loads(payload.decode("utf-8"))
|
|
1368
|
+
except Exception:
|
|
1369
|
+
print("[Shortcuts] apply_command_to_subwindow: invalid bytes payload", flush=True)
|
|
1370
|
+
QApplication.processEvents()
|
|
1371
|
+
return
|
|
1372
|
+
if not isinstance(payload, dict):
|
|
1373
|
+
print(f"[Shortcuts] apply_command_to_subwindow: non-dict payload {type(payload)}", flush=True)
|
|
1374
|
+
QApplication.processEvents()
|
|
1375
|
+
return
|
|
1376
|
+
|
|
1377
|
+
print(f"[Shortcuts] apply_command_to_subwindow: subwin={subwin}, payload={payload!r}", flush=True)
|
|
1378
|
+
QApplication.processEvents()
|
|
1379
|
+
|
|
1380
|
+
# --- flatten accidental nesting:
|
|
1381
|
+
cid = payload.get("command_id")
|
|
1382
|
+
if isinstance(cid, dict):
|
|
1383
|
+
payload = cid
|
|
1384
|
+
cid = payload.get("command_id")
|
|
1385
|
+
|
|
1386
|
+
if not isinstance(cid, str) and isinstance(payload.get("command_id"), dict):
|
|
1387
|
+
payload = payload["command_id"]
|
|
1388
|
+
cid = payload.get("command_id")
|
|
1389
|
+
|
|
1390
|
+
if not isinstance(cid, str) or not cid:
|
|
1391
|
+
print("[Shortcuts] apply_command_to_subwindow: no valid command_id", flush=True)
|
|
1392
|
+
QApplication.processEvents()
|
|
1393
|
+
return
|
|
1394
|
+
|
|
1395
|
+
# --- function bundle handling ---
|
|
1396
|
+
if cid == "function_bundle":
|
|
1397
|
+
steps = payload.get("steps")
|
|
1398
|
+
inherit_target = bool(payload.get("inherit_target", True))
|
|
1399
|
+
print(f"[Shortcuts] function_bundle: {len(steps or [])} step(s), inherit_target={inherit_target}", flush=True)
|
|
1400
|
+
QApplication.processEvents()
|
|
1401
|
+
|
|
1402
|
+
# If explicit steps are present (chip DnD / history replay payload),
|
|
1403
|
+
# run them INLINE via the normal command path (same as FunctionBundleDialog).
|
|
1404
|
+
if isinstance(steps, list) and steps:
|
|
1405
|
+
for i, st in enumerate(steps, start=1):
|
|
1406
|
+
try:
|
|
1407
|
+
scid = st.get("command_id")
|
|
1408
|
+
except Exception:
|
|
1409
|
+
scid = None
|
|
1410
|
+
print(f"[Shortcuts] inline step {i}/{len(steps)} → {scid!r}", flush=True)
|
|
1411
|
+
QApplication.processEvents()
|
|
1412
|
+
# Reuse the same target subwindow for each step
|
|
1413
|
+
self.apply_command_to_subwindow(subwin, st)
|
|
1414
|
+
return
|
|
1415
|
+
|
|
1416
|
+
# No inline steps → this is a true 'function_bundle' command (e.g. from Scripts),
|
|
1417
|
+
# so delegate to the central executor.
|
|
1418
|
+
try:
|
|
1419
|
+
from setiastro.saspro.function_bundle import run_function_bundle_command
|
|
1420
|
+
print("[Shortcuts] function_bundle: using run_function_bundle_command (no inline steps)", flush=True)
|
|
1421
|
+
QApplication.processEvents()
|
|
1422
|
+
|
|
1423
|
+
try:
|
|
1424
|
+
self.mdi.setActiveSubWindow(subwin)
|
|
1425
|
+
except Exception:
|
|
1426
|
+
pass
|
|
1427
|
+
|
|
1428
|
+
# Pass the whole payload as cfg so things like bundle_key, etc., are available.
|
|
1429
|
+
cfg = dict(payload)
|
|
1430
|
+
try:
|
|
1431
|
+
run_function_bundle_command(self.mw, preset=payload.get("preset") or None, cfg=cfg)
|
|
1432
|
+
except TypeError:
|
|
1433
|
+
# older signature: (ctx, cfg)
|
|
1434
|
+
run_function_bundle_command(self.mw, cfg)
|
|
1435
|
+
print("[Shortcuts] function_bundle: run_function_bundle_command finished", flush=True)
|
|
1436
|
+
QApplication.processEvents()
|
|
1437
|
+
return
|
|
1438
|
+
except Exception as ex:
|
|
1439
|
+
print(f"[Shortcuts] function_bundle: FAILED in central executor: {ex!r}", flush=True)
|
|
1440
|
+
QApplication.processEvents()
|
|
1441
|
+
return
|
|
1442
|
+
|
|
1443
|
+
# --- primary path (unchanged) ---
|
|
1444
|
+
mw = self.mw
|
|
1445
|
+
try:
|
|
1446
|
+
if hasattr(mw, "_handle_command_drop"):
|
|
1447
|
+
print(f"[Shortcuts] forwarding cid={cid!r} to _handle_command_drop", flush=True)
|
|
1448
|
+
QApplication.processEvents()
|
|
1449
|
+
mw._handle_command_drop(payload, target_sw=subwin)
|
|
1450
|
+
return
|
|
1451
|
+
except Exception as ex:
|
|
1452
|
+
print(f"[Shortcuts] _handle_command_drop raised: {ex!r}, falling through", flush=True)
|
|
1453
|
+
QApplication.processEvents()
|
|
1454
|
+
|
|
1455
|
+
# --- secondary paths (unchanged) ---
|
|
1456
|
+
w = getattr(subwin, "widget", None)
|
|
1457
|
+
target = w() if callable(w) else w
|
|
1458
|
+
preset = payload.get("preset") or {}
|
|
1459
|
+
|
|
1460
|
+
if hasattr(target, "apply_command"):
|
|
1461
|
+
print(f"[Shortcuts] target.apply_command for cid={cid!r}", flush=True)
|
|
1462
|
+
QApplication.processEvents()
|
|
1463
|
+
target.apply_command(cid, preset)
|
|
1464
|
+
return
|
|
1465
|
+
if hasattr(mw, "apply_command_to_view"):
|
|
1466
|
+
print(f"[Shortcuts] mw.apply_command_to_view for cid={cid!r}", flush=True)
|
|
1467
|
+
QApplication.processEvents()
|
|
1468
|
+
mw.apply_command_to_view(target, cid, preset)
|
|
1469
|
+
return
|
|
1470
|
+
if hasattr(mw, "run_command"):
|
|
1471
|
+
print(f"[Shortcuts] mw.run_command for cid={cid!r}", flush=True)
|
|
1472
|
+
QApplication.processEvents()
|
|
1473
|
+
mw.run_command(cid, preset, view=target)
|
|
1474
|
+
return
|
|
1475
|
+
|
|
1476
|
+
print(f"[Shortcuts] fallback QAction trigger for cid={cid!r}", flush=True)
|
|
1477
|
+
QApplication.processEvents()
|
|
1478
|
+
self.mdi.setActiveSubWindow(subwin)
|
|
1479
|
+
act = self.registry.get(cid if isinstance(cid, str) else str(cid))
|
|
1480
|
+
if act:
|
|
1481
|
+
act.trigger()
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
# ---------- selection ----------
|
|
1485
|
+
def _apply_sel_visual(self, sid: str, on: bool):
|
|
1486
|
+
w = self.widgets.get(sid)
|
|
1487
|
+
if _is_dead(w):
|
|
1488
|
+
# Clean up any stale references
|
|
1489
|
+
self.widgets.pop(sid, None)
|
|
1490
|
+
self.selected.discard(sid)
|
|
1491
|
+
return
|
|
1492
|
+
try:
|
|
1493
|
+
if on:
|
|
1494
|
+
w.setStyleSheet("QToolButton { border: 2px solid #4da3ff; border-radius: 6px; padding: 2px; }")
|
|
1495
|
+
else:
|
|
1496
|
+
w.setStyleSheet("")
|
|
1497
|
+
except RuntimeError:
|
|
1498
|
+
# C++ object died between get() and call
|
|
1499
|
+
self.widgets.pop(sid, None)
|
|
1500
|
+
self.selected.discard(sid)
|
|
1501
|
+
|
|
1502
|
+
def select_only(self, sid: str):
|
|
1503
|
+
self.clear_selection()
|
|
1504
|
+
self.selected.add(sid)
|
|
1505
|
+
self._apply_sel_visual(sid, True)
|
|
1506
|
+
|
|
1507
|
+
def toggle_select(self, sid: str):
|
|
1508
|
+
if sid in self.selected:
|
|
1509
|
+
self.selected.remove(sid)
|
|
1510
|
+
self._apply_sel_visual(sid, False)
|
|
1511
|
+
else:
|
|
1512
|
+
self.selected.add(sid)
|
|
1513
|
+
self._apply_sel_visual(sid, True)
|
|
1514
|
+
|
|
1515
|
+
def select_in_rect(self, rect: QRect, *, mode: str = "replace"):
|
|
1516
|
+
if mode == "replace":
|
|
1517
|
+
self.clear_selection()
|
|
1518
|
+
for sid, w in list(self.widgets.items()):
|
|
1519
|
+
if _is_dead(w):
|
|
1520
|
+
self.widgets.pop(sid, None)
|
|
1521
|
+
self.selected.discard(sid)
|
|
1522
|
+
continue
|
|
1523
|
+
if rect.intersects(w.geometry()):
|
|
1524
|
+
if sid not in self.selected:
|
|
1525
|
+
self.selected.add(sid)
|
|
1526
|
+
self._apply_sel_visual(sid, True)
|
|
1527
|
+
|
|
1528
|
+
def selected_widgets(self):
|
|
1529
|
+
out = []
|
|
1530
|
+
for sid in list(self.selected):
|
|
1531
|
+
w = self.widgets.get(sid)
|
|
1532
|
+
if _is_dead(w):
|
|
1533
|
+
self.widgets.pop(sid, None)
|
|
1534
|
+
self.selected.discard(sid)
|
|
1535
|
+
continue
|
|
1536
|
+
out.append(w)
|
|
1537
|
+
return out
|
|
1538
|
+
|
|
1539
|
+
def clear_selection(self):
|
|
1540
|
+
"""Clear current selection highlight without deleting shortcuts."""
|
|
1541
|
+
# Remove highlight from all currently selected items
|
|
1542
|
+
for sid in list(self.selected):
|
|
1543
|
+
try:
|
|
1544
|
+
self._apply_sel_visual(sid, False)
|
|
1545
|
+
except Exception:
|
|
1546
|
+
pass
|
|
1547
|
+
self.selected.clear()
|
|
1548
|
+
|
|
1549
|
+
# Nudge repaint (optional but helps)
|
|
1550
|
+
try:
|
|
1551
|
+
self.canvas.update()
|
|
1552
|
+
except Exception:
|
|
1553
|
+
pass
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
def clear(self):
|
|
1557
|
+
for sid, w in list(self.widgets.items()):
|
|
1558
|
+
try:
|
|
1559
|
+
if not _is_dead(w):
|
|
1560
|
+
w.hide()
|
|
1561
|
+
try:
|
|
1562
|
+
w.setParent(None) # ← detach from canvas immediately
|
|
1563
|
+
except Exception:
|
|
1564
|
+
pass
|
|
1565
|
+
w.deleteLater()
|
|
1566
|
+
except RuntimeError:
|
|
1567
|
+
pass
|
|
1568
|
+
self.widgets.clear()
|
|
1569
|
+
self.selected.clear()
|
|
1570
|
+
self.settings.setValue(SET_KEY_V2, "[]")
|
|
1571
|
+
self.settings.remove(SET_KEY_V1)
|
|
1572
|
+
self.settings.sync()
|
|
1573
|
+
try:
|
|
1574
|
+
self.canvas.update() # nudge repaint
|
|
1575
|
+
except Exception:
|
|
1576
|
+
pass
|
|
1577
|
+
|
|
1578
|
+
|
|
1579
|
+
# ---------- group move / delete ----------
|
|
1580
|
+
def _group_bounds(self) -> QRect:
|
|
1581
|
+
rect = None
|
|
1582
|
+
for w in self.selected_widgets():
|
|
1583
|
+
rect = w.geometry() if rect is None else rect.united(w.geometry())
|
|
1584
|
+
return rect if rect is not None else QRect()
|
|
1585
|
+
|
|
1586
|
+
def move_selected_by(self, dx: int, dy: int):
|
|
1587
|
+
if not self.selected:
|
|
1588
|
+
return
|
|
1589
|
+
# clamp whole group to canvas bounds so relative spacing stays intact
|
|
1590
|
+
group = self._group_bounds()
|
|
1591
|
+
vp = self.canvas.rect()
|
|
1592
|
+
min_dx = vp.left() - group.left()
|
|
1593
|
+
max_dx = vp.right() - group.right()
|
|
1594
|
+
min_dy = vp.top() - group.top()
|
|
1595
|
+
max_dy = vp.bottom()- group.bottom()
|
|
1596
|
+
dx = max(min_dx, min(dx, max_dx))
|
|
1597
|
+
dy = max(min_dy, min(dy, max_dy))
|
|
1598
|
+
if dx == 0 and dy == 0:
|
|
1599
|
+
return
|
|
1600
|
+
for w in self.selected_widgets():
|
|
1601
|
+
g = w.geometry()
|
|
1602
|
+
g.translate(dx, dy)
|
|
1603
|
+
w.setGeometry(g)
|
|
1604
|
+
|
|
1605
|
+
def delete_by_id(self, sid: str, *, persist: bool = True):
|
|
1606
|
+
self.selected.discard(sid)
|
|
1607
|
+
w = self.widgets.pop(sid, None)
|
|
1608
|
+
if not _is_dead(w):
|
|
1609
|
+
try:
|
|
1610
|
+
w.hide()
|
|
1611
|
+
except RuntimeError:
|
|
1612
|
+
pass
|
|
1613
|
+
try:
|
|
1614
|
+
w.deleteLater()
|
|
1615
|
+
except RuntimeError:
|
|
1616
|
+
pass
|
|
1617
|
+
if persist:
|
|
1618
|
+
self.save_shortcuts()
|
|
1619
|
+
|
|
1620
|
+
def delete_selected(self):
|
|
1621
|
+
# bulk delete, then persist once
|
|
1622
|
+
for sid in list(self.selected):
|
|
1623
|
+
self.delete_by_id(sid, persist=False)
|
|
1624
|
+
self.selected.clear()
|
|
1625
|
+
self.save_shortcuts()
|
|
1626
|
+
|
|
1627
|
+
def remove(self, sid: str):
|
|
1628
|
+
# legacy single-remove (kept for callers)
|
|
1629
|
+
self.delete_by_id(sid, persist=True)
|
|
1630
|
+
|
|
1631
|
+
|
|
1632
|
+
class _StatStretchPresetDialog(QDialog):
|
|
1633
|
+
def __init__(self, parent=None, initial: dict | None = None):
|
|
1634
|
+
super().__init__(parent)
|
|
1635
|
+
self.setWindowTitle("Statistical Stretch — Preset")
|
|
1636
|
+
init = dict(initial or {})
|
|
1637
|
+
|
|
1638
|
+
self.spin_target = QDoubleSpinBox()
|
|
1639
|
+
self.spin_target.setRange(0.0, 1.0); self.spin_target.setDecimals(3)
|
|
1640
|
+
self.spin_target.setSingleStep(0.01)
|
|
1641
|
+
self.spin_target.setValue(float(init.get("target_median", 0.25)))
|
|
1642
|
+
|
|
1643
|
+
self.chk_linked = QCheckBox("Linked RGB channels")
|
|
1644
|
+
self.chk_linked.setChecked(bool(init.get("linked", False)))
|
|
1645
|
+
|
|
1646
|
+
self.chk_normalize = QCheckBox("Normalize to [0..1]")
|
|
1647
|
+
self.chk_normalize.setChecked(bool(init.get("normalize", False)))
|
|
1648
|
+
|
|
1649
|
+
self.spin_curves = QDoubleSpinBox()
|
|
1650
|
+
self.spin_curves.setRange(0.0, 1.0); self.spin_curves.setDecimals(2)
|
|
1651
|
+
self.spin_curves.setSingleStep(0.05)
|
|
1652
|
+
self.spin_curves.setValue(float(init.get("curves_boost", 0.0 if not init.get("apply_curves") else 0.20)))
|
|
1653
|
+
|
|
1654
|
+
form = QFormLayout(self)
|
|
1655
|
+
form.addRow("Target median:", self.spin_target)
|
|
1656
|
+
form.addRow("", self.chk_linked)
|
|
1657
|
+
form.addRow("", self.chk_normalize)
|
|
1658
|
+
form.addRow("Curves boost (0–1):", self.spin_curves)
|
|
1659
|
+
form.addRow(QLabel("Curves are applied only if boost > 0."))
|
|
1660
|
+
|
|
1661
|
+
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
|
|
1662
|
+
btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
|
|
1663
|
+
form.addRow(btns)
|
|
1664
|
+
|
|
1665
|
+
def result_dict(self) -> dict:
|
|
1666
|
+
boost = float(self.spin_curves.value())
|
|
1667
|
+
return {
|
|
1668
|
+
"target_median": float(self.spin_target.value()),
|
|
1669
|
+
"linked": bool(self.chk_linked.isChecked()),
|
|
1670
|
+
"normalize": bool(self.chk_normalize.isChecked()),
|
|
1671
|
+
"apply_curves": bool(boost > 0.0),
|
|
1672
|
+
"curves_boost": boost,
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
|
|
1676
|
+
class _StarStretchPresetDialog(QDialog):
|
|
1677
|
+
def __init__(self, parent=None, initial: dict | None = None):
|
|
1678
|
+
super().__init__(parent)
|
|
1679
|
+
self.setWindowTitle("Star Stretch — Preset")
|
|
1680
|
+
init = dict(initial or {})
|
|
1681
|
+
|
|
1682
|
+
self.spin_amount = QDoubleSpinBox()
|
|
1683
|
+
self.spin_amount.setRange(0.0, 8.0); self.spin_amount.setDecimals(2)
|
|
1684
|
+
self.spin_amount.setSingleStep(0.05)
|
|
1685
|
+
self.spin_amount.setValue(float(init.get("stretch_factor", 5.00)))
|
|
1686
|
+
|
|
1687
|
+
self.spin_sat = QDoubleSpinBox()
|
|
1688
|
+
self.spin_sat.setRange(0.0, 2.0); self.spin_sat.setDecimals(2)
|
|
1689
|
+
self.spin_sat.setSingleStep(0.05)
|
|
1690
|
+
self.spin_sat.setValue(float(init.get("color_boost", 1.00)))
|
|
1691
|
+
|
|
1692
|
+
self.chk_scnr = QCheckBox("Remove Green via SCNR")
|
|
1693
|
+
self.chk_scnr.setChecked(bool(init.get("scnr_green", False)))
|
|
1694
|
+
|
|
1695
|
+
form = QFormLayout(self)
|
|
1696
|
+
form.addRow("Stretch amount (0–8):", self.spin_amount)
|
|
1697
|
+
form.addRow("Color boost (0–2):", self.spin_sat)
|
|
1698
|
+
form.addRow("", self.chk_scnr)
|
|
1699
|
+
|
|
1700
|
+
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
|
|
1701
|
+
btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
|
|
1702
|
+
form.addRow(btns)
|
|
1703
|
+
|
|
1704
|
+
def result_dict(self) -> dict:
|
|
1705
|
+
return {
|
|
1706
|
+
"stretch_factor": float(self.spin_amount.value()), # 0..8
|
|
1707
|
+
"color_boost": float(self.spin_sat.value()), # 0..2
|
|
1708
|
+
"scnr_green": bool(self.chk_scnr.isChecked()),
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
class _RemoveGreenPresetDialog(QDialog):
|
|
1712
|
+
def __init__(self, parent=None, initial: dict | None = None):
|
|
1713
|
+
super().__init__(parent)
|
|
1714
|
+
self.setWindowTitle("Remove Green — Preset")
|
|
1715
|
+
init = dict(initial or {})
|
|
1716
|
+
|
|
1717
|
+
# Local labels so there’s no external dependency.
|
|
1718
|
+
MODE_LABELS = {
|
|
1719
|
+
"avg": "Average neutral (G → min(avg(R,B), G))",
|
|
1720
|
+
"max": "Average neutral MAX (G → min(max(R,B), G))",
|
|
1721
|
+
"min": "Average neutral MIN (G → min(min(R,B), G))",
|
|
1722
|
+
}
|
|
1723
|
+
MODE_INDEX = {"avg": 0, "max": 1, "min": 2}
|
|
1724
|
+
|
|
1725
|
+
# Amount
|
|
1726
|
+
self.spin_amount = QDoubleSpinBox()
|
|
1727
|
+
self.spin_amount.setRange(0.0, 1.0)
|
|
1728
|
+
self.spin_amount.setDecimals(2)
|
|
1729
|
+
self.spin_amount.setSingleStep(0.05)
|
|
1730
|
+
self.spin_amount.setValue(float(init.get("amount", 1.00))) # default full SCNR
|
|
1731
|
+
|
|
1732
|
+
# Mode
|
|
1733
|
+
self.combo_mode = QComboBox()
|
|
1734
|
+
self.combo_mode.addItem(MODE_LABELS["avg"], userData="avg")
|
|
1735
|
+
self.combo_mode.addItem(MODE_LABELS["max"], userData="max")
|
|
1736
|
+
self.combo_mode.addItem(MODE_LABELS["min"], userData="min")
|
|
1737
|
+
init_mode = str(init.get("mode", init.get("neutral_mode", "avg"))).lower()
|
|
1738
|
+
self.combo_mode.setCurrentIndex(MODE_INDEX.get(init_mode, 0))
|
|
1739
|
+
|
|
1740
|
+
# Preserve lightness
|
|
1741
|
+
self.cb_preserve = QCheckBox("Preserve lightness")
|
|
1742
|
+
self.cb_preserve.setChecked(bool(init.get("preserve_lightness", init.get("preserve", True))))
|
|
1743
|
+
|
|
1744
|
+
# Layout
|
|
1745
|
+
form = QFormLayout(self)
|
|
1746
|
+
form.addRow("Amount (0–1):", self.spin_amount)
|
|
1747
|
+
form.addRow("Neutral mode:", self.combo_mode)
|
|
1748
|
+
form.addRow("", self.cb_preserve)
|
|
1749
|
+
|
|
1750
|
+
btns = QDialogButtonBox(
|
|
1751
|
+
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
|
|
1752
|
+
parent=self
|
|
1753
|
+
)
|
|
1754
|
+
btns.accepted.connect(self.accept)
|
|
1755
|
+
btns.rejected.connect(self.reject)
|
|
1756
|
+
form.addRow(btns)
|
|
1757
|
+
|
|
1758
|
+
def result_dict(self) -> dict:
|
|
1759
|
+
return {
|
|
1760
|
+
"amount": float(self.spin_amount.value()), # 0..1
|
|
1761
|
+
"mode": self.combo_mode.currentData() or "avg", # "avg" | "max" | "min"
|
|
1762
|
+
"preserve_lightness": bool(self.cb_preserve.isChecked()), # True/False
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
|
|
1766
|
+
class _BackgroundNeutralPresetDialog(QDialog):
|
|
1767
|
+
"""
|
|
1768
|
+
Preset UI for Background Neutralization:
|
|
1769
|
+
• Mode: Auto (default) or Rectangle
|
|
1770
|
+
• Rect (normalized): x, y, w, h in [0..1]
|
|
1771
|
+
"""
|
|
1772
|
+
def __init__(self, parent=None, initial: dict | None = None):
|
|
1773
|
+
super().__init__(parent)
|
|
1774
|
+
self.setWindowTitle("Background Neutralization — Preset")
|
|
1775
|
+
init = dict(initial or {})
|
|
1776
|
+
|
|
1777
|
+
# Mode radios
|
|
1778
|
+
self.radio_auto = QRadioButton("Auto (50×50 finder)")
|
|
1779
|
+
self.radio_rect = QRadioButton("Rectangle (normalized coords)")
|
|
1780
|
+
mode = (init.get("mode") or "auto").lower()
|
|
1781
|
+
if mode == "rect":
|
|
1782
|
+
self.radio_rect.setChecked(True)
|
|
1783
|
+
else:
|
|
1784
|
+
self.radio_auto.setChecked(True)
|
|
1785
|
+
|
|
1786
|
+
# Rect spinboxes (normalized 0..1)
|
|
1787
|
+
rn = init.get("rect_norm") or [0.40, 0.60, 0.08, 0.06]
|
|
1788
|
+
self.spin_x = QDoubleSpinBox(); self._cfg_norm_box(self.spin_x, rn[0])
|
|
1789
|
+
self.spin_y = QDoubleSpinBox(); self._cfg_norm_box(self.spin_y, rn[1])
|
|
1790
|
+
self.spin_w = QDoubleSpinBox(); self._cfg_norm_box(self.spin_w, rn[2])
|
|
1791
|
+
self.spin_h = QDoubleSpinBox(); self._cfg_norm_box(self.spin_h, rn[3])
|
|
1792
|
+
|
|
1793
|
+
form = QFormLayout(self)
|
|
1794
|
+
form.addRow(self.radio_auto)
|
|
1795
|
+
form.addRow(self.radio_rect)
|
|
1796
|
+
form.addRow("x (0..1):", self.spin_x)
|
|
1797
|
+
form.addRow("y (0..1):", self.spin_y)
|
|
1798
|
+
form.addRow("w (0..1):", self.spin_w)
|
|
1799
|
+
form.addRow("h (0..1):", self.spin_h)
|
|
1800
|
+
|
|
1801
|
+
# Enable/disable rect fields based on mode
|
|
1802
|
+
self.radio_auto.toggled.connect(self._update_enabled)
|
|
1803
|
+
self.radio_rect.toggled.connect(self._update_enabled)
|
|
1804
|
+
self._update_enabled()
|
|
1805
|
+
|
|
1806
|
+
btns = QDialogButtonBox(
|
|
1807
|
+
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
|
|
1808
|
+
parent=self
|
|
1809
|
+
)
|
|
1810
|
+
btns.accepted.connect(self.accept)
|
|
1811
|
+
btns.rejected.connect(self.reject)
|
|
1812
|
+
form.addRow(btns)
|
|
1813
|
+
|
|
1814
|
+
def _cfg_norm_box(self, box: QDoubleSpinBox, val: float):
|
|
1815
|
+
box.setRange(0.0, 1.0)
|
|
1816
|
+
box.setDecimals(3)
|
|
1817
|
+
box.setSingleStep(0.01)
|
|
1818
|
+
try:
|
|
1819
|
+
box.setValue(float(val))
|
|
1820
|
+
except Exception:
|
|
1821
|
+
box.setValue(0.0)
|
|
1822
|
+
|
|
1823
|
+
def _update_enabled(self):
|
|
1824
|
+
on = self.radio_rect.isChecked()
|
|
1825
|
+
for w in (self.spin_x, self.spin_y, self.spin_w, self.spin_h):
|
|
1826
|
+
w.setEnabled(on)
|
|
1827
|
+
|
|
1828
|
+
def result_dict(self) -> dict:
|
|
1829
|
+
if self.radio_auto.isChecked():
|
|
1830
|
+
return {"mode": "auto"}
|
|
1831
|
+
# sanitize/cap in [0,1]
|
|
1832
|
+
x = max(0.0, min(1.0, float(self.spin_x.value())))
|
|
1833
|
+
y = max(0.0, min(1.0, float(self.spin_y.value())))
|
|
1834
|
+
w = max(0.0, min(1.0, float(self.spin_w.value())))
|
|
1835
|
+
h = max(0.0, min(1.0, float(self.spin_h.value())))
|
|
1836
|
+
# ensure at least a 1e-6 nonzero footprint so integer rounding later doesn't zero-out
|
|
1837
|
+
if w == 0.0: w = 1e-6
|
|
1838
|
+
if h == 0.0: h = 1e-6
|
|
1839
|
+
return {"mode": "rect", "rect_norm": [x, y, w, h]}
|
|
1840
|
+
|
|
1841
|
+
class _WhiteBalancePresetDialog(QDialog):
|
|
1842
|
+
def __init__(self, parent=None, initial: dict | None = None):
|
|
1843
|
+
super().__init__(parent)
|
|
1844
|
+
self.setWindowTitle("White Balance — Preset")
|
|
1845
|
+
init = dict(initial or {})
|
|
1846
|
+
|
|
1847
|
+
v = QVBoxLayout(self)
|
|
1848
|
+
|
|
1849
|
+
# Mode
|
|
1850
|
+
row = QHBoxLayout()
|
|
1851
|
+
row.addWidget(QLabel("Mode:"))
|
|
1852
|
+
self.mode = QComboBox()
|
|
1853
|
+
self.mode.addItems(["Star-Based", "Manual", "Auto"])
|
|
1854
|
+
m = (init.get("mode") or "star").lower()
|
|
1855
|
+
if m == "manual": self.mode.setCurrentText("Manual")
|
|
1856
|
+
elif m == "auto": self.mode.setCurrentText("Auto")
|
|
1857
|
+
else: self.mode.setCurrentText("Star-Based")
|
|
1858
|
+
row.addWidget(self.mode); row.addStretch()
|
|
1859
|
+
v.addLayout(row)
|
|
1860
|
+
|
|
1861
|
+
# Star options
|
|
1862
|
+
self.grp_star = QGroupBox("Star-Based")
|
|
1863
|
+
sv = QGridLayout(self.grp_star)
|
|
1864
|
+
self.spin_thr = QDoubleSpinBox(); self.spin_thr.setRange(0.5, 200.0); self.spin_thr.setDecimals(1)
|
|
1865
|
+
self.spin_thr.setSingleStep(0.5); self.spin_thr.setValue(float(init.get("threshold", 50.0)))
|
|
1866
|
+
self.chk_reuse = QCheckBox("Reuse cached detections"); self.chk_reuse.setChecked(bool(init.get("reuse_cached_sources", True)))
|
|
1867
|
+
sv.addWidget(QLabel("Threshold (σ):"), 0, 0); sv.addWidget(self.spin_thr, 0, 1)
|
|
1868
|
+
sv.addWidget(self.chk_reuse, 1, 0, 1, 2)
|
|
1869
|
+
v.addWidget(self.grp_star)
|
|
1870
|
+
|
|
1871
|
+
# Manual options
|
|
1872
|
+
self.grp_manual = QGroupBox("Manual")
|
|
1873
|
+
gv = QGridLayout(self.grp_manual)
|
|
1874
|
+
self.r = QDoubleSpinBox(); self._cfg_gain(self.r, float(init.get("r_gain", 1.0)))
|
|
1875
|
+
self.g = QDoubleSpinBox(); self._cfg_gain(self.g, float(init.get("g_gain", 1.0)))
|
|
1876
|
+
self.b = QDoubleSpinBox(); self._cfg_gain(self.b, float(init.get("b_gain", 1.0)))
|
|
1877
|
+
gv.addWidget(QLabel("Red gain:"), 0, 0); gv.addWidget(self.r, 0, 1)
|
|
1878
|
+
gv.addWidget(QLabel("Green gain:"), 1, 0); gv.addWidget(self.g, 1, 1)
|
|
1879
|
+
gv.addWidget(QLabel("Blue gain:"), 2, 0); gv.addWidget(self.b, 2, 1)
|
|
1880
|
+
v.addWidget(self.grp_manual)
|
|
1881
|
+
|
|
1882
|
+
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
|
|
1883
|
+
btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
|
|
1884
|
+
v.addWidget(btns)
|
|
1885
|
+
|
|
1886
|
+
self.mode.currentTextChanged.connect(self._refresh)
|
|
1887
|
+
self._refresh()
|
|
1888
|
+
|
|
1889
|
+
def _cfg_gain(self, box: QDoubleSpinBox, val: float):
|
|
1890
|
+
box.setRange(0.5, 1.5); box.setDecimals(3); box.setSingleStep(0.01); box.setValue(val)
|
|
1891
|
+
|
|
1892
|
+
def _refresh(self):
|
|
1893
|
+
t = self.mode.currentText()
|
|
1894
|
+
self.grp_star.setVisible(t == "Star-Based")
|
|
1895
|
+
self.grp_manual.setVisible(t == "Manual")
|
|
1896
|
+
|
|
1897
|
+
def result_dict(self) -> dict:
|
|
1898
|
+
t = self.mode.currentText()
|
|
1899
|
+
if t == "Manual":
|
|
1900
|
+
return {"mode": "manual", "r_gain": float(self.r.value()), "g_gain": float(self.g.value()), "b_gain": float(self.b.value())}
|
|
1901
|
+
if t == "Auto":
|
|
1902
|
+
return {"mode": "auto"}
|
|
1903
|
+
return {"mode": "star", "threshold": float(self.spin_thr.value()), "reuse_cached_sources": bool(self.chk_reuse.isChecked())}
|
|
1904
|
+
|
|
1905
|
+
|
|
1906
|
+
class _WaveScaleHDRPresetDialog(QDialog):
|
|
1907
|
+
"""
|
|
1908
|
+
Preset UI for WaveScale HDR:
|
|
1909
|
+
• n_scales (2..10)
|
|
1910
|
+
• compression_factor (0.10..5.00)
|
|
1911
|
+
• mask_gamma (0.10..10.00)
|
|
1912
|
+
• decay_rate (0.10..1.00)
|
|
1913
|
+
• optional dim_gamma (enable to store; omit to use auto)
|
|
1914
|
+
"""
|
|
1915
|
+
def __init__(self, parent=None, initial: dict | None = None):
|
|
1916
|
+
super().__init__(parent)
|
|
1917
|
+
self.setWindowTitle("WaveScale HDR — Preset")
|
|
1918
|
+
init = dict(initial or {})
|
|
1919
|
+
|
|
1920
|
+
form = QFormLayout(self)
|
|
1921
|
+
|
|
1922
|
+
self.sp_scales = QSpinBox()
|
|
1923
|
+
self.sp_scales.setRange(2, 10)
|
|
1924
|
+
self.sp_scales.setValue(int(init.get("n_scales", 5)))
|
|
1925
|
+
|
|
1926
|
+
self.dp_comp = QDoubleSpinBox()
|
|
1927
|
+
self.dp_comp.setRange(0.10, 5.00)
|
|
1928
|
+
self.dp_comp.setDecimals(2)
|
|
1929
|
+
self.dp_comp.setSingleStep(0.05)
|
|
1930
|
+
self.dp_comp.setValue(float(init.get("compression_factor", 1.50)))
|
|
1931
|
+
|
|
1932
|
+
self.dp_gamma = QDoubleSpinBox()
|
|
1933
|
+
self.dp_gamma.setRange(0.10, 10.00)
|
|
1934
|
+
self.dp_gamma.setDecimals(2)
|
|
1935
|
+
self.dp_gamma.setSingleStep(0.05)
|
|
1936
|
+
# matches slider default of 500 → 5.00
|
|
1937
|
+
self.dp_gamma.setValue(float(init.get("mask_gamma", 5.00)))
|
|
1938
|
+
|
|
1939
|
+
self.dp_decay = QDoubleSpinBox()
|
|
1940
|
+
self.dp_decay.setRange(0.10, 1.00)
|
|
1941
|
+
self.dp_decay.setDecimals(2)
|
|
1942
|
+
self.dp_decay.setSingleStep(0.05)
|
|
1943
|
+
self.dp_decay.setValue(float(init.get("decay_rate", 0.50)))
|
|
1944
|
+
|
|
1945
|
+
# Optional dim gamma
|
|
1946
|
+
row_dim = QHBoxLayout()
|
|
1947
|
+
self.chk_dim = QCheckBox("Use custom dim γ")
|
|
1948
|
+
self.dp_dim = QDoubleSpinBox()
|
|
1949
|
+
self.dp_dim.setRange(0.10, 6.00)
|
|
1950
|
+
self.dp_dim.setDecimals(2)
|
|
1951
|
+
self.dp_dim.setSingleStep(0.05)
|
|
1952
|
+
self.dp_dim.setValue(float(init.get("dim_gamma", 2.00)))
|
|
1953
|
+
if "dim_gamma" in init:
|
|
1954
|
+
self.chk_dim.setChecked(True)
|
|
1955
|
+
self.dp_dim.setEnabled(self.chk_dim.isChecked())
|
|
1956
|
+
self.chk_dim.toggled.connect(self.dp_dim.setEnabled)
|
|
1957
|
+
row_dim.addWidget(self.chk_dim)
|
|
1958
|
+
row_dim.addWidget(self.dp_dim, 1)
|
|
1959
|
+
|
|
1960
|
+
form.addRow("Number of scales:", self.sp_scales)
|
|
1961
|
+
form.addRow("Coarse compression:", self.dp_comp)
|
|
1962
|
+
form.addRow("Mask gamma:", self.dp_gamma)
|
|
1963
|
+
form.addRow("Decay rate:", self.dp_decay)
|
|
1964
|
+
form.addRow("Dimming:", row_dim)
|
|
1965
|
+
|
|
1966
|
+
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
|
|
1967
|
+
QDialogButtonBox.StandardButton.Cancel, parent=self)
|
|
1968
|
+
btns.accepted.connect(self.accept)
|
|
1969
|
+
btns.rejected.connect(self.reject)
|
|
1970
|
+
form.addRow(btns)
|
|
1971
|
+
|
|
1972
|
+
def result_dict(self) -> dict:
|
|
1973
|
+
out = {
|
|
1974
|
+
"n_scales": int(self.sp_scales.value()),
|
|
1975
|
+
"compression_factor": float(self.dp_comp.value()),
|
|
1976
|
+
"mask_gamma": float(self.dp_gamma.value()),
|
|
1977
|
+
"decay_rate": float(self.dp_decay.value()),
|
|
1978
|
+
}
|
|
1979
|
+
if self.chk_dim.isChecked():
|
|
1980
|
+
out["dim_gamma"] = float(self.dp_dim.value()) # you said you'll add this param
|
|
1981
|
+
return out
|
|
1982
|
+
|
|
1983
|
+
class _WaveScaleDarkEnhancerPresetDialog(QDialog):
|
|
1984
|
+
"""
|
|
1985
|
+
Preset UI for WaveScale Dark Enhancer:
|
|
1986
|
+
• n_scales (2–10)
|
|
1987
|
+
• boost_factor (0.10–10.00)
|
|
1988
|
+
• mask_gamma (0.10–10.00)
|
|
1989
|
+
• iterations (1–10)
|
|
1990
|
+
"""
|
|
1991
|
+
def __init__(self, parent=None, initial: dict | None = None):
|
|
1992
|
+
super().__init__(parent)
|
|
1993
|
+
self.setWindowTitle("WaveScale Dark Enhancer — Preset")
|
|
1994
|
+
init = dict(initial or {})
|
|
1995
|
+
|
|
1996
|
+
form = QFormLayout(self)
|
|
1997
|
+
|
|
1998
|
+
self.sp_scales = QSpinBox(); self.sp_scales.setRange(2, 10); self.sp_scales.setValue(int(init.get("n_scales", 6)))
|
|
1999
|
+
self.dp_boost = QDoubleSpinBox(); self.dp_boost.setRange(0.10, 10.00); self.dp_boost.setDecimals(2); self.dp_boost.setSingleStep(0.05)
|
|
2000
|
+
self.dp_boost.setValue(float(init.get("boost_factor", 5.00)))
|
|
2001
|
+
self.dp_gamma = QDoubleSpinBox(); self.dp_gamma.setRange(0.10, 10.00); self.dp_gamma.setDecimals(2); self.dp_gamma.setSingleStep(0.05)
|
|
2002
|
+
self.dp_gamma.setValue(float(init.get("mask_gamma", 1.00)))
|
|
2003
|
+
self.sp_iters = QSpinBox(); self.sp_iters.setRange(1, 10); self.sp_iters.setValue(int(init.get("iterations", 2)))
|
|
2004
|
+
|
|
2005
|
+
form.addRow("Number of scales:", self.sp_scales)
|
|
2006
|
+
form.addRow("Boost factor:", self.dp_boost)
|
|
2007
|
+
form.addRow("Mask gamma:", self.dp_gamma)
|
|
2008
|
+
form.addRow("Iterations:", self.sp_iters)
|
|
2009
|
+
|
|
2010
|
+
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
|
|
2011
|
+
QDialogButtonBox.StandardButton.Cancel,
|
|
2012
|
+
parent=self)
|
|
2013
|
+
btns.accepted.connect(self.accept)
|
|
2014
|
+
btns.rejected.connect(self.reject)
|
|
2015
|
+
form.addRow(btns)
|
|
2016
|
+
|
|
2017
|
+
def result_dict(self) -> dict:
|
|
2018
|
+
return {
|
|
2019
|
+
"n_scales": int(self.sp_scales.value()),
|
|
2020
|
+
"boost_factor": float(self.dp_boost.value()),
|
|
2021
|
+
"mask_gamma": float(self.dp_gamma.value()),
|
|
2022
|
+
"iterations": int(self.sp_iters.value()),
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
class _CLAHEPresetDialog(QDialog):
|
|
2026
|
+
"""
|
|
2027
|
+
Preset UI for CLAHE:
|
|
2028
|
+
• clip_limit (0.10–4.00)
|
|
2029
|
+
• tile_px (8–512 px) → converted to OpenCV tileGridSize based on image size
|
|
2030
|
+
"""
|
|
2031
|
+
def __init__(self, parent=None, initial: dict | None = None):
|
|
2032
|
+
super().__init__(parent)
|
|
2033
|
+
self.setWindowTitle("CLAHE — Preset")
|
|
2034
|
+
init = dict(initial or {})
|
|
2035
|
+
|
|
2036
|
+
form = QFormLayout(self)
|
|
2037
|
+
|
|
2038
|
+
self.dp_clip = QDoubleSpinBox()
|
|
2039
|
+
self.dp_clip.setRange(0.10, 4.00)
|
|
2040
|
+
self.dp_clip.setDecimals(2)
|
|
2041
|
+
self.dp_clip.setSingleStep(0.10)
|
|
2042
|
+
self.dp_clip.setValue(float(init.get("clip_limit", 2.00)))
|
|
2043
|
+
|
|
2044
|
+
self.sp_tile_px = QSpinBox()
|
|
2045
|
+
self.sp_tile_px.setRange(8, 512)
|
|
2046
|
+
self.sp_tile_px.setSingleStep(8)
|
|
2047
|
+
|
|
2048
|
+
# Support both old and new in the UI:
|
|
2049
|
+
if "tile_px" in init:
|
|
2050
|
+
self.sp_tile_px.setValue(int(init.get("tile_px", 128)))
|
|
2051
|
+
else:
|
|
2052
|
+
# legacy tile count → rough px guess; keeps old presets "reasonable"
|
|
2053
|
+
legacy_tile = int(init.get("tile", 8))
|
|
2054
|
+
legacy_tile = max(2, min(legacy_tile, 128))
|
|
2055
|
+
# Heuristic: convert tile count to a "typical" px size assuming ~2048 min dim
|
|
2056
|
+
px_guess = int(round(2048 / float(legacy_tile)))
|
|
2057
|
+
px_guess = max(8, min(px_guess, 512))
|
|
2058
|
+
# snap to step 8
|
|
2059
|
+
px_guess = int(round(px_guess / 8)) * 8
|
|
2060
|
+
self.sp_tile_px.setValue(px_guess)
|
|
2061
|
+
|
|
2062
|
+
form.addRow("Clip limit:", self.dp_clip)
|
|
2063
|
+
form.addRow("Tile size (px):", self.sp_tile_px)
|
|
2064
|
+
|
|
2065
|
+
btns = QDialogButtonBox(
|
|
2066
|
+
QDialogButtonBox.StandardButton.Ok |
|
|
2067
|
+
QDialogButtonBox.StandardButton.Cancel,
|
|
2068
|
+
parent=self
|
|
2069
|
+
)
|
|
2070
|
+
btns.accepted.connect(self.accept)
|
|
2071
|
+
btns.rejected.connect(self.reject)
|
|
2072
|
+
form.addRow(btns)
|
|
2073
|
+
|
|
2074
|
+
def result_dict(self) -> dict:
|
|
2075
|
+
return {
|
|
2076
|
+
"clip_limit": float(self.dp_clip.value()),
|
|
2077
|
+
"tile_px": int(self.sp_tile_px.value()),
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
class _MorphologyPresetDialog(QDialog):
|
|
2081
|
+
def __init__(self, parent=None, initial: dict | None = None):
|
|
2082
|
+
super().__init__(parent)
|
|
2083
|
+
self.setWindowTitle("Morphology — Preset")
|
|
2084
|
+
init = dict(initial or {})
|
|
2085
|
+
|
|
2086
|
+
form = QFormLayout(self)
|
|
2087
|
+
|
|
2088
|
+
self.op = QComboBox()
|
|
2089
|
+
self.op.addItems(["Erosion", "Dilation", "Opening", "Closing"])
|
|
2090
|
+
op = (init.get("operation","erosion") or "erosion").lower()
|
|
2091
|
+
idx = {"erosion":0,"dilation":1,"opening":2,"closing":3}.get(op,0)
|
|
2092
|
+
self.op.setCurrentIndex(idx)
|
|
2093
|
+
|
|
2094
|
+
self.k = QSpinBox(); self.k.setRange(1,31); self.k.setSingleStep(2)
|
|
2095
|
+
kv = int(init.get("kernel", 3)); self.k.setValue(kv if kv%2==1 else kv+1)
|
|
2096
|
+
|
|
2097
|
+
self.it = QSpinBox(); self.it.setRange(1,10); self.it.setValue(int(init.get("iterations",1)))
|
|
2098
|
+
|
|
2099
|
+
form.addRow("Operation:", self.op)
|
|
2100
|
+
form.addRow("Kernel size (odd):", self.k)
|
|
2101
|
+
form.addRow("Iterations:", self.it)
|
|
2102
|
+
|
|
2103
|
+
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
|
|
2104
|
+
btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
|
|
2105
|
+
form.addRow(btns)
|
|
2106
|
+
|
|
2107
|
+
def result_dict(self) -> dict:
|
|
2108
|
+
op = ["erosion","dilation","opening","closing"][self.op.currentIndex()]
|
|
2109
|
+
k = int(self.k.value()); k = k if k%2==1 else k+1
|
|
2110
|
+
it = int(self.it.value())
|
|
2111
|
+
return {"operation": op, "kernel": k, "iterations": it}
|
|
2112
|
+
|
|
2113
|
+
class _PixelMathPresetDialog(QDialog):
|
|
2114
|
+
def __init__(self, parent=None, initial: dict | None = None):
|
|
2115
|
+
super().__init__(parent)
|
|
2116
|
+
self.setWindowTitle("Pixel Math — Preset")
|
|
2117
|
+
init = dict(initial or {})
|
|
2118
|
+
v = QVBoxLayout(self)
|
|
2119
|
+
self.rb_single = QRadioButton("Single"); self.rb_single.setChecked(init.get("mode","single")=="single")
|
|
2120
|
+
self.rb_rgb = QRadioButton("Per-channel"); self.rb_rgb.setChecked(init.get("mode","single")=="rgb")
|
|
2121
|
+
row = QHBoxLayout(); row.addWidget(self.rb_single); row.addWidget(self.rb_rgb); row.addStretch(1)
|
|
2122
|
+
v.addLayout(row)
|
|
2123
|
+
self.ed_single = QPlainTextEdit(); self.ed_single.setPlaceholderText("expr"); self.ed_single.setPlainText(init.get("expr",""))
|
|
2124
|
+
v.addWidget(self.ed_single)
|
|
2125
|
+
self.tabs = QTabWidget();
|
|
2126
|
+
self.ed_r, self.ed_g, self.ed_b = QPlainTextEdit(), QPlainTextEdit(), QPlainTextEdit()
|
|
2127
|
+
for ed, name, key in ((self.ed_r,"Red","expr_r"),(self.ed_g,"Green","expr_g"),(self.ed_b,"Blue","expr_b")):
|
|
2128
|
+
w = QWidget(); lay = QVBoxLayout(w); ed.setPlainText(init.get(key,"")); lay.addWidget(ed); self.tabs.addTab(w, name)
|
|
2129
|
+
v.addWidget(self.tabs)
|
|
2130
|
+
self.rb_single.toggled.connect(lambda on: (self.ed_single.setVisible(on), self.tabs.setVisible(not on)))
|
|
2131
|
+
self.rb_single.toggled.emit(self.rb_single.isChecked())
|
|
2132
|
+
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
|
|
2133
|
+
btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
|
|
2134
|
+
v.addWidget(btns)
|
|
2135
|
+
def result_dict(self) -> dict:
|
|
2136
|
+
if self.rb_single.isChecked():
|
|
2137
|
+
return {"mode":"single","expr":self.ed_single.toPlainText().strip()}
|
|
2138
|
+
return {"mode":"rgb","expr_r":self.ed_r.toPlainText().strip(),
|
|
2139
|
+
"expr_g":self.ed_g.toPlainText().strip(),"expr_b":self.ed_b.toPlainText().strip()}
|
|
2140
|
+
|
|
2141
|
+
class _SignatureInsertPresetDialog(QDialog):
|
|
2142
|
+
"""
|
|
2143
|
+
Preset editor for Signature / Insert.
|
|
2144
|
+
Keeps the PNG path + placement so users can drag a shortcut and re-apply.
|
|
2145
|
+
"""
|
|
2146
|
+
POS_KEYS = [
|
|
2147
|
+
("Top-Left", "top_left"),
|
|
2148
|
+
("Top-Center", "top_center"),
|
|
2149
|
+
("Top-Right", "top_right"),
|
|
2150
|
+
("Middle-Left", "middle_left"),
|
|
2151
|
+
("Center", "center"),
|
|
2152
|
+
("Middle-Right", "middle_right"),
|
|
2153
|
+
("Bottom-Left", "bottom_left"),
|
|
2154
|
+
("Bottom-Center", "bottom_center"),
|
|
2155
|
+
("Bottom-Right", "bottom_right"),
|
|
2156
|
+
]
|
|
2157
|
+
|
|
2158
|
+
def __init__(self, parent, initial: dict | None = None):
|
|
2159
|
+
super().__init__(parent)
|
|
2160
|
+
self.setWindowTitle("Signature / Insert – Preset")
|
|
2161
|
+
self.setMinimumWidth(520)
|
|
2162
|
+
|
|
2163
|
+
init = dict(initial or {})
|
|
2164
|
+
v = QVBoxLayout(self)
|
|
2165
|
+
|
|
2166
|
+
tip = QLabel("Tip: For transparent signatures, use a PNG and “Load from File”. "
|
|
2167
|
+
"Views are RGB, so alpha is not preserved.")
|
|
2168
|
+
tip.setWordWrap(True)
|
|
2169
|
+
tip.setStyleSheet("color:#e0b000;")
|
|
2170
|
+
v.addWidget(tip)
|
|
2171
|
+
|
|
2172
|
+
grid = QGridLayout()
|
|
2173
|
+
|
|
2174
|
+
# File path
|
|
2175
|
+
grid.addWidget(QLabel("Signature file (PNG/JPG/TIF):"), 0, 0)
|
|
2176
|
+
self.ed_path = QLineEdit(init.get("file_path", ""))
|
|
2177
|
+
b_browse = QPushButton("Browse…")
|
|
2178
|
+
def _pick():
|
|
2179
|
+
fp, _ = QFileDialog.getOpenFileName(self, "Select signature image",
|
|
2180
|
+
"", "Images (*.png *.jpg *.jpeg *.tif *.tiff)")
|
|
2181
|
+
if fp: self.ed_path.setText(fp)
|
|
2182
|
+
b_browse.clicked.connect(_pick)
|
|
2183
|
+
grid.addWidget(self.ed_path, 0, 1)
|
|
2184
|
+
grid.addWidget(b_browse, 0, 2)
|
|
2185
|
+
|
|
2186
|
+
# Position
|
|
2187
|
+
grid.addWidget(QLabel("Position:"), 1, 0)
|
|
2188
|
+
self.cb_pos = QComboBox()
|
|
2189
|
+
for text, key in self.POS_KEYS:
|
|
2190
|
+
self.cb_pos.addItem(text, userData=key)
|
|
2191
|
+
want = init.get("position", "bottom_right")
|
|
2192
|
+
idx = max(0, next((i for i,(_,k) in enumerate(self.POS_KEYS) if k == want), 0))
|
|
2193
|
+
self.cb_pos.setCurrentIndex(idx)
|
|
2194
|
+
grid.addWidget(self.cb_pos, 1, 1)
|
|
2195
|
+
|
|
2196
|
+
# Margins
|
|
2197
|
+
grid.addWidget(QLabel("Margin X (px):"), 2, 0)
|
|
2198
|
+
self.sp_mx = QSpinBox(); self.sp_mx.setRange(0, 5000); self.sp_mx.setValue(int(init.get("margin_x", 20)))
|
|
2199
|
+
grid.addWidget(self.sp_mx, 2, 1)
|
|
2200
|
+
|
|
2201
|
+
grid.addWidget(QLabel("Margin Y (px):"), 3, 0)
|
|
2202
|
+
self.sp_my = QSpinBox(); self.sp_my.setRange(0, 5000); self.sp_my.setValue(int(init.get("margin_y", 20)))
|
|
2203
|
+
grid.addWidget(self.sp_my, 3, 1)
|
|
2204
|
+
|
|
2205
|
+
# Scale / Opacity / Rotation
|
|
2206
|
+
grid.addWidget(QLabel("Scale (%)"), 4, 0)
|
|
2207
|
+
self.sp_scale = QSpinBox(); self.sp_scale.setRange(10, 800); self.sp_scale.setValue(int(init.get("scale", 100)))
|
|
2208
|
+
grid.addWidget(self.sp_scale, 4, 1)
|
|
2209
|
+
|
|
2210
|
+
grid.addWidget(QLabel("Opacity (%)"), 5, 0)
|
|
2211
|
+
self.sp_op = QSpinBox(); self.sp_op.setRange(0, 100); self.sp_op.setValue(int(init.get("opacity", 100)))
|
|
2212
|
+
grid.addWidget(self.sp_op, 5, 1)
|
|
2213
|
+
|
|
2214
|
+
grid.addWidget(QLabel("Rotation (°)"), 6, 0)
|
|
2215
|
+
self.sp_rot = QSpinBox(); self.sp_rot.setRange(-180, 180); self.sp_rot.setValue(int(init.get("rotation", 0)))
|
|
2216
|
+
grid.addWidget(self.sp_rot, 6, 1)
|
|
2217
|
+
|
|
2218
|
+
# Auto affix
|
|
2219
|
+
self.cb_affix = QCheckBox("Auto-affix after placement")
|
|
2220
|
+
self.cb_affix.setChecked(bool(init.get("auto_affix", True)))
|
|
2221
|
+
grid.addWidget(self.cb_affix, 7, 0, 1, 2)
|
|
2222
|
+
|
|
2223
|
+
v.addLayout(grid)
|
|
2224
|
+
|
|
2225
|
+
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
|
2226
|
+
btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
|
|
2227
|
+
v.addWidget(btns)
|
|
2228
|
+
|
|
2229
|
+
def result_dict(self) -> dict:
|
|
2230
|
+
return {
|
|
2231
|
+
"file_path": self.ed_path.text().strip(),
|
|
2232
|
+
"position": self.cb_pos.currentData(),
|
|
2233
|
+
"margin_x": int(self.sp_mx.value()),
|
|
2234
|
+
"margin_y": int(self.sp_my.value()),
|
|
2235
|
+
"scale": int(self.sp_scale.value()),
|
|
2236
|
+
"opacity": int(self.sp_op.value()),
|
|
2237
|
+
"rotation": int(self.sp_rot.value()),
|
|
2238
|
+
"auto_affix": bool(self.cb_affix.isChecked()),
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
class _HaloBGonPresetDialog(QDialog):
|
|
2242
|
+
def __init__(self, parent=None, initial: dict | None = None):
|
|
2243
|
+
super().__init__(parent)
|
|
2244
|
+
self.setWindowTitle("Halo-B-Gon Preset")
|
|
2245
|
+
v = QVBoxLayout(self)
|
|
2246
|
+
g = QGridLayout(); v.addLayout(g)
|
|
2247
|
+
|
|
2248
|
+
g.addWidget(QLabel("Reduction:"), 0, 0)
|
|
2249
|
+
self.sl = QSlider(Qt.Orientation.Horizontal); self.sl.setRange(0,3); self.sl.setValue(int((initial or {}).get("reduction",0)))
|
|
2250
|
+
self.lab = QLabel(["Extra Low","Low","Medium","High"][self.sl.value()])
|
|
2251
|
+
self.sl.valueChanged.connect(lambda v: self.lab.setText(["Extra Low","Low","Medium","High"][int(v)]))
|
|
2252
|
+
g.addWidget(self.sl, 0, 1); g.addWidget(self.lab, 0, 2)
|
|
2253
|
+
|
|
2254
|
+
self.cb = QCheckBox("Linear data"); self.cb.setChecked(bool((initial or {}).get("linear",False)))
|
|
2255
|
+
g.addWidget(self.cb, 1, 1)
|
|
2256
|
+
|
|
2257
|
+
row = QHBoxLayout(); v.addLayout(row)
|
|
2258
|
+
ok = QPushButton("OK"); ok.clicked.connect(self.accept)
|
|
2259
|
+
ca = QPushButton("Cancel"); ca.clicked.connect(self.reject)
|
|
2260
|
+
row.addStretch(1); row.addWidget(ok); row.addWidget(ca)
|
|
2261
|
+
|
|
2262
|
+
def result_dict(self) -> dict:
|
|
2263
|
+
return {"reduction": int(self.sl.value()), "linear": bool(self.cb.isChecked())}
|
|
2264
|
+
|
|
2265
|
+
class _RescalePresetDialog(QDialog):
|
|
2266
|
+
"""
|
|
2267
|
+
Preset dialog for Geometry → Rescale.
|
|
2268
|
+
Stores: {"factor": float} where factor ∈ [0.10, 10.00].
|
|
2269
|
+
"""
|
|
2270
|
+
def __init__(self, parent=None, initial=None):
|
|
2271
|
+
super().__init__(parent)
|
|
2272
|
+
self.setWindowTitle("Rescale Preset")
|
|
2273
|
+
self._initial = initial or {}
|
|
2274
|
+
|
|
2275
|
+
from PyQt6.QtWidgets import QFormLayout, QDoubleSpinBox, QDialogButtonBox
|
|
2276
|
+
|
|
2277
|
+
lay = QVBoxLayout(self)
|
|
2278
|
+
form = QFormLayout()
|
|
2279
|
+
|
|
2280
|
+
self.spn_factor = QDoubleSpinBox(self)
|
|
2281
|
+
self.spn_factor.setDecimals(2)
|
|
2282
|
+
self.spn_factor.setRange(0.10, 10.00)
|
|
2283
|
+
self.spn_factor.setSingleStep(0.05)
|
|
2284
|
+
self.spn_factor.setValue(float(self._initial.get("factor", 1.0)))
|
|
2285
|
+
form.addRow("Scaling factor:", self.spn_factor)
|
|
2286
|
+
|
|
2287
|
+
lay.addLayout(form)
|
|
2288
|
+
|
|
2289
|
+
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
|
|
2290
|
+
btns.accepted.connect(self.accept)
|
|
2291
|
+
btns.rejected.connect(self.reject)
|
|
2292
|
+
lay.addWidget(btns)
|
|
2293
|
+
|
|
2294
|
+
self.resize(320, 120)
|
|
2295
|
+
|
|
2296
|
+
def result_dict(self):
|
|
2297
|
+
return {"factor": float(self.spn_factor.value())}
|
|
2298
|
+
|
|
2299
|
+
class _ImageCombinePresetDialog(QDialog):
|
|
2300
|
+
def __init__(self, parent, initial: dict):
|
|
2301
|
+
super().__init__(parent); self.setWindowTitle("Image Combine Preset")
|
|
2302
|
+
mode = QComboBox(); mode.addItems(["Average","Add","Subtract","Blend","Multiply","Divide","Screen","Overlay","Difference"])
|
|
2303
|
+
mode.setCurrentText(initial.get("mode", "Blend"))
|
|
2304
|
+
alpha = QSlider(Qt.Orientation.Horizontal); alpha.setRange(0,100); alpha.setValue(int(100*float(initial.get("opacity",1.0))))
|
|
2305
|
+
luma = QCheckBox("Luminance only"); luma.setChecked(bool(initial.get("luma_only", False)))
|
|
2306
|
+
out_rep = QRadioButton("Replace A"); out_new = QRadioButton("Create new"); (out_new if initial.get("output")=="new" else out_rep).setChecked(True)
|
|
2307
|
+
from PyQt6.QtWidgets import QLineEdit
|
|
2308
|
+
other = QLineEdit(initial.get("docB_title","")); other.setPlaceholderText("Optional: exact title of B")
|
|
2309
|
+
|
|
2310
|
+
form = QFormLayout()
|
|
2311
|
+
form.addRow("Mode:", mode)
|
|
2312
|
+
form.addRow("Opacity:", alpha)
|
|
2313
|
+
form.addRow("", luma)
|
|
2314
|
+
form.addRow("Output:", None)
|
|
2315
|
+
h = QHBoxLayout(); h.addWidget(out_rep); h.addWidget(out_new); h.addStretch(1)
|
|
2316
|
+
form.addRow("", QLabel(""))
|
|
2317
|
+
root = QVBoxLayout(self); root.addLayout(form); root.addLayout(h)
|
|
2318
|
+
form.addRow("Other source (title):", other)
|
|
2319
|
+
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
|
|
2320
|
+
btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
|
|
2321
|
+
root.addWidget(btns)
|
|
2322
|
+
self._mode, self._alpha, self._luma, self._rep, self._other = mode, alpha, luma, out_rep, other
|
|
2323
|
+
|
|
2324
|
+
def result_dict(self):
|
|
2325
|
+
return {
|
|
2326
|
+
"mode": self._mode.currentText(),
|
|
2327
|
+
"opacity": self._alpha.value()/100.0,
|
|
2328
|
+
"luma_only": self._luma.isChecked(),
|
|
2329
|
+
"output": "replace" if self._rep.isChecked() else "new",
|
|
2330
|
+
"docB_title": self._other.text().strip(),
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
class _StarSpikesPresetDialog(QDialog):
|
|
2334
|
+
def __init__(self, parent=None, initial: dict | None = None):
|
|
2335
|
+
super().__init__(parent)
|
|
2336
|
+
self.setWindowTitle("Diffraction Spikes Preset")
|
|
2337
|
+
v = QVBoxLayout(self)
|
|
2338
|
+
g = QGridLayout(); v.addLayout(g)
|
|
2339
|
+
ini = dict(initial or {})
|
|
2340
|
+
|
|
2341
|
+
row = 0
|
|
2342
|
+
def dspin(mini, maxi, step, key, default):
|
|
2343
|
+
sp = QDoubleSpinBox(); sp.setRange(mini, maxi); sp.setSingleStep(step); sp.setValue(float(ini.get(key, default)))
|
|
2344
|
+
return sp
|
|
2345
|
+
|
|
2346
|
+
def ispin(mini, maxi, step, key, default):
|
|
2347
|
+
sp = QSpinBox(); sp.setRange(mini, maxi); sp.setSingleStep(step); sp.setValue(int(ini.get(key, default)))
|
|
2348
|
+
return sp
|
|
2349
|
+
|
|
2350
|
+
self.flux_min = dspin(0.0, 999999.0, 10.0, "flux_min", 30.0); g.addWidget(QLabel("Flux Min:"), row,0); g.addWidget(self.flux_min, row,1); row+=1
|
|
2351
|
+
self.flux_max = dspin(1.0, 999999.0, 50.0, "flux_max", 300.0); g.addWidget(QLabel("Flux Max:"), row,0); g.addWidget(self.flux_max, row,1); row+=1
|
|
2352
|
+
self.bmin = dspin(0.1, 999.0, 0.5, "bscale_min", 10.0); g.addWidget(QLabel("Boost Min:"), row,0); g.addWidget(self.bmin, row,1); row+=1
|
|
2353
|
+
self.bmax = dspin(0.1, 999.0, 0.5, "bscale_max", 30.0); g.addWidget(QLabel("Boost Max:"), row,0); g.addWidget(self.bmax, row,1); row+=1
|
|
2354
|
+
self.smin = dspin(0.1, 999.0, 0.1, "shrink_min", 1.0); g.addWidget(QLabel("Shrink Min:"), row,0); g.addWidget(self.smin, row,1); row+=1
|
|
2355
|
+
self.smax = dspin(0.1, 999.0, 0.1, "shrink_max", 5.0); g.addWidget(QLabel("Shrink Max:"), row,0); g.addWidget(self.smax, row,1); row+=1
|
|
2356
|
+
self.dth = dspin(0.0, 100.0, 0.1, "detect_thresh", 5.0);g.addWidget(QLabel("Detect Threshold:"), row,0); g.addWidget(self.dth, row,1); row+=1
|
|
2357
|
+
self.radius = dspin(1.0, 512.0, 1.0, "radius", 128.0); g.addWidget(QLabel("Pupil Radius:"), row,0); g.addWidget(self.radius, row,1); row+=1
|
|
2358
|
+
self.obstr = dspin(0.0, 0.99, 0.01, "obstruction", 0.2); g.addWidget(QLabel("Obstruction:"), row,0); g.addWidget(self.obstr, row,1); row+=1
|
|
2359
|
+
self.vanes = ispin(2, 8, 1, "num_vanes", 4); g.addWidget(QLabel("Num Vanes:"), row,0); g.addWidget(self.vanes, row,1); row+=1
|
|
2360
|
+
self.vwidth = dspin(0.0, 50.0, 0.5, "vane_width", 4.0); g.addWidget(QLabel("Vane Width:"), row,0); g.addWidget(self.vwidth, row,1); row+=1
|
|
2361
|
+
self.rotdeg = dspin(0.0, 360.0, 1.0, "rotation", 0.0); g.addWidget(QLabel("Rotation (°):"), row,0); g.addWidget(self.rotdeg, row,1); row+=1
|
|
2362
|
+
self.boost = dspin(0.1, 10.0, 0.1, "color_boost", 1.5); g.addWidget(QLabel("Spike Boost:"), row,0); g.addWidget(self.boost, row,1); row+=1
|
|
2363
|
+
self.blur = dspin(0.1, 10.0, 0.1, "blur_sigma", 2.0); g.addWidget(QLabel("PSF Blur Sigma:"), row,0); g.addWidget(self.blur, row,1); row+=1
|
|
2364
|
+
|
|
2365
|
+
self.jwst = QCheckBox("JWST Pupil"); self.jwst.setChecked(bool(ini.get("jwst", False)))
|
|
2366
|
+
g.addWidget(self.jwst, row, 0, 1, 2); row += 1
|
|
2367
|
+
|
|
2368
|
+
rowbox = QHBoxLayout(); v.addLayout(rowbox)
|
|
2369
|
+
ok = QPushButton("OK"); ca = QPushButton("Cancel")
|
|
2370
|
+
ok.clicked.connect(self.accept); ca.clicked.connect(self.reject)
|
|
2371
|
+
rowbox.addStretch(1); rowbox.addWidget(ok); rowbox.addWidget(ca)
|
|
2372
|
+
|
|
2373
|
+
def result_dict(self) -> dict:
|
|
2374
|
+
return {
|
|
2375
|
+
"flux_min": float(self.flux_min.value()),
|
|
2376
|
+
"flux_max": float(self.flux_max.value()),
|
|
2377
|
+
"bscale_min": float(self.bmin.value()),
|
|
2378
|
+
"bscale_max": float(self.bmax.value()),
|
|
2379
|
+
"shrink_min": float(self.smin.value()),
|
|
2380
|
+
"shrink_max": float(self.smax.value()),
|
|
2381
|
+
"detect_thresh": float(self.dth.value()),
|
|
2382
|
+
"radius": float(self.radius.value()),
|
|
2383
|
+
"obstruction": float(self.obstr.value()),
|
|
2384
|
+
"num_vanes": int(self.vanes.value()),
|
|
2385
|
+
"vane_width": float(self.vwidth.value()),
|
|
2386
|
+
"rotation": float(self.rotdeg.value()),
|
|
2387
|
+
"color_boost": float(self.boost.value()),
|
|
2388
|
+
"blur_sigma": float(self.blur.value()),
|
|
2389
|
+
"jwst": bool(self.jwst.isChecked()),
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
class _DebayerPresetDialog(QDialog):
|
|
2393
|
+
def __init__(self, parent=None, initial: dict | None = None):
|
|
2394
|
+
super().__init__(parent)
|
|
2395
|
+
self.setWindowTitle("Debayer — Preset")
|
|
2396
|
+
init = dict(initial or {})
|
|
2397
|
+
self.combo = QComboBox(self)
|
|
2398
|
+
self.combo.addItems(["auto", "RGGB", "BGGR", "GRBG", "GBRG"])
|
|
2399
|
+
want = str(init.get("pattern", "auto")).upper()
|
|
2400
|
+
idx = max(0, self.combo.findText(want, Qt.MatchFlag.MatchFixedString))
|
|
2401
|
+
self.combo.setCurrentIndex(idx)
|
|
2402
|
+
|
|
2403
|
+
lay = QVBoxLayout(self)
|
|
2404
|
+
row = QHBoxLayout(); row.addWidget(QLabel("Bayer pattern:")); row.addWidget(self.combo, 1)
|
|
2405
|
+
lay.addLayout(row)
|
|
2406
|
+
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
|
|
2407
|
+
btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
|
|
2408
|
+
lay.addWidget(btns)
|
|
2409
|
+
|
|
2410
|
+
def result_dict(self) -> dict:
|
|
2411
|
+
return {"pattern": self.combo.currentText().upper()}
|
|
2412
|
+
|
|
2413
|
+
from setiastro.saspro.curves_preset import list_custom_presets, _norm_mode
|
|
2414
|
+
|
|
2415
|
+
class _CurvesPresetDialog(QDialog):
|
|
2416
|
+
def __init__(self, parent=None, initial: dict | None = None):
|
|
2417
|
+
super().__init__(parent)
|
|
2418
|
+
self.setWindowTitle("Curves — Preset")
|
|
2419
|
+
init = dict(initial or {})
|
|
2420
|
+
|
|
2421
|
+
# --- Mode ---------------------------------------------------------
|
|
2422
|
+
self.mode = QComboBox()
|
|
2423
|
+
self.mode.addItems(["K (Brightness)", "R", "G", "B", "L*", "a*", "b*", "Chroma", "Saturation"])
|
|
2424
|
+
want = (init.get("mode") or "K (Brightness)").strip()
|
|
2425
|
+
self.mode.setCurrentIndex(max(0, self.mode.findText(want)))
|
|
2426
|
+
|
|
2427
|
+
# --- Shape --------------------------------------------------------
|
|
2428
|
+
self.shape = QComboBox()
|
|
2429
|
+
self.shape.addItem("Linear", "linear")
|
|
2430
|
+
self.shape.addItem("S-curve (mild)", "s_mild")
|
|
2431
|
+
self.shape.addItem("S-curve (medium)", "s_med")
|
|
2432
|
+
self.shape.addItem("S-curve (strong)", "s_strong")
|
|
2433
|
+
self.shape.addItem("Lift shadows", "lift_shadows")
|
|
2434
|
+
self.shape.addItem("Crush shadows", "crush_shadows")
|
|
2435
|
+
self.shape.addItem("Fade blacks", "fade_blacks")
|
|
2436
|
+
self.shape.addItem("Highlight roll-off", "rolloff_highlights")
|
|
2437
|
+
self.shape.addItem("Flatten contrast", "flatten")
|
|
2438
|
+
self.shape.addItem("Custom points", "custom")
|
|
2439
|
+
self.shape.setCurrentIndex(max(0, self.shape.findData((init.get("shape") or "linear").lower())))
|
|
2440
|
+
|
|
2441
|
+
# --- Amount (ignored if custom) -----------------------------------
|
|
2442
|
+
self.amount = QDoubleSpinBox()
|
|
2443
|
+
self.amount.setRange(0.0, 1.0); self.amount.setDecimals(2)
|
|
2444
|
+
self.amount.setSingleStep(0.05)
|
|
2445
|
+
self.amount.setValue(float(init.get("amount", 0.50)))
|
|
2446
|
+
|
|
2447
|
+
# --- Custom points (normalized "x,y; x,y; ...") -------------------
|
|
2448
|
+
self.points = QLineEdit()
|
|
2449
|
+
self.points.setPlaceholderText("points_norm: x,y; x,y; ... (0..1) e.g. 0,0; 0.25,0.15; 0.75,0.85; 1,1")
|
|
2450
|
+
if isinstance(init.get("points_norm"), (list, tuple)) and init["points_norm"]:
|
|
2451
|
+
s = "; ".join(f"{float(x):.6g},{float(y):.6g}" for x, y in init["points_norm"])
|
|
2452
|
+
self.points.setText(s)
|
|
2453
|
+
|
|
2454
|
+
# ===================== Custom Presets picker ======================
|
|
2455
|
+
self.preset_picker = QComboBox()
|
|
2456
|
+
self.btn_load = QPushButton("Load custom → fields")
|
|
2457
|
+
|
|
2458
|
+
# populate & enable/disable based on availability
|
|
2459
|
+
self._rebuild_customs()
|
|
2460
|
+
self.btn_load.clicked.connect(self._load_selected_preset_into_fields)
|
|
2461
|
+
|
|
2462
|
+
# wrap the load-row in a QWidget so we can hide/show the whole row
|
|
2463
|
+
load_row = QHBoxLayout()
|
|
2464
|
+
load_row.setContentsMargins(0, 0, 0, 0)
|
|
2465
|
+
load_row.addWidget(self.btn_load)
|
|
2466
|
+
self._row_custom_controls = QWidget(self)
|
|
2467
|
+
self._row_custom_controls.setLayout(load_row)
|
|
2468
|
+
|
|
2469
|
+
# layout (use explicit labels so they can be hidden with the row)
|
|
2470
|
+
form = QFormLayout(self)
|
|
2471
|
+
form.addRow(QLabel("Mode:", self), self.mode)
|
|
2472
|
+
form.addRow(QLabel("Shape:", self), self.shape)
|
|
2473
|
+
form.addRow(QLabel("Amount (0–1):", self), self.amount)
|
|
2474
|
+
form.addRow(QLabel("Custom points:", self), self.points)
|
|
2475
|
+
|
|
2476
|
+
self._lbl_custom_picker = QLabel("Custom presets:", self)
|
|
2477
|
+
form.addRow(self._lbl_custom_picker, self.preset_picker)
|
|
2478
|
+
form.addRow(QLabel("", self), self._row_custom_controls)
|
|
2479
|
+
|
|
2480
|
+
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
|
|
2481
|
+
btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
|
|
2482
|
+
form.addRow(btns)
|
|
2483
|
+
|
|
2484
|
+
# enable/disable + show/hide depending on shape
|
|
2485
|
+
def _update_enabled():
|
|
2486
|
+
custom = (self.shape.currentData() == "custom")
|
|
2487
|
+
self.points.setEnabled(custom)
|
|
2488
|
+
self.amount.setEnabled(custom)
|
|
2489
|
+
|
|
2490
|
+
# show/hide the custom presets UI as requested
|
|
2491
|
+
self._set_custom_picker_visible(custom)
|
|
2492
|
+
|
|
2493
|
+
self.shape.currentIndexChanged.connect(_update_enabled)
|
|
2494
|
+
_update_enabled()
|
|
2495
|
+
# ---------------------------------------------------------------------
|
|
2496
|
+
|
|
2497
|
+
def _set_custom_picker_visible(self, visible: bool):
|
|
2498
|
+
"""Show/hide the custom presets picker + load row."""
|
|
2499
|
+
for w in (self._lbl_custom_picker, self.preset_picker, self._row_custom_controls):
|
|
2500
|
+
w.setVisible(bool(visible))
|
|
2501
|
+
|
|
2502
|
+
def _rebuild_customs(self):
|
|
2503
|
+
"""Refresh the list from QSettings and (de)activate picker/load."""
|
|
2504
|
+
self.preset_picker.clear()
|
|
2505
|
+
customs = list_custom_presets()
|
|
2506
|
+
if not customs:
|
|
2507
|
+
self.preset_picker.addItem("(No custom presets saved)", userData=None)
|
|
2508
|
+
self.preset_picker.setEnabled(False)
|
|
2509
|
+
self.btn_load.setEnabled(False)
|
|
2510
|
+
return
|
|
2511
|
+
self.preset_picker.setEnabled(True)
|
|
2512
|
+
self.btn_load.setEnabled(True)
|
|
2513
|
+
for p in sorted(customs, key=lambda d: d.get("name", "").lower()):
|
|
2514
|
+
self.preset_picker.addItem(p.get("name", "(unnamed)"), userData=p)
|
|
2515
|
+
|
|
2516
|
+
def _load_selected_preset_into_fields(self):
|
|
2517
|
+
p = self.preset_picker.currentData()
|
|
2518
|
+
if not isinstance(p, dict):
|
|
2519
|
+
return
|
|
2520
|
+
# mode
|
|
2521
|
+
want = _norm_mode(p.get("mode"))
|
|
2522
|
+
idx = self.mode.findText(want)
|
|
2523
|
+
if idx >= 0:
|
|
2524
|
+
self.mode.setCurrentIndex(idx)
|
|
2525
|
+
# switch to custom
|
|
2526
|
+
j = self.shape.findData("custom")
|
|
2527
|
+
if j >= 0:
|
|
2528
|
+
self.shape.setCurrentIndex(j)
|
|
2529
|
+
# points → text
|
|
2530
|
+
pts = p.get("points_norm") or []
|
|
2531
|
+
if isinstance(pts, (list, tuple)) and pts:
|
|
2532
|
+
s = "; ".join(f"{float(x):.6g},{float(y):.6g}" for x, y in pts)
|
|
2533
|
+
self.points.setText(s)
|
|
2534
|
+
|
|
2535
|
+
# -------------------- parsing & result -------------------------------
|
|
2536
|
+
def _parse_points_text(self) -> list[tuple[float, float]]:
|
|
2537
|
+
txt = (self.points.text() or "").strip()
|
|
2538
|
+
if not txt:
|
|
2539
|
+
return []
|
|
2540
|
+
s = txt.replace("\n", ";").replace("\r", ";")
|
|
2541
|
+
parts = [p.strip() for p in s.split(";") if p.strip()]
|
|
2542
|
+
out: list[tuple[float, float]] = []
|
|
2543
|
+
for part in parts:
|
|
2544
|
+
p = part.replace(",", " ").split()
|
|
2545
|
+
if len(p) != 2:
|
|
2546
|
+
continue
|
|
2547
|
+
try:
|
|
2548
|
+
x = float(p[0]); y = float(p[1])
|
|
2549
|
+
except ValueError:
|
|
2550
|
+
continue
|
|
2551
|
+
out.append((max(0.0, min(1.0, x)), max(0.0, min(1.0, y))))
|
|
2552
|
+
|
|
2553
|
+
if out:
|
|
2554
|
+
if all(abs(x - 0.0) > 1e-6 for x, _ in out): out.insert(0, (0.0, 0.0))
|
|
2555
|
+
if all(abs(x - 1.0) > 1e-6 for x, _ in out): out.append((1.0, 1.0))
|
|
2556
|
+
out = sorted(out, key=lambda t: t[0])
|
|
2557
|
+
cleaned, lastx = [], -1.0
|
|
2558
|
+
for x, y in out:
|
|
2559
|
+
if x <= lastx: x = min(1.0, lastx + 1e-4)
|
|
2560
|
+
cleaned.append((x, y)); lastx = x
|
|
2561
|
+
out = cleaned
|
|
2562
|
+
return out
|
|
2563
|
+
|
|
2564
|
+
def result_dict(self) -> dict:
|
|
2565
|
+
mode = _norm_mode(self.mode.currentText())
|
|
2566
|
+
shape = self.shape.currentData() or "linear"
|
|
2567
|
+
amt = float(self.amount.value())
|
|
2568
|
+
d = {"mode": mode, "shape": shape, "amount": amt}
|
|
2569
|
+
if shape == "custom":
|
|
2570
|
+
pts = self._parse_points_text()
|
|
2571
|
+
if pts:
|
|
2572
|
+
d["points_norm"] = pts
|
|
2573
|
+
else:
|
|
2574
|
+
d["shape"] = "linear"
|
|
2575
|
+
d.pop("points_norm", None)
|
|
2576
|
+
return d
|
|
2577
|
+
|
|
2578
|
+
class _GHSPresetDialog(QDialog):
|
|
2579
|
+
def __init__(self, parent=None, initial: dict | None = None):
|
|
2580
|
+
super().__init__(parent)
|
|
2581
|
+
self.setWindowTitle("Universal Hyperbolic Stretch — Preset")
|
|
2582
|
+
init = dict(initial or {})
|
|
2583
|
+
|
|
2584
|
+
self.mode = QComboBox()
|
|
2585
|
+
self.mode.addItems(["K (Brightness)", "R", "G", "B"])
|
|
2586
|
+
want = (init.get("channel") or "K (Brightness)").strip()
|
|
2587
|
+
i = self.mode.findText(want); self.mode.setCurrentIndex(max(0, i))
|
|
2588
|
+
|
|
2589
|
+
def _mk_spin(minv, maxv, step, val, dec=2):
|
|
2590
|
+
s = QDoubleSpinBox(); s.setRange(minv, maxv); s.setDecimals(dec); s.setSingleStep(step); s.setValue(val); return s
|
|
2591
|
+
|
|
2592
|
+
self.alpha = _mk_spin(0.02, 10.0, 0.02, float(init.get("alpha", 1.00)))
|
|
2593
|
+
self.beta = _mk_spin(0.02, 10.0, 0.02, float(init.get("beta", 1.00)))
|
|
2594
|
+
self.gamma = _mk_spin(0.01, 5.0, 0.01, float(init.get("gamma", 1.00)))
|
|
2595
|
+
self.pivot = _mk_spin(0.00, 1.0, 0.01, float(init.get("pivot", 0.50)))
|
|
2596
|
+
self.lp = _mk_spin(0.00, 1.0, 0.01, float(init.get("lp", 0.00)))
|
|
2597
|
+
self.hp = _mk_spin(0.00, 1.0, 0.01, float(init.get("hp", 0.00)))
|
|
2598
|
+
|
|
2599
|
+
form = QFormLayout(self)
|
|
2600
|
+
form.addRow("Channel:", self.mode)
|
|
2601
|
+
form.addRow("α (0.02–10):", self.alpha)
|
|
2602
|
+
form.addRow("β (0.02–10):", self.beta)
|
|
2603
|
+
form.addRow("γ (0.01–5):", self.gamma)
|
|
2604
|
+
form.addRow("Pivot (0–1):", self.pivot)
|
|
2605
|
+
form.addRow("LP (0–1):", self.lp)
|
|
2606
|
+
form.addRow("HP (0–1):", self.hp)
|
|
2607
|
+
|
|
2608
|
+
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
|
|
2609
|
+
btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
|
|
2610
|
+
form.addRow(btns)
|
|
2611
|
+
|
|
2612
|
+
def result_dict(self) -> dict:
|
|
2613
|
+
return {
|
|
2614
|
+
"channel": self.mode.currentText(),
|
|
2615
|
+
"alpha": float(self.alpha.value()),
|
|
2616
|
+
"beta": float(self.beta.value()),
|
|
2617
|
+
"gamma": float(self.gamma.value()),
|
|
2618
|
+
"pivot": float(self.pivot.value()),
|
|
2619
|
+
"lp": float(self.lp.value()),
|
|
2620
|
+
"hp": float(self.hp.value()),
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
class _ABEPresetDialog(QDialog):
|
|
2624
|
+
def __init__(self, parent=None, initial: dict | None = None):
|
|
2625
|
+
super().__init__(parent)
|
|
2626
|
+
self.setWindowTitle("ABE — Preset")
|
|
2627
|
+
p = dict(initial or {})
|
|
2628
|
+
form = QFormLayout(self)
|
|
2629
|
+
|
|
2630
|
+
self.degree = QSpinBox(); self.degree.setRange(1, 6); self.degree.setValue(int(p.get("degree", 2)))
|
|
2631
|
+
self.samples = QSpinBox(); self.samples.setRange(20, 100000); self.samples.setSingleStep(20); self.samples.setValue(int(p.get("samples", 120)))
|
|
2632
|
+
self.down = QSpinBox(); self.down.setRange(1, 64); self.down.setValue(int(p.get("downsample", 6)))
|
|
2633
|
+
self.patch = QSpinBox(); self.patch.setRange(5, 151); self.patch.setSingleStep(2); self.patch.setValue(int(p.get("patch", 15)))
|
|
2634
|
+
self.rbf = QCheckBox("Enable RBF"); self.rbf.setChecked(bool(p.get("rbf", True)))
|
|
2635
|
+
self.smooth = QDoubleSpinBox(); self.smooth.setRange(0.0, 10.0); self.smooth.setDecimals(3); self.smooth.setSingleStep(0.01); self.smooth.setValue(float(p.get("rbf_smooth", 1.0)))
|
|
2636
|
+
self.mk_bg = QCheckBox("Also create background document"); self.mk_bg.setChecked(bool(p.get("make_background_doc", False)))
|
|
2637
|
+
|
|
2638
|
+
form.addRow("Polynomial degree:", self.degree)
|
|
2639
|
+
form.addRow("# samples:", self.samples)
|
|
2640
|
+
form.addRow("Downsample:", self.down)
|
|
2641
|
+
form.addRow("Patch size (px):", self.patch)
|
|
2642
|
+
form.addRow(self.rbf)
|
|
2643
|
+
form.addRow("RBF smooth:", self.smooth)
|
|
2644
|
+
form.addRow(self.mk_bg)
|
|
2645
|
+
|
|
2646
|
+
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
|
|
2647
|
+
btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
|
|
2648
|
+
form.addRow(btns)
|
|
2649
|
+
|
|
2650
|
+
def result_dict(self) -> dict:
|
|
2651
|
+
return {
|
|
2652
|
+
"degree": int(self.degree.value()),
|
|
2653
|
+
"samples": int(self.samples.value()),
|
|
2654
|
+
"downsample": int(self.down.value()),
|
|
2655
|
+
"patch": int(self.patch.value()),
|
|
2656
|
+
"rbf": bool(self.rbf.isChecked()),
|
|
2657
|
+
"rbf_smooth": float(self.smooth.value()),
|
|
2658
|
+
"make_background_doc": bool(self.mk_bg.isChecked()),
|
|
2659
|
+
# exclusion polygons: intentionally unsupported here
|
|
2660
|
+
}
|
|
2661
|
+
class _CropPresetDialog(QDialog):
|
|
2662
|
+
def __init__(self, parent=None, initial: dict | None = None):
|
|
2663
|
+
super().__init__(parent)
|
|
2664
|
+
self.setWindowTitle("Crop Preset")
|
|
2665
|
+
init = dict(initial or {})
|
|
2666
|
+
mode = str(init.get("mode", "margins")).lower()
|
|
2667
|
+
margins = dict(init.get("margins", {}))
|
|
2668
|
+
|
|
2669
|
+
lay = QVBoxLayout(self)
|
|
2670
|
+
form = QFormLayout()
|
|
2671
|
+
|
|
2672
|
+
# --- Mode + help button row --------------------------------------
|
|
2673
|
+
self.cmb_mode = QComboBox()
|
|
2674
|
+
self.cmb_mode.addItems(["margins", "rect_norm", "quad_norm"])
|
|
2675
|
+
self.cmb_mode.setCurrentText(mode)
|
|
2676
|
+
# Per-item tooltips
|
|
2677
|
+
self.cmb_mode.setItemData(0, "Crop by pixel margins from each edge.", Qt.ItemDataRole.ToolTipRole)
|
|
2678
|
+
self.cmb_mode.setItemData(1, "Axis-aligned rectangle in 0..1 normalized coords (optional rotation).", Qt.ItemDataRole.ToolTipRole)
|
|
2679
|
+
self.cmb_mode.setItemData(2, "Four corners (TL,TR,BR,BL) in 0..1 normalized coords for perspective/keystone.", Qt.ItemDataRole.ToolTipRole)
|
|
2680
|
+
|
|
2681
|
+
# Tiny "?" button
|
|
2682
|
+
self.btn_mode_help = QToolButton()
|
|
2683
|
+
self.btn_mode_help.setText("?")
|
|
2684
|
+
self.btn_mode_help.setToolTip("What do these modes mean?")
|
|
2685
|
+
self.btn_mode_help.setFixedWidth(24)
|
|
2686
|
+
self.btn_mode_help.clicked.connect(self._show_mode_help)
|
|
2687
|
+
|
|
2688
|
+
# Put combo + help button on one row for the form
|
|
2689
|
+
mode_row = QWidget(self)
|
|
2690
|
+
mode_row_lay = QHBoxLayout(mode_row)
|
|
2691
|
+
mode_row_lay.setContentsMargins(0, 0, 0, 0)
|
|
2692
|
+
mode_row_lay.addWidget(self.cmb_mode, 1)
|
|
2693
|
+
mode_row_lay.addWidget(self.btn_mode_help, 0)
|
|
2694
|
+
form.addRow("Mode:", mode_row)
|
|
2695
|
+
# -----------------------------------------------------------------
|
|
2696
|
+
|
|
2697
|
+
# Margins UI
|
|
2698
|
+
self.top = QSpinBox(); self.right = QSpinBox(); self.bottom = QSpinBox(); self.left = QSpinBox()
|
|
2699
|
+
for sb in (self.top, self.right, self.bottom, self.left):
|
|
2700
|
+
sb.setRange(0, 1_000_000)
|
|
2701
|
+
self.top.setValue(int(margins.get("top", 0)))
|
|
2702
|
+
self.right.setValue(int(margins.get("right", 0)))
|
|
2703
|
+
self.bottom.setValue(int(margins.get("bottom", 0)))
|
|
2704
|
+
self.left.setValue(int(margins.get("left", 0)))
|
|
2705
|
+
|
|
2706
|
+
self.cb_new = QCheckBox("Create new view")
|
|
2707
|
+
self.cb_new.setChecked(bool(init.get("create_new_view", False)))
|
|
2708
|
+
self.le_title = QLineEdit(init.get("title", "Crop"))
|
|
2709
|
+
|
|
2710
|
+
form.addRow("Top (px):", self.top)
|
|
2711
|
+
form.addRow("Right (px):", self.right)
|
|
2712
|
+
form.addRow("Bottom (px):", self.bottom)
|
|
2713
|
+
form.addRow("Left (px):", self.left)
|
|
2714
|
+
form.addRow("", self.cb_new)
|
|
2715
|
+
form.addRow("New view title:", self.le_title)
|
|
2716
|
+
lay.addLayout(form)
|
|
2717
|
+
|
|
2718
|
+
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
|
|
2719
|
+
btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
|
|
2720
|
+
lay.addWidget(btns)
|
|
2721
|
+
|
|
2722
|
+
def _show_mode_help(self):
|
|
2723
|
+
current = self.cmb_mode.currentText()
|
|
2724
|
+
txt = (
|
|
2725
|
+
"<b>Crop modes</b><br><br>"
|
|
2726
|
+
"<b>margins</b> — Crop by pixel offsets from each image edge.<br>"
|
|
2727
|
+
"• <i>top/right/bottom/left</i> are in pixels.<br><br>"
|
|
2728
|
+
"<b>rect_norm</b> — Axis-aligned rectangle (optionally rotated) expressed in normalized 0..1 units.<br>"
|
|
2729
|
+
"• Schema: { mode:'rect_norm', rect:{ x, y, w, h, angle_deg } }<br>"
|
|
2730
|
+
"• x,y: top-left; w,h: size; angle_deg: CCW rotation around center (optional).<br><br>"
|
|
2731
|
+
"<b>quad_norm</b> — Arbitrary 4-corner crop in normalized 0..1 units (perspective/keystone).<br>"
|
|
2732
|
+
"• Schema: { mode:'quad_norm', quad:[[xTL,yTL],[xTR,yTR],[xBR,yBR],[xBL,yBL]] }<br>"
|
|
2733
|
+
"• Order: TL, TR, BR, BL. (0,0)=top-left, (1,1)=bottom-right."
|
|
2734
|
+
)
|
|
2735
|
+
# Small extra hint for the selected item
|
|
2736
|
+
if current == "rect_norm":
|
|
2737
|
+
txt += "<br><br><i>Tip:</i> Use rect_norm for regular boxes; add a small angle when needed."
|
|
2738
|
+
elif current == "quad_norm":
|
|
2739
|
+
txt += "<br><br><i>Tip:</i> Use quad_norm when the box edges aren’t parallel (keystone or tilt)."
|
|
2740
|
+
|
|
2741
|
+
QMessageBox.information(self, "Crop modes help", txt)
|
|
2742
|
+
|
|
2743
|
+
def result_dict(self) -> dict:
|
|
2744
|
+
return {
|
|
2745
|
+
"mode": self.cmb_mode.currentText(),
|
|
2746
|
+
"margins": {
|
|
2747
|
+
"top": int(self.top.value()),
|
|
2748
|
+
"right": int(self.right.value()),
|
|
2749
|
+
"bottom": int(self.bottom.value()),
|
|
2750
|
+
"left": int(self.left.value()),
|
|
2751
|
+
},
|
|
2752
|
+
"create_new_view": bool(self.cb_new.isChecked()),
|
|
2753
|
+
"title": self.le_title.text().strip() or "Crop",
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
class _RGBAlignPresetDialog(QDialog):
|
|
2757
|
+
def __init__(self, parent=None, initial: dict | None = None):
|
|
2758
|
+
super().__init__(parent)
|
|
2759
|
+
self.setWindowTitle("RGB Align — Preset")
|
|
2760
|
+
init = dict(initial or {})
|
|
2761
|
+
v = QVBoxLayout(self)
|
|
2762
|
+
|
|
2763
|
+
# ── model row ───────────────────────────────────────
|
|
2764
|
+
row = QHBoxLayout()
|
|
2765
|
+
row.addWidget(QLabel("Alignment model:"))
|
|
2766
|
+
self.cb_model = QComboBox()
|
|
2767
|
+
# include EDGE first
|
|
2768
|
+
self.cb_model.addItems(["edge", "homography", "affine", "poly3", "poly4"])
|
|
2769
|
+
want = init.get("model", "edge").lower()
|
|
2770
|
+
idx = max(0, self.cb_model.findText(want, Qt.MatchFlag.MatchFixedString))
|
|
2771
|
+
self.cb_model.setCurrentIndex(idx)
|
|
2772
|
+
row.addWidget(self.cb_model, 1)
|
|
2773
|
+
v.addLayout(row)
|
|
2774
|
+
|
|
2775
|
+
# ── SEP sigma ───────────────────────────────────────
|
|
2776
|
+
sep_row = QHBoxLayout()
|
|
2777
|
+
sep_row.addWidget(QLabel("SEP sigma:"))
|
|
2778
|
+
self.sb_sigma = QSpinBox()
|
|
2779
|
+
self.sb_sigma.setRange(1, 10)
|
|
2780
|
+
self.sb_sigma.setValue(int(init.get("sep_sigma", 3)))
|
|
2781
|
+
self.sb_sigma.setToolTip("Detection threshold (σ) for EDGE mode.\n"
|
|
2782
|
+
"Higher = fewer stars. Only used when model = EDGE.")
|
|
2783
|
+
sep_row.addWidget(self.sb_sigma)
|
|
2784
|
+
v.addLayout(sep_row)
|
|
2785
|
+
|
|
2786
|
+
# ── create new ──────────────────────────────────────
|
|
2787
|
+
self.chk_new = QCheckBox("Create new document")
|
|
2788
|
+
self.chk_new.setChecked(bool(init.get("new_doc", True)))
|
|
2789
|
+
v.addWidget(self.chk_new)
|
|
2790
|
+
|
|
2791
|
+
# ── buttons ─────────────────────────────────────────
|
|
2792
|
+
btns = QDialogButtonBox(
|
|
2793
|
+
QDialogButtonBox.StandardButton.Ok |
|
|
2794
|
+
QDialogButtonBox.StandardButton.Cancel,
|
|
2795
|
+
parent=self
|
|
2796
|
+
)
|
|
2797
|
+
btns.accepted.connect(self.accept)
|
|
2798
|
+
btns.rejected.connect(self.reject)
|
|
2799
|
+
v.addWidget(btns)
|
|
2800
|
+
|
|
2801
|
+
def result_dict(self) -> dict:
|
|
2802
|
+
return {
|
|
2803
|
+
"model": self.cb_model.currentText().lower(), # "edge" / "homography" / ...
|
|
2804
|
+
"sep_sigma": int(self.sb_sigma.value()), # <-- new
|
|
2805
|
+
"new_doc": bool(self.chk_new.isChecked()),
|
|
2806
|
+
}
|
|
2807
|
+
|