setiastrosuitepro 1.6.4__py3-none-any.whl → 1.6.12__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.

Files changed (115) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/colorwheel.svg +97 -0
  4. setiastro/images/cosmic.svg +40 -0
  5. setiastro/images/cosmicsat.svg +24 -0
  6. setiastro/images/first_quarter.png +0 -0
  7. setiastro/images/full_moon.png +0 -0
  8. setiastro/images/graxpert.svg +19 -0
  9. setiastro/images/last_quarter.png +0 -0
  10. setiastro/images/linearfit.svg +32 -0
  11. setiastro/images/new_moon.png +0 -0
  12. setiastro/images/pixelmath.svg +42 -0
  13. setiastro/images/waning_crescent_1.png +0 -0
  14. setiastro/images/waning_crescent_2.png +0 -0
  15. setiastro/images/waning_crescent_3.png +0 -0
  16. setiastro/images/waning_crescent_4.png +0 -0
  17. setiastro/images/waning_crescent_5.png +0 -0
  18. setiastro/images/waning_gibbous_1.png +0 -0
  19. setiastro/images/waning_gibbous_2.png +0 -0
  20. setiastro/images/waning_gibbous_3.png +0 -0
  21. setiastro/images/waning_gibbous_4.png +0 -0
  22. setiastro/images/waning_gibbous_5.png +0 -0
  23. setiastro/images/waxing_crescent_1.png +0 -0
  24. setiastro/images/waxing_crescent_2.png +0 -0
  25. setiastro/images/waxing_crescent_3.png +0 -0
  26. setiastro/images/waxing_crescent_4.png +0 -0
  27. setiastro/images/waxing_crescent_5.png +0 -0
  28. setiastro/images/waxing_gibbous_1.png +0 -0
  29. setiastro/images/waxing_gibbous_2.png +0 -0
  30. setiastro/images/waxing_gibbous_3.png +0 -0
  31. setiastro/images/waxing_gibbous_4.png +0 -0
  32. setiastro/images/waxing_gibbous_5.png +0 -0
  33. setiastro/qml/ResourceMonitor.qml +84 -82
  34. setiastro/saspro/__main__.py +20 -1
  35. setiastro/saspro/_generated/build_info.py +2 -2
  36. setiastro/saspro/abe.py +37 -4
  37. setiastro/saspro/aberration_ai.py +237 -21
  38. setiastro/saspro/acv_exporter.py +379 -0
  39. setiastro/saspro/add_stars.py +33 -6
  40. setiastro/saspro/backgroundneutral.py +108 -40
  41. setiastro/saspro/blemish_blaster.py +4 -1
  42. setiastro/saspro/blink_comparator_pro.py +74 -24
  43. setiastro/saspro/clahe.py +4 -1
  44. setiastro/saspro/continuum_subtract.py +4 -1
  45. setiastro/saspro/convo.py +13 -7
  46. setiastro/saspro/cosmicclarity.py +129 -18
  47. setiastro/saspro/crop_dialog_pro.py +123 -7
  48. setiastro/saspro/curve_editor_pro.py +109 -42
  49. setiastro/saspro/doc_manager.py +245 -15
  50. setiastro/saspro/exoplanet_detector.py +120 -28
  51. setiastro/saspro/frequency_separation.py +1158 -204
  52. setiastro/saspro/ghs_dialog_pro.py +81 -16
  53. setiastro/saspro/graxpert.py +1 -0
  54. setiastro/saspro/gui/main_window.py +429 -228
  55. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  56. setiastro/saspro/gui/mixins/menu_mixin.py +27 -1
  57. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  58. setiastro/saspro/gui/mixins/toolbar_mixin.py +384 -18
  59. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  60. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  61. setiastro/saspro/halobgon.py +4 -0
  62. setiastro/saspro/histogram.py +5 -1
  63. setiastro/saspro/image_combine.py +4 -0
  64. setiastro/saspro/image_peeker_pro.py +4 -0
  65. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  66. setiastro/saspro/imageops/stretch.py +582 -62
  67. setiastro/saspro/isophote.py +4 -0
  68. setiastro/saspro/layers.py +13 -9
  69. setiastro/saspro/layers_dock.py +183 -3
  70. setiastro/saspro/legacy/image_manager.py +154 -20
  71. setiastro/saspro/legacy/numba_utils.py +67 -47
  72. setiastro/saspro/legacy/xisf.py +240 -98
  73. setiastro/saspro/live_stacking.py +180 -79
  74. setiastro/saspro/luminancerecombine.py +228 -27
  75. setiastro/saspro/mask_creation.py +174 -15
  76. setiastro/saspro/mfdeconv.py +113 -35
  77. setiastro/saspro/mfdeconvcudnn.py +119 -70
  78. setiastro/saspro/mfdeconvsport.py +112 -35
  79. setiastro/saspro/morphology.py +4 -0
  80. setiastro/saspro/multiscale_decomp.py +51 -12
  81. setiastro/saspro/numba_utils.py +72 -57
  82. setiastro/saspro/ops/commands.py +18 -18
  83. setiastro/saspro/ops/script_editor.py +10 -2
  84. setiastro/saspro/ops/scripts.py +122 -0
  85. setiastro/saspro/perfect_palette_picker.py +37 -3
  86. setiastro/saspro/plate_solver.py +84 -49
  87. setiastro/saspro/psf_viewer.py +119 -37
  88. setiastro/saspro/resources.py +67 -0
  89. setiastro/saspro/rgbalign.py +4 -0
  90. setiastro/saspro/selective_color.py +4 -1
  91. setiastro/saspro/sfcc.py +364 -152
  92. setiastro/saspro/shortcuts.py +160 -29
  93. setiastro/saspro/signature_insert.py +692 -33
  94. setiastro/saspro/stacking_suite.py +1331 -484
  95. setiastro/saspro/star_alignment.py +247 -123
  96. setiastro/saspro/star_spikes.py +4 -0
  97. setiastro/saspro/star_stretch.py +38 -3
  98. setiastro/saspro/stat_stretch.py +743 -128
  99. setiastro/saspro/subwindow.py +786 -360
  100. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  101. setiastro/saspro/wavescale_hdr.py +4 -1
  102. setiastro/saspro/wavescalede.py +4 -1
  103. setiastro/saspro/whitebalance.py +84 -12
  104. setiastro/saspro/widgets/common_utilities.py +28 -21
  105. setiastro/saspro/widgets/resource_monitor.py +109 -59
  106. setiastro/saspro/widgets/spinboxes.py +10 -13
  107. setiastro/saspro/wimi.py +27 -656
  108. setiastro/saspro/wims.py +13 -3
  109. setiastro/saspro/xisf.py +101 -11
  110. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/METADATA +2 -1
  111. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +115 -82
  112. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
  113. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
  114. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
  115. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/license.txt +0 -0
