MoleditPy-linux 4.5.0__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.0 → moleditpy_linux-4.5.2}/PKG-INFO +2 -2
  2. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/pyproject.toml +2 -2
  3. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/MoleditPy_linux.egg-info/PKG-INFO +2 -2
  4. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/MoleditPy_linux.egg-info/requires.txt +1 -1
  5. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/core/molecular_data.py +111 -34
  6. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/main.py +2 -2
  7. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/angle_dialog.py +22 -0
  8. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/atom_item.py +31 -6
  9. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/bond_length_dialog.py +47 -1
  10. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/compute_logic.py +25 -0
  11. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/dihedral_dialog.py +23 -0
  12. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/edit_actions_logic.py +17 -22
  13. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/io_logic.py +31 -18
  14. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/main_window_init.py +27 -17
  15. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +2 -2
  16. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/string_importers.py +1 -0
  17. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/utils/system_utils.py +18 -2
  18. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/LICENSE +0 -0
  19. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/README.md +0 -0
  20. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/setup.cfg +0 -0
  21. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/MoleditPy_linux.egg-info/SOURCES.txt +0 -0
  22. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  23. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  24. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  25. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/__init__.py +0 -0
  26. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/__main__.py +0 -0
  27. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/assets/file_icon.ico +0 -0
  28. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/assets/icon.icns +0 -0
  29. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/assets/icon.ico +0 -0
  30. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/assets/icon.png +0 -0
  31. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/core/__init__.py +0 -0
  32. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/core/mol_geometry.py +0 -0
  33. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/plugins/__init__.py +0 -0
  34. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/plugins/plugin_interface.py +0 -0
  35. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/plugins/plugin_manager.py +0 -0
  36. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
  37. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/__init__.py +0 -0
  38. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/about_dialog.py +0 -0
  39. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/align_plane_dialog.py +0 -0
  40. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/alignment_dialog.py +0 -0
  41. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/analysis_window.py +0 -0
  42. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/app_state.py +0 -0
  43. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/atom_picking.py +0 -0
  44. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/base_picking_dialog.py +0 -0
  45. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/bond_item.py +0 -0
  46. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/calculation_worker.py +0 -0
  47. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
  48. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +0 -0
  49. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/custom_interactor_style.py +0 -0
  50. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
  51. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
  52. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/dialog_logic.py +0 -0
  53. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/edit_3d_logic.py +0 -0
  54. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/export_logic.py +0 -0
  55. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/geometry_base_dialog.py +0 -0
  56. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/main_window.py +0 -0
  57. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/mirror_dialog.py +0 -0
  58. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/molecular_scene_handler.py +0 -0
  59. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/molecule_scene.py +0 -0
  60. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/move_group_dialog.py +0 -0
  61. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +0 -0
  62. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
  63. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/planarize_dialog.py +0 -0
  64. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/plugin_menu_manager.py +0 -0
  65. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
  66. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
  67. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +0 -0
  68. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
  69. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
  70. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/template_preview_item.py +0 -0
  71. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/template_preview_view.py +0 -0
  72. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/translation_dialog.py +0 -0
  73. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/ui_manager.py +0 -0
  74. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/user_template_dialog.py +0 -0
  75. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/view_3d_logic.py +0 -0
  76. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/ui/zoomable_view.py +0 -0
  77. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/utils/__init__.py +0 -0
  78. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/utils/constants.py +0 -0
  79. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/utils/default_settings.py +0 -0
  80. {moleditpy_linux-4.5.0 → moleditpy_linux-4.5.2}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
  81. {moleditpy_linux-4.5.0 → 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.0
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.0"
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.0
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"]
@@ -30,6 +30,93 @@ class PointTuple(tuple):
30
30
  return float(self[1])
31
31
 
32
32
 
33
+ def _mdl_radical_code(radical_electrons: int) -> int:
34
+ """Map a count of unpaired electrons to an MDL radical code."""
35
+ # MDL encodes 1 = singlet, 2 = doublet, 3 = triplet.
36
+ if radical_electrons <= 0:
37
+ return 0
38
+ return 2 if radical_electrons == 1 else 3
39
+
40
+
41
+ def _mdl_property_lines(atom_records: List[Dict[str, Any]]) -> List[str]:
42
+ """Build the M CHG / M RAD property lines for a V2000 block."""
43
+ lines = []
44
+ for tag, key, encode in (
45
+ ("CHG", "charge", int),
46
+ ("RAD", "radical", _mdl_radical_code),
47
+ ):
48
+ entries = [
49
+ (idx, encode(rec[key]))
50
+ for idx, rec in enumerate(atom_records, start=1)
51
+ if rec[key]
52
+ ]
53
+ # The MDL property block allows at most 8 entries per line.
54
+ for start in range(0, len(entries), 8):
55
+ chunk = entries[start : start + 8]
56
+ lines.append(
57
+ f"M {tag}{len(chunk):3d}"
58
+ + "".join(f"{idx:4d}{val:4d}" for idx, val in chunk)
59
+ )
60
+ return lines
61
+
62
+
63
+ def _to_v2000_block(
64
+ atom_records: List[Dict[str, Any]], bond_records: List[Dict[str, Any]]
65
+ ) -> str:
66
+ """Render collected atom/bond records as an MDL V2000 MOL block."""
67
+ lines = ["", " MoleditPy", ""]
68
+ lines.append(
69
+ f"{len(atom_records):3d}{len(bond_records):3d} 0 0 0 0 0 0 0 0999 V2000"
70
+ )
71
+ for rec in atom_records:
72
+ # Charge and radical travel in the M CHG / M RAD blocks below, so every
73
+ # per-atom field here stays zero.
74
+ lines.append(
75
+ f"{rec['x']:10.4f}{rec['y']:10.4f}{0.0:10.4f} {rec['symbol']:<3}"
76
+ " 0 0 0 0 0 0 0 0 0 0 0 0"
77
+ )
78
+ for rec in bond_records:
79
+ lines.append(
80
+ f"{rec['a1']:3d}{rec['a2']:3d}{rec['order']:3d}{rec['stereo']:3d} 0 0 0"
81
+ )
82
+ lines.extend(_mdl_property_lines(atom_records))
83
+ lines.append("M END")
84
+ return "\n".join(lines) + "\n"
85
+
86
+
87
+ def _to_v3000_block(
88
+ atom_records: List[Dict[str, Any]], bond_records: List[Dict[str, Any]]
89
+ ) -> str:
90
+ """Render collected atom/bond records as an MDL V3000 MOL block."""
91
+ lines = ["", " MoleditPy", "", " 0 0 0 0 0 0 0 0 0 0999 V3000"]
92
+ lines.append("M V30 BEGIN CTAB")
93
+ lines.append(f"M V30 COUNTS {len(atom_records)} {len(bond_records)} 0 0 0")
94
+ lines.append("M V30 BEGIN ATOM")
95
+ for idx, rec in enumerate(atom_records, start=1):
96
+ extra = ""
97
+ if rec["charge"]:
98
+ extra += f" CHG={int(rec['charge'])}"
99
+ if rec["radical"]:
100
+ extra += f" RAD={_mdl_radical_code(rec['radical'])}"
101
+ lines.append(
102
+ f"M V30 {idx} {rec['symbol']} "
103
+ f"{rec['x']:.4f} {rec['y']:.4f} 0.0000 0{extra}"
104
+ )
105
+ lines.append("M V30 END ATOM")
106
+ lines.append("M V30 BEGIN BOND")
107
+ for idx, rec in enumerate(bond_records, start=1):
108
+ cfg = ""
109
+ if rec["stereo"] == 1:
110
+ cfg = " CFG=1"
111
+ elif rec["stereo"] == 6:
112
+ cfg = " CFG=3"
113
+ lines.append(f"M V30 {idx} {rec['order']} {rec['a1']} {rec['a2']}{cfg}")
114
+ lines.append("M V30 END BOND")
115
+ lines.append("M V30 END CTAB")
116
+ lines.append("M END")
117
+ return "\n".join(lines) + "\n"
118
+
119
+
33
120
  class MolecularData:
34
121
  """In-memory graph of atoms and bonds, independent of any UI framework."""
35
122
 
@@ -348,7 +435,7 @@ class MolecularData:
348
435
 
349
436
  # Counts line and bond indices must only reflect atoms actually written
350
437
  atom_map: Dict[int, int] = {}
351
- atom_lines: List[str] = []
438
+ atom_records: List[Dict[str, Any]] = []
352
439
  for old_id, atom in self.atoms.items():
353
440
  # Convert scene pixel coordinates to angstroms when emitting MOL block
354
441
  pos = atom.get("pos")
@@ -362,37 +449,23 @@ class MolecularData:
362
449
  else:
363
450
  continue
364
451
 
365
- x, y = x_px * ANGSTROM_PER_PIXEL, y_px * ANGSTROM_PER_PIXEL
366
- z, symbol = 0.0, atom["symbol"]
367
- charge = atom.get("charge", 0)
368
-
369
- chg_code = 0
370
- if charge == 3:
371
- chg_code = 1
372
- elif charge == 2:
373
- chg_code = 2
374
- elif charge == 1:
375
- chg_code = 3
376
- elif charge == -1:
377
- chg_code = 5
378
- elif charge == -2:
379
- chg_code = 6
380
- elif charge == -3:
381
- chg_code = 7
382
-
383
- atom_map[old_id] = len(atom_lines)
384
- atom_lines.append(
385
- f"{x:10.4f}{y:10.4f}{z:10.4f} {symbol:<3} 0 0 0{chg_code:3d} 0 0 0 0 0 0 0\n"
452
+ atom_map[old_id] = len(atom_records)
453
+ atom_records.append(
454
+ {
455
+ "x": x_px * ANGSTROM_PER_PIXEL,
456
+ "y": y_px * ANGSTROM_PER_PIXEL,
457
+ "symbol": atom["symbol"],
458
+ "charge": int(atom.get("charge", 0) or 0),
459
+ "radical": int(atom.get("radical", 0) or 0),
460
+ }
386
461
  )
387
462
 
388
- bond_lines = []
463
+ bond_records: List[Dict[str, Any]] = []
389
464
  for (id1, id2), bond in self.bonds.items():
390
465
  if id1 not in atom_map or id2 not in atom_map:
391
466
  continue
392
- idx1, idx2 = atom_map[id1] + 1, atom_map[id2] + 1
393
- # Bond order may be a float (1.5 = aromatic); V2000 uses code 4.
467
+ # Bond order may be a float (1.5 = aromatic); MDL uses code 4.
394
468
  order_val = float(bond["order"])
395
- order = 4 if order_val == 1.5 else int(order_val)
396
469
  stereo_code = 0
397
470
  bond_stereo = bond.get("stereo", 0)
398
471
  if bond_stereo == 1:
@@ -400,16 +473,20 @@ class MolecularData:
400
473
  elif bond_stereo == 2:
401
474
  stereo_code = 6
402
475
 
403
- bond_lines.append(
404
- f"{idx1:3d}{idx2:3d}{order:3d}{stereo_code:3d} 0 0 0\n"
476
+ bond_records.append(
477
+ {
478
+ "a1": atom_map[id1] + 1,
479
+ "a2": atom_map[id2] + 1,
480
+ "order": 4 if order_val == 1.5 else int(order_val),
481
+ "stereo": stereo_code,
482
+ }
405
483
  )
406
484
 
407
- mol_block = "\n MoleditPy\n\n"
408
- mol_block += f"{len(atom_lines):3d}{len(bond_lines):3d} 0 0 0 0 0 0 0 0999 V2000\n"
409
- mol_block += "".join(atom_lines)
410
- mol_block += "".join(bond_lines)
411
- mol_block += "M END\n"
412
- return mol_block
485
+ # V2000 packs the counts into three-character fields, so anything past
486
+ # 999 would run the atom count into the bond count and corrupt the file.
487
+ if len(atom_records) > 999 or len(bond_records) > 999:
488
+ return _to_v3000_block(atom_records, bond_records)
489
+ return _to_v2000_block(atom_records, bond_records)
413
490
 
414
491
  def to_template_dict(
415
492
  self, name: str, version: str = "1.0", application_version: str = ""
@@ -189,7 +189,7 @@ def setup_logging() -> None:
189
189
  log_dir = os.path.join(os.path.expanduser("~"), ".moleditpy")
190
190
  try:
191
191
  os.makedirs(log_dir, exist_ok=True)
192
- log_path = os.path.join(log_dir, "moleditpy_linux.log")
192
+ log_path = os.path.join(log_dir, "moleditpy.log")
193
193
  fh = logging.handlers.RotatingFileHandler(
194
194
  log_path, maxBytes=1_048_576, backupCount=3, encoding="utf-8"
195
195
  )
@@ -231,7 +231,7 @@ def main() -> None:
231
231
  if sys.platform == "win32":
232
232
  # Taskbar grouping ID follows the major version
233
233
  major = VERSION.split(".")[0] if VERSION and VERSION != "Unknown" else "0"
234
- myappid = f"hyoko.moleditpy_linux.{major}"
234
+ myappid = f"hyoko.moleditpy.{major}"
235
235
  ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
236
236
 
237
237
  parser = argparse.ArgumentParser(
@@ -35,6 +35,7 @@ from ..core.mol_geometry import (
35
35
  calc_angle_deg,
36
36
  get_connected_group,
37
37
  )
38
+ from ..utils.suppress_log import suppress_log
38
39
 
39
40
  if TYPE_CHECKING:
40
41
  from .main_window import MainWindow
@@ -468,5 +469,26 @@ class AngleDialog(GeometryBaseDialog):
468
469
  atoms_to_move,
469
470
  )
470
471
 
472
+ achieved = calc_angle_deg(positions[idx_a], positions[idx_b], positions[idx_c])
473
+
471
474
  # Write updated positions back using inherited helper
472
475
  self._update_molecule_geometry(positions)
476
+
477
+ self._warn_if_angle_not_applied(new_angle_deg, achieved)
478
+
479
+ def _warn_if_angle_not_applied(self, requested: float, achieved: float) -> None:
480
+ """Tell the user when the requested angle could not be reached.
481
+
482
+ For an angle inside a ring the connected group reached from one arm
483
+ comes back around and contains all three atoms, so they rotate
484
+ together and the angle is unchanged. Leaving the old value silently
485
+ looks like the edit was applied.
486
+ """
487
+ if abs(achieved - requested) < 0.5:
488
+ return
489
+ with suppress_log(AttributeError, RuntimeError, TypeError):
490
+ self.main_window.statusBar().showMessage(
491
+ f"Angle unchanged ({achieved:.2f} deg): the three atoms rotate "
492
+ "together, which happens for an angle inside a ring.",
493
+ 5000,
494
+ )
@@ -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 ---
@@ -14,7 +14,7 @@ from __future__ import annotations
14
14
  import logging
15
15
 
16
16
  import numpy as np
17
- from typing import Any, TYPE_CHECKING, Optional, Sequence
17
+ from typing import Any, Dict, TYPE_CHECKING, Optional, Sequence
18
18
 
19
19
  from PyQt6.QtCore import Qt
20
20
  from PyQt6.QtWidgets import (
@@ -32,6 +32,7 @@ from rdkit import Chem
32
32
 
33
33
  from .geometry_base_dialog import GeometryBaseDialog
34
34
  from ..core.mol_geometry import calc_distance, get_connected_group
35
+ from ..utils.suppress_log import suppress_log
35
36
 
36
37
  if TYPE_CHECKING:
37
38
  from .main_window import MainWindow
@@ -359,6 +360,8 @@ class BondLengthDialog(GeometryBaseDialog):
359
360
  if current_distance == 0:
360
361
  return
361
362
 
363
+ original = {i: np.array(p, dtype=float) for i, p in positions.items()}
364
+
362
365
  direction = direction / current_distance
363
366
 
364
367
  if self.both_groups_radio.isChecked():
@@ -400,3 +403,46 @@ class BondLengthDialog(GeometryBaseDialog):
400
403
 
401
404
  # Write updated positions back using inherited helper
402
405
  self._update_molecule_geometry(positions)
406
+
407
+ self._warn_if_other_bonds_moved(original, positions, idx1, idx2)
408
+
409
+ def _warn_if_other_bonds_moved(
410
+ self,
411
+ before: Dict[int, Any],
412
+ after: Dict[int, Any],
413
+ idx1: int,
414
+ idx2: int,
415
+ ) -> None:
416
+ """Report a neighbouring bond that this edit resized as a side effect.
417
+
418
+ A bond inside a ring cannot be resized alone: the group translated
419
+ along the bond axis wraps back around the ring, so the ring closes at
420
+ a different length. Stretching a benzene bond to 1.80 A pulls its
421
+ neighbour to 1.24 A, shorter than a double bond, with nothing on
422
+ screen to say so.
423
+ """
424
+ edited = {idx1, idx2}
425
+ worst_delta = 0.0
426
+ worst_label = ""
427
+ for bond in self.mol.GetBonds():
428
+ i, j = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx()
429
+ if {i, j} == edited or i not in before or j not in before:
430
+ continue
431
+ delta = calc_distance(after[i], after[j]) - calc_distance(
432
+ before[i], before[j]
433
+ )
434
+ if abs(delta) > abs(worst_delta):
435
+ worst_delta = delta
436
+ worst_label = (
437
+ f"{self.mol.GetAtomWithIdx(i).GetSymbol()}{i}-"
438
+ f"{self.mol.GetAtomWithIdx(j).GetSymbol()}{j}"
439
+ )
440
+
441
+ if abs(worst_delta) < 1e-4:
442
+ return
443
+ with suppress_log(AttributeError, RuntimeError, TypeError):
444
+ self.main_window.statusBar().showMessage(
445
+ f"Bond {worst_label} also changed by {worst_delta:+.3f} A: a bond "
446
+ "inside a ring cannot be resized on its own.",
447
+ 5000,
448
+ )
@@ -19,6 +19,7 @@ from PyQt6.QtCore import QThread, QTimer, QPoint
19
19
  from PyQt6.QtGui import QAction, QColor
20
20
  from PyQt6.QtWidgets import QMenu, QMessageBox, QWidget
21
21
  from rdkit import Chem
22
+ from rdkit.Chem import AllChem
22
23
 
23
24
  from . import OBABEL_AVAILABLE
24
25
 
@@ -458,7 +459,31 @@ class ComputeManager:
458
459
  # Defer so Qt finishes routing the modal result before restoring focus.
459
460
  QTimer.singleShot(0, self.host.init_manager.view_2d.setFocus)
460
461
 
462
+ def _ez_consistent_mol_block(self) -> Optional[str]:
463
+ """MOL block whose 2D geometry encodes the editor's E/Z labels."""
464
+ data = self.host.state_manager.data
465
+ if not any(b.get("stereo", 0) in (3, 4) for b in data.bonds.values()):
466
+ return None
467
+ stereo_mol = data.to_rdkit_mol(use_2d_stereo=False)
468
+ if stereo_mol is None:
469
+ return None
470
+ try:
471
+ # MDL carries double-bond cis/trans only in the 2D coordinates, so a
472
+ # label that contradicts how the user drew the bond is lost unless
473
+ # the layout is rebuilt from the stereo. These coordinates only seed
474
+ # 3D generation; the 2D canvas keeps the user's own layout.
475
+ planar = Chem.Mol(stereo_mol)
476
+ planar.RemoveAllConformers()
477
+ AllChem.Compute2DCoords(planar)
478
+ return str(Chem.MolToMolBlock(planar, includeStereo=True))
479
+ except (RuntimeError, ValueError) as e:
480
+ logging.warning("E/Z-consistent 2D layout failed: %s", e)
481
+ return None
482
+
461
483
  def _setup_mol_block_for_worker(self, mol: Chem.Mol) -> str:
484
+ ez_block = self._ez_consistent_mol_block()
485
+ if ez_block:
486
+ return ez_block
462
487
  mol_block = self.host.state_manager.data.to_mol_block()
463
488
  if not mol_block:
464
489
  mol_block = Chem.MolToMolBlock(mol, includeStereo=True)
@@ -35,6 +35,7 @@ from ..core.mol_geometry import (
35
35
  calculate_dihedral,
36
36
  get_connected_group,
37
37
  )
38
+ from ..utils.suppress_log import suppress_log
38
39
 
39
40
  if TYPE_CHECKING:
40
41
  from .main_window import MainWindow
@@ -469,5 +470,27 @@ class DihedralDialog(GeometryBaseDialog):
469
470
  positions, idx1, idx2, idx3, idx4, new_dihedral_deg, atoms_to_move
470
471
  )
471
472
 
473
+ achieved = calculate_dihedral(positions, idx1, idx2, idx3, idx4)
474
+
472
475
  # Write updated positions back using inherited helper
473
476
  self._update_molecule_geometry(positions)
477
+
478
+ self._warn_if_torsion_not_applied(new_dihedral_deg, achieved)
479
+
480
+ def _warn_if_torsion_not_applied(self, requested: float, achieved: float) -> None:
481
+ """Tell the user when the requested torsion could not be reached.
482
+
483
+ A torsion inside a ring cannot rotate: the connected group reached from
484
+ one side comes back around and contains all four atoms, so they turn
485
+ together and the angle is unchanged. Silently leaving the old value
486
+ looks like the edit was applied.
487
+ """
488
+ difference = abs(achieved - requested) % 360.0
489
+ if min(difference, 360.0 - difference) < 0.5:
490
+ return
491
+ with suppress_log(AttributeError, RuntimeError, TypeError):
492
+ self.main_window.statusBar().showMessage(
493
+ f"Dihedral unchanged ({achieved:.2f}°): the four atoms rotate "
494
+ "together, which happens for a bond inside a ring.",
495
+ 5000,
496
+ )
@@ -583,26 +583,17 @@ class EditActionsManager:
583
583
  if orig_id not in self.host.state_manager.data.atoms:
584
584
  continue
585
585
 
586
- # Get implicit hydrogens; fallback to total - explicit
587
- implicit_h = (
588
- int(rd_atom.GetNumImplicitHs())
589
- if hasattr(rd_atom, "GetNumImplicitHs")
590
- else 0
591
- )
592
- if implicit_h is None or implicit_h < 0:
586
+ # Hydrogens still needing an atom of their own. GetTotalNumHs()
587
+ # excludes H already drawn as atoms but includes the
588
+ # property-explicit H that sanitization assigns to an aromatic
589
+ # N-H; subtracting GetNumExplicitHs() here would cancel exactly
590
+ # that term and leave luminol's two N-H without hydrogens.
591
+ try:
592
+ implicit_h = int(rd_atom.GetTotalNumHs())
593
+ except (AttributeError, RuntimeError, ValueError, TypeError):
594
+ implicit_h = 0
595
+ if implicit_h < 0:
593
596
  implicit_h = 0
594
- if implicit_h == 0:
595
- # Fallback
596
- try:
597
- total_h = int(rd_atom.GetTotalNumHs())
598
- explicit_h = (
599
- int(rd_atom.GetNumExplicitHs())
600
- if hasattr(rd_atom, "GetNumExplicitHs")
601
- else 0
602
- )
603
- implicit_h = max(0, total_h - explicit_h)
604
- except (AttributeError, RuntimeError, ValueError, TypeError):
605
- implicit_h = 0
606
597
 
607
598
  if implicit_h <= 0:
608
599
  continue
@@ -954,12 +945,16 @@ class EditActionsManager:
954
945
  continue
955
946
  original_id = atom.GetIntProp("_original_atom_id")
956
947
 
957
- # Robust retrieval of H counts: prefer implicit, fallback to total or 0
948
+ # GetTotalNumHs() counts implicit plus property-explicit H but
949
+ # not H drawn as its own atom, which is exactly what the label
950
+ # should show. Sanitization files an aromatic N-H (luminol,
951
+ # pyrrole) under the property, where GetNumImplicitHs() alone
952
+ # reports 0 and the H silently vanishes from the label.
958
953
  try:
959
- h_count = int(atom.GetNumImplicitHs())
954
+ h_count = int(atom.GetTotalNumHs())
960
955
  except (AttributeError, RuntimeError, ValueError, TypeError):
961
956
  try:
962
- h_count = int(atom.GetTotalNumHs())
957
+ h_count = int(atom.GetNumImplicitHs())
963
958
  except (AttributeError, RuntimeError, ValueError, TypeError):
964
959
  h_count = 0
965
960
 
@@ -152,8 +152,11 @@ class IOManager:
152
152
 
153
153
  def _mol_from_xyz_lines(self, raw_lines: list[str]) -> Any:
154
154
  """Create an RDKit molecule from XYZ text lines."""
155
- lines = [ln.strip() for ln in raw_lines if not ln.strip().startswith("#")]
156
- while lines and not lines[0]:
155
+ lines = [ln.strip() for ln in raw_lines]
156
+ # Only leading comments are metadata. In a headed XYZ the second line is
157
+ # the title and may itself begin with '#', so discarding every '#' line
158
+ # would shift the atom rows up one and silently drop the first atom.
159
+ while lines and (not lines[0] or lines[0].startswith("#")):
157
160
  lines.pop(0)
158
161
 
159
162
  if not lines:
@@ -167,9 +170,11 @@ class IOManager:
167
170
  if num_atoms == 0:
168
171
  raise ValueError("XYZ file has zero atoms")
169
172
  except ValueError as exc:
170
- # Not a standard headed XYZ — treat all lines as atom rows
173
+ # Not a standard headed XYZ — treat all lines as atom rows. There is
174
+ # no title line here, so interleaved comments are safe to drop.
171
175
  if "zero atoms" in str(exc) or "too few" in str(exc):
172
176
  raise
177
+ lines = [ln for ln in lines if ln and not ln.startswith("#")]
173
178
  num_atoms = len(lines)
174
179
  atom_start = 0
175
180
 
@@ -442,6 +447,7 @@ class IOManager:
442
447
  num_atoms = mol.GetNumAtoms()
443
448
  bonds_added = []
444
449
 
450
+ candidates = []
445
451
  for i in range(num_atoms):
446
452
  for j in range(i + 1, num_atoms):
447
453
  atom_i = mol.GetAtomWithIdx(i)
@@ -456,21 +462,27 @@ class IOManager:
456
462
  expected = radius_i + radius_j
457
463
  tolerance = 1.2 if (symbol_i == "H" or symbol_j == "H") else 1.3
458
464
  if expected * 0.5 <= distance <= expected * tolerance:
459
- if mol.GetBondBetweenAtoms(i, j) is None:
460
- if (
461
- symbol_i == "H" and mol.GetAtomWithIdx(i).GetDegree() >= 1
462
- ) or (
463
- symbol_j == "H" and mol.GetAtomWithIdx(j).GetDegree() >= 1
464
- ):
465
- continue
466
- try:
467
- mol.AddBond(i, j, Chem.BondType.SINGLE)
468
- bonds_added.append((i, j, distance))
469
- except (RuntimeError, ValueError, TypeError):
470
- # Safe defensive fallback catching RuntimeError, ValueError, TypeError
471
- logging.debug(
472
- "Suppressed non-critical error", exc_info=True
473
- )
465
+ candidates.append((distance, i, j, symbol_i, symbol_j))
466
+
467
+ # Nearest pairs first. The monovalent-H rule below accepts whichever
468
+ # partner it sees first, so index order would otherwise decide the
469
+ # connectivity and the same geometry could bond an H differently
470
+ # depending only on how the file happened to list its atoms.
471
+ candidates.sort(key=lambda c: c[0])
472
+
473
+ for distance, i, j, symbol_i, symbol_j in candidates:
474
+ if mol.GetBondBetweenAtoms(i, j) is not None:
475
+ continue
476
+ if (symbol_i == "H" and mol.GetAtomWithIdx(i).GetDegree() >= 1) or (
477
+ symbol_j == "H" and mol.GetAtomWithIdx(j).GetDegree() >= 1
478
+ ):
479
+ continue
480
+ try:
481
+ mol.AddBond(i, j, Chem.BondType.SINGLE)
482
+ bonds_added.append((i, j, distance))
483
+ except (RuntimeError, ValueError, TypeError):
484
+ # Safe defensive fallback catching RuntimeError, ValueError, TypeError
485
+ logging.debug("Suppressed non-critical error", exc_info=True)
474
486
 
475
487
  return len(bonds_added)
476
488
 
@@ -816,6 +828,7 @@ class IOManager:
816
828
  atom.GetSymbol(),
817
829
  QPointF(scene_x, scene_y),
818
830
  charge=atom.GetFormalCharge(),
831
+ radical=atom.GetNumRadicalElectrons(),
819
832
  )
820
833
  rdkit_idx_to_my_id[i] = atom_id
821
834
 
@@ -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
 
@@ -117,10 +117,10 @@ class SettingsOtherTab(SettingsTabBase):
117
117
 
118
118
  self.log_to_file_checkbox = QCheckBox()
119
119
  self.log_to_file_checkbox.setToolTip(
120
- "Save application log to ~/.moleditpy/moleditpy_linux.log (rotated, max 1 MB × 3)."
120
+ "Save application log to ~/.moleditpy/moleditpy.log (rotated, max 1 MB × 3)."
121
121
  )
122
122
  form_layout.addRow(
123
- "Save log to file (~/.moleditpy/moleditpy_linux.log):", self.log_to_file_checkbox
123
+ "Save log to file (~/.moleditpy/moleditpy.log):", self.log_to_file_checkbox
124
124
  )
125
125
 
126
126
  self.log_level_debug_checkbox = QCheckBox()
@@ -119,6 +119,7 @@ class StringImporterManager:
119
119
  atom.GetSymbol(),
120
120
  QPointF(scene_x, scene_y),
121
121
  charge=atom.GetFormalCharge(),
122
+ radical=atom.GetNumRadicalElectrons(),
122
123
  )
123
124
  rdkit_idx_to_my_id[i] = atom_id
124
125
  return rdkit_idx_to_my_id
@@ -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