MoleditPy-linux 4.5.1__tar.gz → 4.5.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/PKG-INFO +2 -2
  2. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/pyproject.toml +2 -2
  3. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/MoleditPy_linux.egg-info/PKG-INFO +2 -2
  4. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/MoleditPy_linux.egg-info/requires.txt +1 -1
  5. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/atom_item.py +31 -6
  6. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/main_window_init.py +27 -17
  7. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/utils/system_utils.py +18 -2
  8. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/LICENSE +0 -0
  9. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/README.md +0 -0
  10. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/setup.cfg +0 -0
  11. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/MoleditPy_linux.egg-info/SOURCES.txt +0 -0
  12. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  13. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  14. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  15. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/__init__.py +0 -0
  16. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/__main__.py +0 -0
  17. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/assets/file_icon.ico +0 -0
  18. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/assets/icon.icns +0 -0
  19. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/assets/icon.ico +0 -0
  20. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/assets/icon.png +0 -0
  21. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/core/__init__.py +0 -0
  22. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/core/mol_geometry.py +0 -0
  23. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/core/molecular_data.py +0 -0
  24. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/main.py +0 -0
  25. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/plugins/__init__.py +0 -0
  26. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/plugins/plugin_interface.py +0 -0
  27. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/plugins/plugin_manager.py +0 -0
  28. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
  29. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/__init__.py +0 -0
  30. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/about_dialog.py +0 -0
  31. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/align_plane_dialog.py +0 -0
  32. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/alignment_dialog.py +0 -0
  33. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/analysis_window.py +0 -0
  34. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/angle_dialog.py +0 -0
  35. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/app_state.py +0 -0
  36. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/atom_picking.py +0 -0
  37. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/base_picking_dialog.py +0 -0
  38. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/bond_item.py +0 -0
  39. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/bond_length_dialog.py +0 -0
  40. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/calculation_worker.py +0 -0
  41. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
  42. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/compute_logic.py +0 -0
  43. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +0 -0
  44. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/custom_interactor_style.py +0 -0
  45. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
  46. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
  47. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/dialog_logic.py +0 -0
  48. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/dihedral_dialog.py +0 -0
  49. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/edit_3d_logic.py +0 -0
  50. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/edit_actions_logic.py +0 -0
  51. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/export_logic.py +0 -0
  52. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/geometry_base_dialog.py +0 -0
  53. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/io_logic.py +0 -0
  54. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/main_window.py +0 -0
  55. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/mirror_dialog.py +0 -0
  56. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/molecular_scene_handler.py +0 -0
  57. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/molecule_scene.py +0 -0
  58. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/move_group_dialog.py +0 -0
  59. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +0 -0
  60. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
  61. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/planarize_dialog.py +0 -0
  62. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/plugin_menu_manager.py +0 -0
  63. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
  64. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
  65. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +0 -0
  66. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
  67. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +0 -0
  68. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
  69. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/string_importers.py +0 -0
  70. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/template_preview_item.py +0 -0
  71. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/template_preview_view.py +0 -0
  72. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/translation_dialog.py +0 -0
  73. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/ui_manager.py +0 -0
  74. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/user_template_dialog.py +0 -0
  75. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/view_3d_logic.py +0 -0
  76. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/zoomable_view.py +0 -0
  77. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/utils/__init__.py +0 -0
  78. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/utils/constants.py +0 -0
  79. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/utils/default_settings.py +0 -0
  80. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
  81. {moleditpy_linux-4.5.1 → moleditpy_linux-4.5.2}/src/moleditpy_linux/utils/suppress_log.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy-linux
3
- Version: 4.5.1
3
+ Version: 4.5.2
4
4
  Summary: A cross-platform, simple, and intuitive molecular structure editor built in Python. It allows 2D molecular drawing and 3D structure visualization. It supports exporting structure files for input to DFT calculation software.
5
5
  Author-email: HiroYokoyama <titech.yoko.hiro@gmail.com>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -697,7 +697,7 @@ Requires-Dist: numpy
697
697
  Requires-Dist: pyqt6<6.10; python_version == "3.9"
698
698
  Requires-Dist: pyqt6<6.12; python_version != "3.9"
699
699
  Requires-Dist: pyvista<0.49
700
- Requires-Dist: pyvistaqt<0.12
700
+ Requires-Dist: pyvistaqt<0.13
701
701
  Requires-Dist: rdkit<2026.4
702
702
  Dynamic: license-file
703
703
 
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "MoleditPy-linux"
7
7
 