@@ -12,13 +12,43 @@ from PyQt6.QtCore import Qt, QTimer
12
12
  from PyQt6.QtWidgets import (
13
13
  QDockWidget, QPlainTextEdit, QTreeWidget, QTreeWidgetItem,
14
14
  QVBoxLayout, QWidget, QTextEdit, QListWidget, QListWidgetItem,
15
- QAbstractItemView, QApplication
15
+ QAbstractItemView, QApplication, QLineEdit, QMenu
16
16
  )
17
- from PyQt6.QtGui import QTextCursor, QAction
17
+ from PyQt6.QtGui import QTextCursor, QAction, QGuiApplication
18
18
 
19
19
  if TYPE_CHECKING:
20
20
  from PyQt6.QtWidgets import QAction
21
21
 
22
+ import os
23
+
24
+ GLYPHS = "■●◆▲▪▫•◼◻◾◽🔗"
25
+
26
+ def _strip_ui_decorations(text: str) -> str:
27
+ """
28
+ Strip UI-only decorations from titles:
29
+ - Qt mnemonics (&)
30
+ - link badges like "[LINK]"
31
+ - your glyph badges
32
+ - file extension (optional, but nice for Explorer)
33
+ """
34
+ if not text:
35
+ return ""
36
+ s = str(text)
37
+
38
+ # remove mnemonics
39
+ s = s.replace("&", "")
40
+
41
+ # remove common prefixes/badges
42
+ s = s.replace("[LINK]", "").strip()
43
+
44
+ # remove glyph badges
45
+ s = s.translate({ord(ch): None for ch in GLYPHS})
46
+
47
+ # collapse whitespace
48
+ s = " ".join(s.split())
49
+
50
+ return s
51
+
22
52
 
23
53
  class DockMixin:
24
54
  """
@@ -105,14 +135,44 @@ class DockMixin:
105
135
  self._view_panels_menu.removeAction(action)
106
136
 
107
137
  def _init_explorer_dock(self):
108
- self.explorer = QListWidget()
109
- # Enter/Return or single-activation: focus if open, else open
138
+ host = QWidget(self)
139
+ lay = QVBoxLayout(host)
140
+ lay.setContentsMargins(4, 4, 4, 4)
141
+ lay.setSpacing(4)
142
+
143
+ # Optional filter box (super useful)
144
+ self.explorer_filter = QLineEdit(host)
145
+ self.explorer_filter.setPlaceholderText(self.tr("Filter open documents…"))
146
+ self.explorer_filter.textChanged.connect(self._explorer_apply_filter)
147
+ lay.addWidget(self.explorer_filter)
148
+
149
+ self.explorer = QTreeWidget(host)
150
+ self.explorer.setObjectName("ExplorerTree")
151
+ self.explorer.setColumnCount(3)
152
+ self.explorer.setHeaderLabels([self.tr("Document"), self.tr("Dims"), self.tr("Type")])
153
+
154
+ # Sorting
155
+ self.explorer.setSortingEnabled(True)
156
+ self.explorer.header().setSortIndicatorShown(True)
157
+ self.explorer.sortByColumn(0, Qt.SortOrder.AscendingOrder)
158
+
159
+ # Selection/activation behavior
160
+ self.explorer.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
110
161
  self.explorer.itemActivated.connect(self._activate_or_open_from_explorer)
111
- # Double-click: same behavior
112
- self.explorer.itemDoubleClicked.connect(self._activate_or_open_from_explorer)
162
+
163
+ # Inline rename support
164
+ self.explorer.setEditTriggers(
165
+ QAbstractItemView.EditTrigger.EditKeyPressed |
166
+ QAbstractItemView.EditTrigger.SelectedClicked
167
+ )
168
+ self.explorer.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
169
+ self.explorer.customContextMenuRequested.connect(self._on_explorer_context_menu)
170
+ self.explorer.itemChanged.connect(self._on_explorer_item_changed)
171
+
172
+ lay.addWidget(self.explorer)
113
173
 
114
174
  dock = QDockWidget(self.tr("Explorer"), self)
115
- dock.setWidget(self.explorer)
175
+ dock.setWidget(host)
116
176
  dock.setObjectName("ExplorerDock")
117
177
  self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, dock)
118
178
 
@@ -341,35 +401,196 @@ class DockMixin:
341
401
  base = self._normalize_base_doc(doc)
342
402
 
343
403
  # de-dupe by identity on base
344
- for i in range(self.explorer.count()):
345
- it = self.explorer.item(i)
346
- if it.data(Qt.ItemDataRole.UserRole) is base:
347
- # refresh text in case dims/name changed
348
- it.setText(self._format_explorer_title(base))
404
+ for i in range(self.explorer.topLevelItemCount()):
405
+ it = self.explorer.topLevelItem(i)
406
+ if it.data(0, Qt.ItemDataRole.UserRole) is base:
407
+ self._refresh_explorer_row(it, base)
349
408
  return
350
409
 
351
- item = QListWidgetItem(self._format_explorer_title(base))
352
- item.setData(Qt.ItemDataRole.UserRole, base)
410
+ it = QTreeWidgetItem()
411
+ it.setData(0, Qt.ItemDataRole.UserRole, base)
412
+
413
+ # Make name editable; other columns read-only
414
+ it.setFlags(it.flags() | Qt.ItemFlag.ItemIsEditable | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
415
+
416
+ self._refresh_explorer_row(it, base)
417
+
353
418
  fp = (base.metadata or {}).get("file_path")
354
419
  if fp:
355
- item.setToolTip(fp)
356
- self.explorer.addItem(item)
420
+ it.setToolTip(0, fp)
421
+
422
+ self.explorer.addTopLevelItem(it)
357
423
 
358
424
  # keep row label in sync with edits/resizes/renames
359
425
  try:
360
- base.changed.connect(lambda *_: self._update_explorer_item_for_doc(base))
426
+ base.changed.connect(lambda *_, d=base: self._update_explorer_item_for_doc(d))
361
427
  except Exception:
362
428
  pass
363
429
 
430
+
364
431
  def _remove_doc_from_explorer(self, doc):
365
- """
366
- Remove either the exact doc or its base (handles ROI proxies).
367
- """
368
432
  base = self._normalize_base_doc(doc)
369
- for i in range(self.explorer.count()):
370
- it = self.explorer.item(i)
371
- d = it.data(Qt.ItemDataRole.UserRole)
433
+ for i in range(self.explorer.topLevelItemCount()):
434
+ it = self.explorer.topLevelItem(i)
435
+ d = it.data(0, Qt.ItemDataRole.UserRole)
372
436
  if d is doc or d is base:
373
- self.explorer.takeItem(i)
437
+ self.explorer.takeTopLevelItem(i)
374
438
  break
375
439
 
440
+
441
+ def _update_explorer_item_for_doc(self, doc):
442
+ for i in range(self.explorer.topLevelItemCount()):
443
+ it = self.explorer.topLevelItem(i)
444
+ if it.data(0, Qt.ItemDataRole.UserRole) is doc:
445
+ self._refresh_explorer_row(it, doc)
446
+ return
447
+
448
+ def _refresh_explorer_row(self, item, doc):
449
+ # Column 0: display name (NO glyph decorations)
450
+ name = _strip_ui_decorations(doc.display_name() or "Untitled")
451
+
452
+ name_no_ext, _ext = os.path.splitext(name)
453
+ if name_no_ext:
454
+ name = name_no_ext
455
+
456
+ item.setText(0, name)
457
+
458
+ # Column 1: dims
459
+ dims = ""
460
+ try:
461
+ import numpy as np
462
+ arr = getattr(doc, "image", None)
463
+ if isinstance(arr, np.ndarray) and arr.size:
464
+ h, w = arr.shape[:2]
465
+ c = arr.shape[2] if arr.ndim == 3 else 1
466
+ dims = f"{h}×{w}×{c}"
467
+ except Exception:
468
+ pass
469
+ item.setText(1, dims)
470
+
471
+ # Column 2: type/bit-depth (whatever you have available)
472
+ md = (doc.metadata or {})
473
+ bit = md.get("bit_depth") or md.get("dtype") or ""
474
+ kind = md.get("format") or md.get("doc_type") or ""
475
+ t = " / ".join([s for s in (str(kind), str(bit)) if s and s != "None"])
476
+ item.setText(2, t)
477
+
478
+ def _on_explorer_item_changed(self, item, col: int):
479
+ if col != 0:
480
+ return
481
+
482
+ doc = item.data(0, Qt.ItemDataRole.UserRole)
483
+ if doc is None:
484
+ return
485
+
486
+ new_name = (item.text(0) or "").strip()
487
+ if not new_name:
488
+ # revert to current doc name
489
+ self._refresh_explorer_row(item, doc)
490
+ return
491
+
492
+ # Avoid infinite loops: only apply if changed
493
+ cur = _strip_ui_decorations(doc.display_name() or "Untitled")
494
+ cur_no_ext, _ = os.path.splitext(cur)
495
+ cur = cur_no_ext or cur
496
+ if new_name == cur:
497
+ return
498
+
499
+ try:
500
+ doc.metadata["display_name"] = new_name
501
+ except Exception:
502
+ # if metadata missing or immutable, revert
503
+ self._refresh_explorer_row(item, doc)
504
+ return
505
+
506
+ try:
507
+ doc.changed.emit()
508
+ except Exception:
509
+ pass
510
+
511
+ def _on_explorer_context_menu(self, pos):
512
+ it = self.explorer.itemAt(pos)
513
+ if it is None:
514
+ return
515
+ doc = it.data(0, Qt.ItemDataRole.UserRole)
516
+ if doc is None:
517
+ return
518
+
519
+ menu = QMenu(self.explorer)
520
+ a_rename = menu.addAction(self.tr("Rename Document…"))
521
+ a_close = menu.addAction(self.tr("Close Document"))
522
+ menu.addSeparator()
523
+ a_copy_path = menu.addAction(self.tr("Copy File Path"))
524
+ a_reveal = menu.addAction(self.tr("Reveal in File Manager"))
525
+ menu.addSeparator()
526
+ a_send_shelf = menu.addAction(self.tr("Send View to Shelf")) # acts on active view for this doc
527
+
528
+ act = menu.exec(self.explorer.viewport().mapToGlobal(pos))
529
+ if act == a_rename:
530
+ # Start inline editing
531
+ self.explorer.editItem(it, 0)
532
+
533
+ elif act == a_close:
534
+ # close only if no other subwindows show it: you already do that in _on_view_about_to_close,
535
+ # but Explorer close is explicit; just close all views of this doc then docman.close_document.
536
+ try:
537
+ self._close_all_views_for_doc(doc)
538
+ except Exception:
539
+ pass
540
+
541
+ elif act == a_copy_path:
542
+ fp = (doc.metadata or {}).get("file_path", "")
543
+ if fp:
544
+ QGuiApplication.clipboard().setText(fp)
545
+
546
+ elif act == a_reveal:
547
+ fp = (doc.metadata or {}).get("file_path", "")
548
+ if fp:
549
+ self._reveal_in_file_manager(fp)
550
+
551
+ elif act == a_send_shelf:
552
+ sw = self._find_subwindow_for_doc(doc)
553
+ if sw and hasattr(sw.widget(), "_send_to_shelf"):
554
+ try:
555
+ sw.widget()._send_to_shelf()
556
+ except Exception:
557
+ pass
558
+
559
+ def _close_all_views_for_doc(self, doc):
560
+ base = self._normalize_base_doc(doc)
561
+ subs = list(self.mdi.subWindowList())
562
+ for sw in subs:
563
+ w = sw.widget()
564
+ if getattr(w, "base_document", None) is base:
565
+ try:
566
+ sw.close()
567
+ except Exception:
568
+ pass
569
+ # If none left (or even if close failed), try docman close defensively
570
+ try:
571
+ self.docman.close_document(base)
572
+ except Exception:
573
+ pass
574
+
575
+
576
+ def _reveal_in_file_manager(self, path: str):
577
+ import sys, os, subprocess
578
+ try:
579
+ if sys.platform.startswith("win"):
580
+ subprocess.Popen(["explorer", "/select,", os.path.normpath(path)])
581
+ elif sys.platform == "darwin":
582
+ subprocess.Popen(["open", "-R", path])
583
+ else:
584
+ # best-effort on Linux
585
+ subprocess.Popen(["xdg-open", os.path.dirname(path)])
586
+ except Exception:
587
+ pass
588
+
589
+ def _explorer_apply_filter(self, text: str):
590
+ t = (text or "").strip().lower()
591
+ for i in range(self.explorer.topLevelItemCount()):
592
+ it = self.explorer.topLevelItem(i)
593
+ name = (it.text(0) or "").lower()
594
+ fp = (it.toolTip(0) or "").lower()
595
+ hide = bool(t) and (t not in name) and (t not in fp)
596
+ it.setHidden(hide)
@@ -6,8 +6,9 @@ from __future__ import annotations
6
6
  import os
7
7
  from typing import TYPE_CHECKING
8
8
 
9
- from PyQt6.QtGui import QAction
9
+ from PyQt6.QtGui import QAction, QKeySequence
10
10
  from PyQt6.QtWidgets import QMenu, QToolButton, QWidgetAction
11
+ from PyQt6.QtCore import Qt
11
12
 
12
13
  if TYPE_CHECKING:
13
14
  pass
@@ -229,6 +230,7 @@ class MenuMixin:
229
230
 
230
231
 
231
232
  m_header = mb.addMenu(self.tr("&Header Mods && Misc"))
233
+ m_header.addAction(self.act_acv_exporter)
232
234
  m_header.addAction(self.act_astrobin_exporter)
233
235
  m_header.addAction(self.act_batch_convert)
234
236
  m_header.addAction(self.act_batch_renamer)
@@ -266,6 +268,14 @@ class MenuMixin:
266
268
  m_view.addAction(self.act_tile_grid)
267
269
  m_view.addSeparator()
268
270
 
271
+ # NEW: Minimize All Views
272
+ self.act_minimize_all_views = QAction(self.tr("Minimize All Views"), self)
273
+ self.act_minimize_all_views.setShortcut(QKeySequence("Ctrl+Shift+M"))
274
+ self.act_minimize_all_views.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
275
+ self.act_minimize_all_views.triggered.connect(self._minimize_all_views)
276
+ m_view.addAction(self.act_minimize_all_views)
277
+
278
+ m_view.addSeparator()
269
279
 
270
280
  # a button that shows current group & opens a drop-down
271
281
  self._link_btn = QToolButton(self)
@@ -388,3 +398,19 @@ class MenuMixin:
388
398
  if sub is not None:
389
399
  yield from self._iter_menu_actions(sub)
390
400
 
401
+ def _minimize_all_views(self):
402
+ mdi = getattr(self, "mdi", None)
403
+ if mdi is None:
404
+ return
405
+
406
+ try:
407
+ for sw in mdi.subWindowList():
408
+ try:
409
+ if not sw.isVisible():
410
+ continue
411
+ # Minimize each MDI child
412
+ sw.showMinimized()
413
+ except Exception:
414
+ pass
415
+ except Exception:
416
+ pass
@@ -9,12 +9,30 @@ from __future__ import annotations
9
9
  from typing import TYPE_CHECKING
10
10
 
11
11
  from PyQt6.QtCore import Qt, QTimer
12
- from PyQt6.QtGui import QBrush, QColor, QFont, QPalette
13
- from PyQt6.QtWidgets import QApplication
12
+ from PyQt6.QtGui import QBrush, QColor, QFont, QPalette, QPainter, QPixmap, QIcon
13
+ from PyQt6.QtWidgets import QApplication, QLabel, QWidget
14
14
 
15
15
  if TYPE_CHECKING:
16
16
  pass
17
17
 
18
+ def _force_mdi_subwindow_flags(sw):
19
+ f = sw.windowFlags()
20
+
21
+ # Clear only the *window type* bits (NOT random low bits)
22
+ f &= ~Qt.WindowType.WindowType_Mask
23
+
24
+ # Force true MDI child type
25
+ f |= Qt.WindowType.SubWindow
26
+
27
+ # Add desired buttons/hints
28
+ f |= (Qt.WindowType.CustomizeWindowHint |
29
+ Qt.WindowType.WindowTitleHint |
30
+ Qt.WindowType.WindowSystemMenuHint |
31
+ Qt.WindowType.WindowMinimizeButtonHint |
32
+ Qt.WindowType.WindowMaximizeButtonHint |
33
+ Qt.WindowType.WindowCloseButtonHint)
34
+
35
+ sw.setWindowFlags(f)
18
36
 
19
37
  class ThemeMixin:
20
38
  """