8
- version = "4.5.1"
8
+ version = "4.5.2"
9
9
 
10
10
  license = {file = "LICENSE"}
11
11
 
@@ -37,7 +37,7 @@ dependencies = [
37
37
  "pyqt6 < 6.10; python_version == '3.9'",
38
38
  "pyqt6 < 6.12; python_version != '3.9'",
39
39
  "pyvista < 0.49",
40
- "pyvistaqt < 0.12",
40
+ "pyvistaqt < 0.13",
41
41
  "rdkit < 2026.4",
42
42
  ]
43
43
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy-linux
3
- Version: 4.5.1
3
+ Version: 4.5.2
4
4
  Summary: A cross-platform, simple, and intuitive molecular structure editor built in Python. It allows 2D molecular drawing and 3D structure visualization. It supports exporting structure files for input to DFT calculation software.
5
5
  Author-email: HiroYokoyama <titech.yoko.hiro@gmail.com>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -697,7 +697,7 @@ Requires-Dist: numpy
697
697
  Requires-Dist: pyqt6<6.10; python_version == "3.9"
698
698
  Requires-Dist: pyqt6<6.12; python_version != "3.9"
699
699
  Requires-Dist: pyvista<0.49
700
- Requires-Dist: pyvistaqt<0.12
700
+ Requires-Dist: pyvistaqt<0.13
701
701
  Requires-Dist: rdkit<2026.4
702
702
  Dynamic: license-file
703
703
 
@@ -1,6 +1,6 @@
1
1
  numpy
2
2
  pyvista<0.49
3
- pyvistaqt<0.12
3
+ pyvistaqt<0.13
4
4
  rdkit<2026.4
5
5
 
6
6
  [:python_version != "3.9"]
@@ -43,6 +43,34 @@ from ..utils.sip_isdeleted_safe import sip_isdeleted_safe
43
43
  if TYPE_CHECKING:
44
44
  from .bond_item import BondItem
45
45
 
46
+ SUBSCRIPT_MAP = str.maketrans("0123456789", "₀₁₂₃₄₅₆₇₈₉")
47
+
48
+ # Every subscript digit, so one warm-up covers any implicit-H count.
49
+ _WARM_UP_TEXT = "CH" + "0123456789".translate(SUBSCRIPT_MAP)
50
+
51
+
52
+ def warm_font_cache(font: Optional[QFont] = None) -> None:
53
+ """Pay Qt's one-off font costs up front instead of on the first heteroatom.
54
+
55
+ Two process-wide costs hide in QFontMetricsF: the first text shaping in a
56
+ family (~10-120 ms, worse on a cold Windows font cache) and the first
57
+ lookup of a subscript digit, which Arial lacks — Qt answers that by walking
58
+ the whole system font database (~200-330 ms, measured).
59
+
60
+ Drawing a carbon skeleton pays neither: a bonded neutral carbon is
61
+ invisible, so it renders no glyph at all. The first O or N a user places
62
+ gets "OH₂" and pays both at once, mid-click, as a visible freeze.
63
+
64
+ Call once on the GUI thread after the window is up. QFontMetricsF is not
65
+ usable off the GUI thread, so this cannot move to a worker.
66
+ """
67
+ try:
68
+ QFontMetricsF(font or QFont(FONT_FAMILY, 20, FONT_WEIGHT_BOLD)).boundingRect(
69
+ _WARM_UP_TEXT
70
+ )
71
+ except (RuntimeError, TypeError, ValueError):
72
+ logging.debug("Font cache warm-up skipped", exc_info=True)
73
+
46
74
 
47
75
  class AtomItem(QGraphicsItem):
48
76
  """2D scene item representing a single atom in the molecule editor."""
@@ -138,8 +166,7 @@ class AtomItem(QGraphicsItem):
138
166
  if not is_skeletal_carbon:
139
167
  hydrogen_part = "H"
140
168
  if self.implicit_h_count > 1:
141
- subscript_map = str.maketrans("0123456789", "₀₁₂₃₄₅₆₇₈₉")
142
- hydrogen_part += str(self.implicit_h_count).translate(subscript_map)
169
+ hydrogen_part += str(self.implicit_h_count).translate(SUBSCRIPT_MAP)
143
170
 
144
171
  flip_text = False
145
172
  if hydrogen_part and self.bonds:
@@ -261,8 +288,7 @@ class AtomItem(QGraphicsItem):
261
288
  if not is_skeletal_carbon:
262
289
  hydrogen_part = "H"
263
290
  if self.implicit_h_count > 1:
264
- subscript_map = str.maketrans("0123456789", "₀₁₂₃₄₅₆₇₈₉")
265
- hydrogen_part += str(self.implicit_h_count).translate(subscript_map)
291
+ hydrogen_part += str(self.implicit_h_count).translate(SUBSCRIPT_MAP)
266
292
 
267
293
  flip_text = False
268
294
  if hydrogen_part and self.bonds:
@@ -361,9 +387,8 @@ class AtomItem(QGraphicsItem):
361
387
  if not is_skeletal_carbon:
362
388
  hydrogen_part = "H"
363
389
  if self.implicit_h_count > 1:
364
- subscript_map = str.maketrans("0123456789", "₀₁₂₃₄₅₆₇₈₉")
365
390
  hydrogen_part += str(self.implicit_h_count).translate(
366
- subscript_map
391
+ SUBSCRIPT_MAP
367
392
  )
368
393
 
369
394
  # --- Determine if the text should be flipped ---
@@ -20,9 +20,6 @@ import os
20
20
  import sys
21
21
  from typing import Any, Dict, Optional
22
22
 
23
- # RDKit imports (explicit to satisfy flake8 and used features)
24
- from rdkit import Chem
25
-
26
23
  # PyQt6 Modules
27
24
  from PyQt6.QtCore import QLineF, QPointF, QRectF, Qt, QTimer, QUrl
28
25
  from PyQt6.QtGui import (
@@ -65,7 +62,8 @@ except ImportError:
65
62
  winreg = None # type: ignore[assignment]
66
63
 
67
64
 
68
- from ..utils.constants import DEFAULT_CPK_COLORS, NUM_DASHES, VERSION
65
+ from ..utils.constants import DEFAULT_CPK_COLORS, FONT_FAMILY, NUM_DASHES, VERSION
66
+ from .atom_item import warm_font_cache
69
67
  from .custom_qt_interactor import CustomQtInteractor
70
68
  from ..core.molecular_data import MolecularData
71
69
  from .molecule_scene import MoleculeScene
@@ -74,6 +72,10 @@ from .settings_dialog import SettingsDialog
74
72
  from .zoomable_view import ZoomableView
75
73
 
76
74
 
75
+ # Distinct from None, which is a real answer meaning "OS preference unknown".
76
+ _UNQUERIED = object()
77
+
78
+
77
79
  class MainInitManager:
78
80
  """Feature class separated from main_window.py"""
79
81
 
@@ -82,6 +84,7 @@ class MainInitManager:
82
84
  ) -> None:
83
85
  self.host = host
84
86
  # Explicit declarations for Mypy
87
+ self._os_dark_pref: Any = _UNQUERIED
85
88
  self.scene: Any = None # MoleculeScene, created during init
86
89
  self.data: Optional[MolecularData] = None
87
90
  self.formula_label: Optional[QLabel] = None
@@ -179,18 +182,6 @@ class MainInitManager:
179
182
  # Install event filter to capture window close events (handled in UIManager)
180
183
  self.host.installEventFilter(self.host.ui_manager)
181
184
 
182
- # RDKit Warm-up (initial execution cost)
183
- try:
184
- # Create a molecule with a variety of common atoms to ensure
185
- # the valence/H-count machinery is fully initialized.
186
- warmup_smiles = "OC(N)C(S)P"
187
- warmup_mol = Chem.MolFromSmiles(warmup_smiles)
188
- if warmup_mol:
189
- for atom in warmup_mol.GetAtoms():
190
- atom.GetNumImplicitHs()
191
- except (AttributeError, RuntimeError, ValueError) as e:
192
- logging.warning(f"RDKit warm-up failed: {e}")
193
-
194
185
  self.host.state_manager.reset_undo_stack()
195
186
  self.scene.selectionChanged.connect( # type: ignore[attr-defined]
196
187
  self.host.edit_actions_manager.update_edit_menu_actions
@@ -206,6 +197,8 @@ class MainInitManager:
206
197
  QTimer.singleShot(0, lambda: self.load_command_line_file(initial_file))
207
198
 
208
199
  QTimer.singleShot(0, self.apply_initial_settings)
200
+ # After apply_initial_settings, so the user's own font family is warmed.
201
+ QTimer.singleShot(0, self._warm_font_cache)
209
202
  # Camera initialization flag (permits reset only during the first draw)
210
203
  self._camera_initialized = False
211
204
 
@@ -226,6 +219,19 @@ class MainInitManager:
226
219
  f"Suppressed exception: {e}"
227
220
  ) # Suppress non-critical UI/menu initialization errors