@@ -88,7 +106,7 @@ class ThemeMixin:
88
106
  app.setPalette(self._gray_palette())
89
107
  app.setStyleSheet(
90
108
  "QToolTip { color: #f0f0f0; background-color: #3a3a3a; border: 1px solid #5a5a5a; }"
91
- )
109
+ )
92
110
  elif mode == "light":
93
111
  app.setPalette(self._light_palette())
94
112
  app.setStyleSheet(
@@ -119,6 +137,11 @@ class ThemeMixin:
119
137
  self._repolish_top_levels()
120
138
  self._apply_workspace_theme()
121
139
  self._style_mdi_titlebars()
140
+
141
+ try:
142
+ self._retint_zoom_icons()
143
+ except Exception:
144
+ pass
122
145
  self._menu_view_panels = None
123
146
 
124
147
  try:
@@ -139,26 +162,149 @@ class ThemeMixin:
139
162
  w.setUpdatesEnabled(True)
140
163
 
141
164
  def _style_mdi_titlebars(self):
142
- """Apply theme-specific styles to MDI subwindow titlebars."""
143
165
  mode = self._theme_mode()
166
+
144
167
  if mode == "dark":
145
- base = "#1b1b1b" # inactive titlebar
146
- active = "#242424" # active titlebar
168
+ base = "#1b1b1b"
169
+ active = "#242424"
147
170
  fg = "#dcdcdc"
148
171
  elif mode in ("gray", "custom"):
149
172
  base = "#3a3a3a"
150
173
  active = "#454545"
151
174
  fg = "#f0f0f0"
152
175
  else:
153
- # No override in light / system modes
154
- self.mdi.setStyleSheet("")
155
- return
176
+ base = "#eaeaea"
177
+ active = "#ffffff"
178
+ fg = "#141414"
156
179
 
180
+ # style *our* titlebar only
157
181
  self.mdi.setStyleSheet(f"""
158
- QMdiSubWindow::titlebar {{ background: {base}; color: {fg}; }}
159
- QMdiSubWindow::titlebar:active {{ background: {active}; color: {fg}; }}
182
+ QWidget#sas_mdi_titlebar {{
183
+ background: {base};
184
+ }}
185
+ QWidget#sas_mdi_titlebar[active="true"] {{
186
+ background: {active};
187
+ }}
188
+ QLabel#sas_mdi_title_label {{
189
+ color: {fg};
190
+ background: transparent;
191
+ }}
192
+ QWidget#sas_mdi_titlebar QToolButton {{
193
+ color: {fg};
194
+ background: transparent;
195
+ }}
196
+ QWidget#sas_mdi_titlebar QToolButton:hover {{
197
+ background: rgba(255,255,255,0.10);
198
+ }}
160
199
  """)
161
200
 
201
+
202
+ def _fix_mdi_titlebar_emboss(self, fg_hex: str):
203
+ """
204
+ Fusion/Windows style can draw embossed title text (two-pass).
205
+ In dark themes that can become white-on-white -> 'double text'.
206
+ Force the shadow/emboss colors on the *titlebar widget only*.
207
+ """
208
+ try:
209
+ fg = QColor(fg_hex)
210
+ except Exception:
211
+ fg = QColor(240, 240, 240)
212
+
213
+ for sw in self.mdi.subWindowList():
214
+ try:
215
+ tb = sw.findChild(QWidget, "qt_mdi_titlebar")
216
+ if tb is None:
217
+ continue
218
+
219
+ pal = tb.palette()
220
+
221
+ # Main text
222
+ pal.setColor(QPalette.ColorRole.WindowText, fg)
223
+ pal.setColor(QPalette.ColorRole.Text, fg)
224
+ pal.setColor(QPalette.ColorRole.ButtonText, fg)
225
+
226
+ # Critical: make the embossed/shadow pass dark
227
+ dark = QColor(0, 0, 0)
228
+ pal.setColor(QPalette.ColorRole.Light, dark)
229
+ pal.setColor(QPalette.ColorRole.Midlight, dark)
230
+ pal.setColor(QPalette.ColorRole.Dark, dark)
231
+ pal.setColor(QPalette.ColorRole.Shadow, dark)
232
+
233
+ tb.setPalette(pal)
234
+
235
+ # Also push to the label if present (some styles read it from label)
236
+ lbl = tb.findChild(QLabel)
237
+ if lbl is not None:
238
+ lbl.setPalette(pal)
239
+ except Exception:
240
+ pass
241
+
242
+ def _tint_icon(self, icon: QIcon, color: QColor) -> QIcon:
243
+ """
244
+ Take an existing icon (often fromTheme) and force a single-color glyph.
245
+ Sets Normal and Active to the same tinted pixmaps to prevent hover flipping.
246
+ """
247
+ if icon.isNull():
248
+ return icon
249
+
250
+ out = QIcon()
251
+ sizes = [16, 20, 24, 32, 48, 64]
252
+
253
+ for sz in sizes:
254
+ pm = icon.pixmap(sz, sz, QIcon.Mode.Normal, QIcon.State.Off)
255
+ if pm.isNull():
256
+ continue
257
+
258
+ tinted = QPixmap(pm.size())
259
+ tinted.fill(Qt.GlobalColor.transparent)
260
+
261
+ p = QPainter(tinted)
262
+ p.setRenderHint(QPainter.RenderHint.Antialiasing, True)
263
+ p.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, True)
264
+
265
+ # Use the original alpha as a mask, fill with our color
266
+ p.drawPixmap(0, 0, pm)
267
+ p.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn)
268
+ p.fillRect(tinted.rect(), color)
269
+ p.end()
270
+
271
+ # Normal + Active -> same pixmap (prevents hover flip)
272
+ out.addPixmap(tinted, QIcon.Mode.Normal, QIcon.State.Off)
273
+ out.addPixmap(tinted, QIcon.Mode.Active, QIcon.State.Off)
274
+
275
+ # Disabled: slightly dimmer (optional)
276
+ dis = QColor(color)
277
+ dis.setAlphaF(0.45)
278
+ dispm = QPixmap(tinted.size())
279
+ dispm.fill(Qt.GlobalColor.transparent)
280
+ p2 = QPainter(dispm)
281
+ p2.drawPixmap(0, 0, tinted)
282
+ p2.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn)
283
+ p2.fillRect(dispm.rect(), dis)
284
+ p2.end()
285
+ out.addPixmap(dispm, QIcon.Mode.Disabled, QIcon.State.Off)
286
+
287
+ return out
288
+
289
+ def _retint_zoom_icons(self):
290
+ """
291
+ Retint only the zoom actions (the ones built from QIcon.fromTheme).
292
+ Call after app palette is applied.
293
+ """
294
+ pal = QApplication.palette()
295
+ glyph = pal.color(QPalette.ColorRole.ButtonText) # or Text; ButtonText tends to match toolbars well
296
+
297
+ for name in ("act_zoom_out", "act_zoom_in", "act_zoom_1_1", "act_zoom_fit"):
298
+ act = getattr(self, name, None)
299
+ if act is None:
300
+ continue
301
+
302
+ # stash original once so repeated theme flips don't re-tint a tinted icon
303
+ if not hasattr(act, "_base_icon"):
304
+ act._base_icon = act.icon()
305
+
306
+ act.setIcon(self._tint_icon(act._base_icon, glyph))
307
+
162
308
  def _dark_palette(self) -> QPalette:
163
309
  """Create a dark theme palette."""
164
310
  p = QPalette()
@@ -172,7 +318,7 @@ class ThemeMixin:
172
318
  hi = QColor(30, 144, 255) # highlight (dodger blue)
173
319
 
174
320
  p.setColor(QPalette.ColorRole.Window, panel)
175
- p.setColor(QPalette.ColorRole.WindowText, text)
321
+ p.setColor(QPalette.ColorRole.WindowText, QColor(255, 255, 255))
176
322
  p.setColor(QPalette.ColorRole.Base, bg)
177
323
  p.setColor(QPalette.ColorRole.AlternateBase, altbase)
178
324
  p.setColor(QPalette.ColorRole.ToolTipBase, panel)
@@ -270,7 +416,7 @@ class ThemeMixin:
270
416
  link = QColor(120, 170, 255)
271
417
  linkv = QColor(180, 150, 255)
272
418
  hi = QColor(95, 145, 230)
273
- hitxt = QColor(255, 255, 255)
419
+ hitxt = QColor(20, 20, 20)
274
420
 
275
421
  # Core roles
276
422
  p.setColor(QPalette.ColorRole.Window, window)
@@ -300,7 +446,7 @@ class ThemeMixin:
300
446
  p.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, dis)
301
447
  p.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Base, QColor(58, 58, 58))
302
448
  p.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Highlight, QColor(80, 80, 80))
303
- p.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.HighlightedText, QColor(210, 210, 210))
449
+ p.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.HighlightedText, QColor(20, 20, 20))
304
450
 
305
451
  return p
306
452