228
221
 
222
+ def _warm_font_cache(self) -> None:
223
+ """Resolve the atom-label font now, in the user's configured family."""
224
+ scene = getattr(self, "scene", None)
225
+ font = None
226
+ if scene is not None and hasattr(scene, "get_setting"):
227
+ family = scene.get_setting("atom_font_family_2d", FONT_FAMILY)
228
+ size = scene.get_setting("atom_font_size_2d", 20)
229
+ bold = scene.get_setting("atom_font_bold_2d", True)
230
+ font = QFont(
231
+ family, size, QFont.Weight.Bold if bold else QFont.Weight.Normal
232
+ )
233
+ warm_font_cache(font)
234
+
229
235
  def init_ui(self) -> None:
230
236
  """Set the window icon and initialize main layout, toolbars, and menu bar."""
231
237
  script_dir = os.path.dirname(os.path.abspath(__file__))
@@ -1076,7 +1082,11 @@ class MainInitManager:
1076
1082
  return QColor(fg)
1077
1083
 
1078
1084
  with suppress_log(Exception):
1079
- os_pref = detect_system_dark_mode()
1085
+ # Cached: this forks `defaults`/`gsettings` on macOS and Linux, and
1086
+ # icon painting asks once per icon (15+ during startup).
1087
+ if self._os_dark_pref is _UNQUERIED:
1088
+ self._os_dark_pref = detect_system_dark_mode()
1089
+ os_pref = self._os_dark_pref
1080
1090
  if os_pref is not None:
1081
1091
  return QColor("#FFFFFF") if os_pref else QColor("#000000")
1082
1092
 
@@ -21,6 +21,9 @@ try:
21
21
  except ImportError:
22
22
  winreg = None # type: ignore[assignment]
23
23
 
24
+ # A wedged `defaults`/`gsettings` must not take the app's startup with it.
25
+ _THEME_QUERY_TIMEOUT_S = 5.0
26
+
24
27
 
25
28
  def detect_system_dark_mode() -> Optional[bool]:
26
29
  """Return True if the OS prefers dark app theme, False if light, or None if unknown."""
@@ -33,8 +36,18 @@ def detect_system_dark_mode() -> Optional[bool]:
33
36
 
34
37
 
35
38
  def detect_system_theme() -> Optional[str]:
36
- """Return the OS's preferred theme setting as 'dark', 'light', or None."""
37
- with suppress_log(AttributeError, RuntimeError, OSError, FileNotFoundError):
39
+ """Return the OS's preferred theme setting as 'dark', 'light', or None.
40
+
41
+ Forks a helper process on macOS and Linux, so callers that ask repeatedly
42
+ should hold on to the answer rather than re-querying.
43
+ """
44
+ with suppress_log(
45
+ AttributeError,
46
+ RuntimeError,
47
+ OSError,
48
+ FileNotFoundError,
49
+ subprocess.SubprocessError,
50
+ ):
38
51
  # Windows
39
52
  if platform.system() == "Windows" and winreg is not None:
40
53
  with winreg.OpenKey(
@@ -50,6 +63,7 @@ def detect_system_theme() -> Optional[str]:
50
63
  ["defaults", "read", "-g", "AppleInterfaceStyle"],
51
64
  capture_output=True,
52
65
  text=True,
66
+ timeout=_THEME_QUERY_TIMEOUT_S,
53
67
  )
54
68
  if p.returncode == 0 and "dark" in p.stdout.strip().lower():
55
69
  return "dark"
@@ -61,6 +75,7 @@ def detect_system_theme() -> Optional[str]:
61
75
  ["gsettings", "get", "org.gnome.desktop.interface", "color-scheme"],
62
76
  capture_output=True,
63
77
  text=True,
78
+ timeout=_THEME_QUERY_TIMEOUT_S,
64
79
  )
65
80
  if p.returncode == 0:
66
81
  out = p.stdout.strip().strip("'\n ")
@@ -73,6 +88,7 @@ def detect_system_theme() -> Optional[str]:
73
88
  ["gsettings", "get", "org.gnome.desktop.interface", "gtk-theme"],
74
89
  capture_output=True,
75
90
  text=True,
91
+ timeout=_THEME_QUERY_TIMEOUT_S,
76
92
  )
77
93
  if p.returncode == 0 and "-dark" in p.stdout.lower():
78
94
  return "dark"
File without changes