MoleditPy-linux 4.5.2__tar.gz → 4.6.1__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 (82) hide show
  1. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/PKG-INFO +2 -1
  2. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/README.md +1 -0
  3. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/pyproject.toml +1 -1
  4. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/MoleditPy_linux.egg-info/PKG-INFO +2 -1
  5. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/MoleditPy_linux.egg-info/SOURCES.txt +1 -0
  6. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/atom_item.py +16 -3
  7. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/bond_item.py +18 -4
  8. moleditpy_linux-4.6.1/src/moleditpy_linux/ui/chain_mixin.py +178 -0
  9. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/main_window_init.py +64 -0
  10. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/molecular_scene_handler.py +18 -2
  11. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/molecule_scene.py +33 -3
  12. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +4 -1
  13. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/template_preview_item.py +62 -1
  14. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/ui_manager.py +13 -1
  15. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/zoomable_view.py +1 -1
  16. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/LICENSE +0 -0
  17. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/setup.cfg +0 -0
  18. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  19. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  20. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/MoleditPy_linux.egg-info/requires.txt +0 -0
  21. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  22. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/__init__.py +0 -0
  23. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/__main__.py +0 -0
  24. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/assets/file_icon.ico +0 -0
  25. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/assets/icon.icns +0 -0
  26. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/assets/icon.ico +0 -0
  27. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/assets/icon.png +0 -0
  28. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/core/__init__.py +0 -0
  29. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/core/mol_geometry.py +0 -0
  30. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/core/molecular_data.py +0 -0
  31. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/main.py +0 -0
  32. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/plugins/__init__.py +0 -0
  33. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/plugins/plugin_interface.py +0 -0
  34. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/plugins/plugin_manager.py +0 -0
  35. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
  36. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/__init__.py +0 -0
  37. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/about_dialog.py +0 -0
  38. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/align_plane_dialog.py +0 -0
  39. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/alignment_dialog.py +0 -0
  40. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/analysis_window.py +0 -0
  41. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/angle_dialog.py +0 -0
  42. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/app_state.py +0 -0
  43. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/atom_picking.py +0 -0
  44. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/base_picking_dialog.py +0 -0
  45. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/bond_length_dialog.py +0 -0
  46. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/calculation_worker.py +0 -0
  47. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
  48. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/compute_logic.py +0 -0
  49. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +0 -0
  50. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/custom_interactor_style.py +0 -0
  51. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
  52. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
  53. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/dialog_logic.py +0 -0
  54. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/dihedral_dialog.py +0 -0
  55. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/edit_3d_logic.py +0 -0
  56. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/edit_actions_logic.py +0 -0
  57. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/export_logic.py +0 -0
  58. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/geometry_base_dialog.py +0 -0
  59. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/io_logic.py +0 -0
  60. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/main_window.py +0 -0
  61. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/mirror_dialog.py +0 -0
  62. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/move_group_dialog.py +0 -0
  63. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +0 -0
  64. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
  65. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/planarize_dialog.py +0 -0
  66. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/plugin_menu_manager.py +0 -0
  67. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
  68. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
  69. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
  70. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +0 -0
  71. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
  72. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/string_importers.py +0 -0
  73. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/template_preview_view.py +0 -0
  74. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/translation_dialog.py +0 -0
  75. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/user_template_dialog.py +0 -0
  76. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/ui/view_3d_logic.py +0 -0
  77. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/utils/__init__.py +0 -0
  78. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/utils/constants.py +0 -0
  79. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/utils/default_settings.py +0 -0
  80. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
  81. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/utils/suppress_log.py +0 -0
  82. {moleditpy_linux-4.5.2 → moleditpy_linux-4.6.1}/src/moleditpy_linux/utils/system_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy-linux
3
- Version: 4.5.2
3
+ Version: 4.6.1
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
@@ -742,6 +742,7 @@ This application combines a modern GUI built with **PyQt6**, powerful cheminform
742
742
 
743
743
  * **Intuitive Operations:** Easily add, edit, and delete atoms and bonds with simple mouse controls. Left-click and drag to create, and right-click to delete.
744
744
  * **Advanced Templates:** Place templates for benzene or 3- to 9-membered rings with a live preview. Features intelligent logic to automatically adjust the double-bond configuration when fusing a benzene ring to an existing structure.
745
+ * **Alkyl Chain Tool:** Drag to draw a zigzag chain of any length — the bond length stays fixed, the chain axis snaps to 15° steps, and the number of carbons that will be added (`n`) is previewed next to the cursor.
745
746
  * **Charges & Radicals:** Quickly set formal charges (`+`/`-`) and radicals (`.`) on any atom using keyboard shortcuts or the toolbar.
746
747
  * **Full Element Support:** Select any element from a built-in periodic table dialog.
747
748
  * **Clipboard Operations:** Full support for `Cut` (Ctrl+X), `Copy` (Ctrl+C), and `Paste` (Ctrl+V) for molecular fragments.
@@ -39,6 +39,7 @@ This application combines a modern GUI built with **PyQt6**, powerful cheminform
39
39
 
40
40
  * **Intuitive Operations:** Easily add, edit, and delete atoms and bonds with simple mouse controls. Left-click and drag to create, and right-click to delete.
41
41
  * **Advanced Templates:** Place templates for benzene or 3- to 9-membered rings with a live preview. Features intelligent logic to automatically adjust the double-bond configuration when fusing a benzene ring to an existing structure.
42
+ * **Alkyl Chain Tool:** Drag to draw a zigzag chain of any length — the bond length stays fixed, the chain axis snaps to 15° steps, and the number of carbons that will be added (`n`) is previewed next to the cursor.
42
43
  * **Charges & Radicals:** Quickly set formal charges (`+`/`-`) and radicals (`.`) on any atom using keyboard shortcuts or the toolbar.
43
44
  * **Full Element Support:** Select any element from a built-in periodic table dialog.
44
45
  * **Clipboard Operations:** Full support for `Cut` (Ctrl+X), `Copy` (Ctrl+C), and `Paste` (Ctrl+V) for molecular fragments.
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "MoleditPy-linux"
7
7
 
8
- version = "4.5.2"
8
+ version = "4.6.1"
9
9
 
10
10
  license = {file = "LICENSE"}
11
11
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy-linux
3
- Version: 4.5.2
3
+ Version: 4.6.1
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
@@ -742,6 +742,7 @@ This application combines a modern GUI built with **PyQt6**, powerful cheminform
742
742
 
743
743
  * **Intuitive Operations:** Easily add, edit, and delete atoms and bonds with simple mouse controls. Left-click and drag to create, and right-click to delete.
744
744
  * **Advanced Templates:** Place templates for benzene or 3- to 9-membered rings with a live preview. Features intelligent logic to automatically adjust the double-bond configuration when fusing a benzene ring to an existing structure.
745
+ * **Alkyl Chain Tool:** Drag to draw a zigzag chain of any length — the bond length stays fixed, the chain axis snaps to 15° steps, and the number of carbons that will be added (`n`) is previewed next to the cursor.
745
746
  * **Charges & Radicals:** Quickly set formal charges (`+`/`-`) and radicals (`.`) on any atom using keyboard shortcuts or the toolbar.
746
747
  * **Full Element Support:** Select any element from a built-in periodic table dialog.
747
748
  * **Clipboard Operations:** Full support for `Cut` (Ctrl+X), `Copy` (Ctrl+C), and `Paste` (Ctrl+V) for molecular fragments.
@@ -34,6 +34,7 @@ src/moleditpy_linux/ui/base_picking_dialog.py
34
34
  src/moleditpy_linux/ui/bond_item.py
35
35
  src/moleditpy_linux/ui/bond_length_dialog.py
36
36
  src/moleditpy_linux/ui/calculation_worker.py
37
+ src/moleditpy_linux/ui/chain_mixin.py
37
38
  src/moleditpy_linux/ui/color_settings_dialog.py
38
39
  src/moleditpy_linux/ui/compute_logic.py
39
40
  src/moleditpy_linux/ui/constrained_optimization_dialog.py
@@ -34,7 +34,6 @@ from PyQt6.QtWidgets import (
34
34
  from ..utils.constants import (
35
35
  ATOM_RADIUS,
36
36
  CPK_COLORS,
37
- DESIRED_ATOM_PIXEL_RADIUS,
38
37
  FONT_FAMILY,
39
38
  FONT_WEIGHT_BOLD,
40
39
  )
@@ -332,7 +331,13 @@ class AtomItem(QGraphicsItem):
332
331
  return path
333
332
 
334
333
  def shape(self) -> QPainterPath:
335
- """Define the shape of the atom item for collision detection."""
334
+ """Define the shape of the atom item for collision detection.
335
+
336
+ The hit radius is derived from the ``bond_snapping_distance_2d``
337
+ setting so that hover highlight matches the snapping / keyboard
338
+ instant-switch zone exactly. The radius is expressed in screen
339
+ pixels and divided by the current view scale.
340
+ """
336
341
  scene = self.scene()
337
342
  if not scene or not scene.views():
338
343
  path = QPainterPath()
@@ -342,8 +347,16 @@ class AtomItem(QGraphicsItem):
342
347
 
343
348
  view = scene.views()[0]
344
349
  scale = view.transform().m11()
350
+ if scale <= 0.0:
351
+ scale = 1.0
345
352
 
346
- scene_radius = DESIRED_ATOM_PIXEL_RADIUS / scale
353
+ # Use the same setting that find_atom_near() uses for snapping,
354
+ # so hover highlight and snap zones are identical.
355
+ if hasattr(scene, "get_setting"):
356
+ hit_px = scene.get_setting("bond_snapping_distance_2d", 14.0)
357
+ else:
358
+ hit_px = 14.0
359
+ scene_radius = hit_px / scale
347
360
 
348
361
  path = QPainterPath()
349
362
  path.addEllipse(QPointF(0, 0), scene_radius, scene_radius)
@@ -36,7 +36,6 @@ from PyQt6.QtWidgets import (
36
36
  )
37
37
 
38
38
  from ..utils.constants import (
39
- DESIRED_BOND_PIXEL_WIDTH,
40
39
  EZ_LABEL_BOX_SIZE,
41
40
  EZ_LABEL_MARGIN,
42
41
  EZ_LABEL_TEXT_OUTLINE,
@@ -293,10 +292,25 @@ class BondItem(QGraphicsItem):
293
292
  path.moveTo(line.p1())
294
293
  path.lineTo(line.p2())
295
294
 
296
- # Stroke it to give it some width (e.g., 10px or dynamic based on settings) generally easier to click
297
- # even if the visual width is smaller.
295
+ # Use the exact same setting as atom hover highlight and snapping, zoom-aware.
296
+ scene = self.scene()
297
+ if scene and scene.views():
298
+ view = scene.views()[0]
299
+ scale = view.transform().m11()
300
+ if scale <= 0.0:
301
+ scale = 1.0
302
+ if hasattr(scene, "get_setting"):
303
+ hit_px = scene.get_setting("bond_snapping_distance_2d", 14.0)
304
+ else:
305
+ hit_px = 14.0
306
+ # width is diameter, so 2 * radius
307
+ scene_width = (hit_px * 2) / scale
308
+ else:
309
+ scene_width = 28.0
310
+
311
+ # Stroke it to give it some width
298
312
  stroker = QPainterPathStroker()
299
- stroker.setWidth(DESIRED_BOND_PIXEL_WIDTH) # Use constant (20.0)
313
+ stroker.setWidth(scene_width)
300
314
  path = stroker.createStroke(path)
301
315
 
302
316
  # If there's an E/Z label, add its rect to the selection shape
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ MoleditPy — A Python-based molecular editing software
6
+
7
+ Author: Hiromichi Yokoyama
8
+ License: GPL-3.0 license
9
+ Repo: https://github.com/HiroYokoyama/python_molecular_editor
10
+ DOI: 10.5281/zenodo.17268532
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ from typing import Any, List, Optional, Tuple
17
+
18
+ from PyQt6.QtCore import QPointF
19
+
20
+ from .atom_item import AtomItem
21
+ from ..utils.constants import DEFAULT_BOND_LENGTH
22
+
23
+ # Half of the 120 deg zigzag angle, measured from the chain axis.
24
+ CHAIN_HALF_ANGLE_DEG = 30.0
25
+ CHAIN_ANGLE_SNAP_DEG = 15.0
26
+ MAX_CHAIN_LENGTH = 100
27
+ # Reject an end snap that would collapse the tail bond onto its neighbour.
28
+ MIN_TAIL_BOND_RATIO = 0.4
29
+
30
+
31
+ class ChainMixin:
32
+ """Drag-to-draw tool that grows a fixed-bond-length zigzag alkyl chain."""
33
+
34
+ add_molecule_fragment: Any
35
+ current_atom_symbol: Any
36
+ find_atom_near: Any
37
+ get_setting: Any
38
+ template_preview: Any
39
+
40
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
41
+ self.chain_anchor: Optional[QPointF] = None
42
+ self.chain_start_atom: Optional[AtomItem] = None
43
+ self.chain_end_atom: Optional[AtomItem] = None
44
+ self.chain_points: List[QPointF] = []
45
+ self.chain_active: bool = False
46
+ super().__init__(*args, **kwargs)
47
+
48
+ def begin_chain(self, pos: QPointF, start_atom: Optional[AtomItem] = None) -> None:
49
+ """Anchor a new chain drag at ``pos`` (or at ``start_atom`` if given)."""
50
+ self.chain_start_atom = start_atom
51
+ self.chain_end_atom = None
52
+ self.chain_anchor = start_atom.pos() if start_atom is not None else QPointF(pos)
53
+ self.chain_points = []
54
+ self.chain_active = True
55
+
56
+ def chain_geometry(
57
+ self, cursor: QPointF
58
+ ) -> Tuple[List[QPointF], Optional[AtomItem]]:
59
+ """Zigzag for this cursor, with the last vertex pulled onto a nearby atom.
60
+
61
+ Every other bond keeps the fixed length, so the tail bond absorbs the
62
+ whole difference and may end up longer or shorter. Both ends of the drag
63
+ use the shared 2D bond snapping distance, as bond drawing does.
64
+ """
65
+ if self.chain_anchor is None:
66
+ return [], None
67
+
68
+ points = self.calculate_chain_points(self.chain_anchor, cursor)
69
+ if len(points) < 2:
70
+ return points, None
71
+
72
+ snap_dist = self.get_setting("bond_snapping_distance_2d", 14.0)
73
+ target = self.find_atom_near(cursor, tol=snap_dist)
74
+ if target is None or target is self.chain_start_atom:
75
+ return points, None
76
+
77
+ end = target.pos()
78
+ prev = points[-2]
79
+ tail = math.hypot(end.x() - prev.x(), end.y() - prev.y())
80
+ if tail < MIN_TAIL_BOND_RATIO * DEFAULT_BOND_LENGTH:
81
+ return points, None
82
+
83
+ points[-1] = end
84
+ return points, target
85
+
86
+ def calculate_chain_points(self, anchor: QPointF, cursor: QPointF) -> List[QPointF]:
87
+ """Return the zigzag vertices from ``anchor`` toward ``cursor``.
88
+
89
+ The chain axis is snapped to 15 deg steps, every bond keeps the standard
90
+ 2D bond length, and the number of bonds follows the drag distance
91
+ projected onto the axis.
92
+ """
93
+ vx = cursor.x() - anchor.x()
94
+ vy = cursor.y() - anchor.y()
95
+
96
+ snap = math.radians(CHAIN_ANGLE_SNAP_DEG)
97
+ axis_angle = round(math.atan2(vy, vx) / snap) * snap
98
+ ax, ay = math.cos(axis_angle), math.sin(axis_angle)
99
+
100
+ half = math.radians(CHAIN_HALF_ANGLE_DEG)
101
+ step = DEFAULT_BOND_LENGTH * math.cos(half)
102
+ offset = DEFAULT_BOND_LENGTH * math.sin(half)
103
+
104
+ count = int(round((vx * ax + vy * ay) / step))
105
+ if count < 1:
106
+ return []
107
+ count = min(count, MAX_CHAIN_LENGTH)
108
+
109
+ # Zigzag bulges toward the side of the axis the cursor drifted to.
110
+ side = -1.0 if (ax * vy - ay * vx) < 0 else 1.0
111
+ px, py = -ay * offset * side, ax * offset * side
112
+
113
+ points = [QPointF(anchor)]
114
+ for k in range(1, count + 1):
115
+ wobble = 1.0 if k % 2 else 0.0
116
+ points.append(
117
+ QPointF(
118
+ anchor.x() + ax * step * k + px * wobble,
119
+ anchor.y() + ay * step * k + py * wobble,
120
+ )
121
+ )
122
+ return points
123
+
124
+ def update_chain_preview(self, pos: QPointF) -> None:
125
+ """Refresh the ghost chain and the repeat-count label near the cursor."""
126
+ if not self.chain_active or self.chain_anchor is None:
127
+ return
128
+
129
+ self.chain_points, self.chain_end_atom = self.chain_geometry(pos)
130
+ if len(self.chain_points) < 2:
131
+ self.template_preview.hide()
132
+ return
133
+
134
+ self.template_preview.set_chain_geometry(
135
+ self.chain_points,
136
+ f"n = {self.new_atom_count()}",
137
+ QPointF(pos.x() + 16, pos.y() - 12),
138
+ )
139
+ self.template_preview.show()
140
+
141
+ def new_atom_count(self) -> int:
142
+ """Atoms the previewed chain would add; reused end atoms do not count."""
143
+ if not self.chain_points:
144
+ return 0
145
+ reused = sum(
146
+ 1
147
+ for atom in (self.chain_start_atom, self.chain_end_atom)
148
+ if atom is not None
149
+ )
150
+ return len(self.chain_points) - reused
151
+
152
+ def clear_chain_preview(self) -> None:
153
+ """Hide the ghost chain and drop the drag state."""
154
+ self.chain_anchor = None
155
+ self.chain_start_atom = None
156
+ self.chain_end_atom = None
157
+ self.chain_points = []
158
+ self.chain_active = False
159
+ self.template_preview.hide()
160
+
161
+ def commit_chain(self, pos: QPointF) -> bool:
162
+ """Materialize the previewed chain as atoms and bonds. True if anything was added."""
163
+ if self.chain_anchor is None:
164
+ return False
165
+
166
+ points, end_atom = self.chain_geometry(pos)
167
+ if len(points) < 2:
168
+ return False
169
+
170
+ bonds_info = [(i, i + 1, 1) for i in range(len(points) - 1)]
171
+ # Naming both reused atoms fuses them even with template fusing turned off.
172
+ existing = [
173
+ atom for atom in (self.chain_start_atom, end_atom) if atom is not None
174
+ ]
175
+ self.add_molecule_fragment(
176
+ points, bonds_info, existing_items=existing, symbol=self.current_atom_symbol
177
+ )
178
+ return True
@@ -873,6 +873,17 @@ class MainInitManager:
873
873
  self.tool_group.addAction(action) # type: ignore[union-attr]
874
874
  toolbar.addSeparator()
875
875
 
876
+ def _add_chain_action(self, toolbar: QToolBar) -> None:
877
+ """Add the drag-to-draw alkyl chain action."""
878
+ action = QAction(self.host)
879
+ action.setIcon(self._create_chain_icon())
880
+ action.setToolTip("Alkyl Chain — drag to draw a zigzag chain")
881
+ action.setCheckable(True)
882
+ action.triggered.connect(lambda checked: self.host.ui_manager.set_mode("chain"))
883
+ self.mode_actions["chain"] = action
884
+ toolbar.addAction(action)
885
+ self.tool_group.addAction(action) # type: ignore[union-attr]
886
+
876
887
  def _add_charge_radical_actions(self, toolbar: QToolBar) -> None:
877
888
  """Add charge and radical modification actions."""
878
889
  ops = [
@@ -910,6 +921,8 @@ class MainInitManager:
910
921
  toolbar_bottom.addAction(action)
911
922
  self.tool_group.addAction(action) # type: ignore[union-attr]
912
923
 
924
+ self._add_chain_action(toolbar_bottom)
925
+
913
926
  user_action = QAction("USER", self.host)
914
927
  user_action.setCheckable(True)
915
928
  user_action.setToolTip("Open User Templates Dialog")
@@ -1039,6 +1052,57 @@ class MainInitManager:
1039
1052
  painter.end()
1040
1053
  return QIcon(pixmap)
1041
1054
 
1055
+ def _create_chain_icon(self, size: int = 32) -> QIcon:
1056
+ """Generate the -(-)n- repeat-unit icon for the alkyl chain tool."""
1057
+ fg = self._get_icon_foreground_color()
1058
+ pixmap = QPixmap(size, size)
1059
+ pixmap.fill(Qt.GlobalColor.transparent)
1060
+ painter = QPainter(pixmap)
1061
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
1062
+ painter.setPen(QPen(fg, max(1.8, size / 16.0)))
1063
+ painter.setBrush(Qt.BrushStyle.NoBrush)
1064
+
1065
+ cy = size * 0.375
1066
+ amp = size * 0.17
1067
+ # Backbone zigzag spans the full width so the repeat brackets cross it.
1068
+ zig = [
1069
+ QPointF(1 + i * (size - 2) / 2.0, cy + (amp if i % 2 == 0 else -amp))
1070
+ for i in range(3)
1071
+ ]
1072
+ for start, end in zip(zig, zig[1:]):
1073
+ painter.drawLine(start, end)
1074
+
1075
+ painter.setPen(QPen(fg, max(1.5, size / 20.0)))
1076
+ bracket_h = size * 0.55
1077
+ bracket_w = size * 0.19
1078
+ inset = size * 0.13
1079
+ painter.drawArc(
1080
+ QRectF(inset, cy - bracket_h / 2, bracket_w, bracket_h), 118 * 16, 124 * 16
1081
+ )
1082
+ painter.drawArc(
1083
+ QRectF(size - inset - bracket_w, cy - bracket_h / 2, bracket_w, bracket_h),
1084
+ -62 * 16,
1085
+ 124 * 16,
1086
+ )
1087
+
1088
+ font = painter.font()
1089
+ font.setPixelSize(max(8, int(size * 0.30)))
1090
+ font.setBold(True)
1091
+ painter.setFont(font)
1092
+ painter.drawText(
1093
+ QRectF(
1094
+ size - size * 0.26,
1095
+ cy + bracket_h / 2 - size * 0.06,
1096
+ size * 0.24,
1097
+ size * 0.36,
1098
+ ),
1099
+ Qt.AlignmentFlag.AlignCenter,
1100
+ "n",
1101
+ )
1102
+
1103
+ painter.end()
1104
+ return QIcon(pixmap)
1105
+
1042
1106
  def _create_template_icon(
1043
1107
  self, n: int, is_benzene: bool = False, size: int = 32
1044
1108
  ) -> QIcon:
@@ -856,7 +856,10 @@ class KeyboardMixin:
856
856
  return
857
857
 
858
858
  view = self.views()[0]
859
- cursor_pos = view.mapToScene(view.mapFromGlobal(QCursor.pos()))
859
+ # mapToScene expects viewport coordinates. Mapping to the view widget directly
860
+ # includes the widget's frame margins, introducing a cursor offset that gets
861
+ # magnified when zooming.
862
+ cursor_pos = view.mapToScene(view.viewport().mapFromGlobal(QCursor.pos()))
860
863
  transform = view.transform()
861
864
  key = event.key()
862
865
  modifiers = event.modifiers()
@@ -1587,9 +1590,22 @@ class SceneQueryMixin:
1587
1590
  return False
1588
1591
 
1589
1592
  def find_atom_near(self, pos: Any, tol: float = 14.0) -> Any:
1590
- """Return the AtomItem within tolerance distance of pos, or None."""
1593
+ """Return the AtomItem within tolerance distance of pos, or None.
1594
+
1595
+ ``tol`` is specified in **screen pixels**. It is divided by the
1596
+ current view scale so that the effective snap radius stays constant
1597
+ on screen regardless of zoom level — matching the behaviour of
1598
+ ``AtomItem.shape()`` (hover highlight).
1599
+ """
1591
1600
  if pos is None:
1592
1601
  return None
1602
+ # Convert screen-pixel tolerance to scene units (zoom-aware).
1603
+ if self.views():
1604
+ xform = self.views()[0].transform()
1605
+ if xform is not None:
1606
+ scale = xform.m11()
1607
+ if scale > 0:
1608
+ tol = tol / scale
1593
1609
  # Create a small search rectangle around the position
1594
1610
  search_rect = QRectF(pos.x() - tol, pos.y() - tol, 2 * tol, 2 * tol)
1595
1611
  nearby_items = self.items(search_rect)
@@ -36,9 +36,12 @@ from .template_preview_item import TemplatePreviewItem
36
36
  from ..utils.sip_isdeleted_safe import sip_isdeleted_safe
37
37
 
38
38
  from .molecular_scene_handler import TemplateMixin, KeyboardMixin, SceneQueryMixin
39
+ from .chain_mixin import ChainMixin
39
40
 
40
41
 
41
- class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScene):
42
+ class MoleculeScene(
43
+ ChainMixin, TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScene
44
+ ):
42
45
  """Central QGraphicsScene that owns all 2D atom and bond items."""
43
46
 
44
47
  def __init__(self, data: MolecularData, window: MainWindow) -> None:
@@ -433,6 +436,15 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
433
436
  event.accept()
434
437
  return # Complete right-click processing and prevent proceeding to left-click logic
435
438
 
439
+ if self.mode == "chain":
440
+ self.clearSelection()
441
+ snap_dist = self.get_setting("bond_snapping_distance_2d", 14.0)
442
+ self.begin_chain(
443
+ self.press_pos, self.find_atom_near(self.press_pos, tol=snap_dist)
444
+ )
445
+ event.accept()
446
+ return
447
+
436
448
  if self.mode.startswith("template"):
437
449
  self.clearSelection() # In template mode, no selection is made; only the click position is recorded.
438
450
  return
@@ -487,6 +499,10 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
487
499
  if self.mode.startswith("template"):
488
500
  self.update_template_preview(event.scenePos())
489
501
 
502
+ if self.mode == "chain" and self.chain_active:
503
+ self.update_chain_preview(event.scenePos())
504
+ return
505
+
490
506
  if not self.mouse_moved_since_press and self.press_pos:
491
507
  if (
492
508
  event.scenePos() - self.press_pos
@@ -544,6 +560,20 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
544
560
 
545
561
  self.temp_line = None
546
562
 
563
+ if self.mode == "chain":
564
+ committed = False
565
+ if self.chain_active:
566
+ committed = self.commit_chain(end_pos)
567
+ self.clear_chain_preview()
568
+ self.start_atom = None
569
+ self.start_pos = None
570
+ self.press_pos = None
571
+ if committed:
572
+ self.data_changed_in_event = True
573
+ self.update_all_items()
574
+ self.window.edit_actions_manager.push_undo_state()
575
+ return
576
+
547
577
  if self.mode.startswith("template") and is_click:
548
578
  if self.template_context and self.template_context.get("points"):
549
579
  context = self.template_context
@@ -972,8 +1002,8 @@ class MoleculeScene(TemplateMixin, KeyboardMixin, SceneQueryMixin, QGraphicsScen
972
1002
  # Find the active view for this scene
973
1003
  for view in self.views():
974
1004
  if view.isVisible():
975
- # Map global cursor position to scene coordinates
976
- local_pos = view.mapFromGlobal(global_pos)
1005
+ # Map global cursor position to scene coordinates (via viewport)
1006
+ local_pos = view.viewport().mapFromGlobal(global_pos)
977
1007
  scene_pos = view.mapToScene(local_pos)
978
1008
 
979
1009
  # If the mouse is within the viewport, trigger the preview update
@@ -198,7 +198,10 @@ class Settings2DTab(SettingsTabBase):
198
198
  self._create_slider(5, 50, 1.0, is_int=True)
199
199
  )
200
200
  self.bond_snapping_distance_2d_slider.setToolTip(
201
- "The distance in pixels within which drawing a bond will snap to an existing atom."
201
+ "The distance in screen pixels (zoom-independent) within which:\n"
202
+ "• Drawing a bond snaps to an existing atom\n"
203
+ "• Keyboard shortcuts instantly edit the atom under cursor\n"
204
+ "• Atoms show hover highlight"
202
205
  )
203
206
  form_layout.addRow(
204
207
  "Bond Snapping Distance (px):",
@@ -34,6 +34,10 @@ class TemplatePreviewItem(QGraphicsItem):
34
34
  self.user_template_bonds: List[Any] = []
35
35
  self.user_template_atoms: List[Any] = []
36
36
  self.is_user_template = False
37
+ self.chain_points: List[QPointF] = []
38
+ self.chain_label = ""
39
+ self.chain_label_pos = QPointF()
40
+ self.is_chain = False
37
41
 
38
42
  def set_geometry(self, points: list[QPointF], is_aromatic: bool = False) -> None:
39
43
  """Set polygon points for a standard ring template preview."""
@@ -41,6 +45,21 @@ class TemplatePreviewItem(QGraphicsItem):
41
45
  self.polygon = QPolygonF(points)
42
46
  self.is_aromatic = is_aromatic
43
47
  self.is_user_template = False
48
+ self.is_chain = False
49
+ self.update()
50
+
51
+ def set_chain_geometry(
52
+ self, points: list[QPointF], label: str, label_pos: QPointF
53
+ ) -> None:
54
+ """Set the zigzag vertices and the repeat-count label for a chain preview."""
55
+ self.prepareGeometryChange()
56
+ self.chain_points = list(points)
57
+ self.chain_label = label
58
+ self.chain_label_pos = QPointF(label_pos)
59
+ self.is_chain = True
60
+ self.is_user_template = False
61
+ self.is_aromatic = False
62
+ self.polygon = QPolygonF()
44
63
  self.update()
45
64
 
46
65
  def set_user_template_geometry(
@@ -55,12 +74,22 @@ class TemplatePreviewItem(QGraphicsItem):
55
74
  self.user_template_bonds = bonds_info
56
75
  self.user_template_atoms = atoms_data
57
76
  self.is_user_template = True
77
+ self.is_chain = False
58
78
  self.is_aromatic = False
59
79
  self.polygon = QPolygonF()
60
80
  self.update()
61
81
 
62
82
  def boundingRect(self) -> QRectF:
63
83
  """Return the bounding rect encompassing the preview geometry."""
84
+ if self.is_chain and self.chain_points:
85
+ xs = [p.x() for p in self.chain_points] + [self.chain_label_pos.x()]
86
+ ys = [p.y() for p in self.chain_points] + [self.chain_label_pos.y()]
87
+ return QRectF(
88
+ min(xs) - 10,
89
+ min(ys) - 20,
90
+ max(xs) - min(xs) + 90,
91
+ max(ys) - min(ys) + 40,
92
+ )
64
93
  if self.is_user_template and self.user_template_points:
65
94
  # Calculate bounding rect for user template
66
95
  min_x = min(p.x() for p in self.user_template_points)
@@ -81,11 +110,43 @@ class TemplatePreviewItem(QGraphicsItem):
81
110
  """Dispatch to the regular or user-template painter."""
82
111
  if painter is None:
83
112
  return
84
- if self.is_user_template:
113
+ if self.is_chain:
114
+ self.paint_chain(painter)
115
+ elif self.is_user_template:
85
116
  self.paint_user_template(painter)
86
117
  else:
87
118
  self.paint_regular_template(painter)
88
119
 
120
+ def paint_chain(self, painter: QPainter) -> None:
121
+ """Paint the ghost zigzag chain plus the repeat count next to the cursor."""
122
+ if len(self.chain_points) < 2:
123
+ return
124
+
125
+ painter.setPen(QPen(QColor(80, 80, 80, 180), 2.5))
126
+ painter.setBrush(Qt.BrushStyle.NoBrush)
127
+ for i in range(len(self.chain_points) - 1):
128
+ painter.drawLine(QLineF(self.chain_points[i], self.chain_points[i + 1]))
129
+
130
+ dot = QColor(80, 80, 80, 140)
131
+ painter.setBrush(QBrush(dot))
132
+ painter.setPen(QPen(dot, 1))
133
+ for p in self.chain_points[1:]:
134
+ painter.drawEllipse(p, 2.5, 2.5)
135
+
136
+ if not self.chain_label:
137
+ return
138
+ font = QFont("Arial", 11, QFont.Weight.Bold)
139
+ painter.setFont(font)
140
+ metrics = painter.fontMetrics()
141
+ rect = QRectF(metrics.boundingRect(self.chain_label))
142
+ rect.moveTopLeft(self.chain_label_pos)
143
+ rect = rect.adjusted(-5, -3, 5, 3)
144
+ painter.setPen(QPen(QColor(80, 80, 80, 180), 1))
145
+ painter.setBrush(QBrush(QColor(255, 255, 255, 225)))
146
+ painter.drawRoundedRect(rect, 4, 4)
147
+ painter.setPen(QPen(QColor(40, 40, 40)))
148
+ painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, self.chain_label)
149
+
89
150
  def paint_regular_template(self, painter: QPainter) -> None:
90
151
  """Paint a standard ring or aromatic template as a polygon outline."""
91
152
  painter.setPen(self.pen)
@@ -62,6 +62,10 @@ class UIManager(QObject):
62
62
 
63
63
  # Trigger immediate scene refresh to show/update template previews
64
64
  self.host.init_manager.scene.refresh_mode_state()
65
+ # A shortcut key can switch modes mid-drag; drop the abandoned chain anchor.
66
+ if prev_mode == "chain" and mode_str != "chain":
67
+ self.host.init_manager.scene.clear_chain_preview()
68
+
65
69
  # Clear ghost when leaving template mode
66
70
  if (
67
71
  prev_mode
@@ -75,7 +79,7 @@ class UIManager(QObject):
75
79
  # Set cursor shape
76
80
  if mode_str == "select":
77
81
  self.host.init_manager.view_2d.setCursor(Qt.CursorShape.ArrowCursor)
78
- elif mode_str.startswith(("atom", "bond", "template")):
82
+ elif mode_str.startswith(("atom", "bond", "template", "chain")):
79
83
  self.host.init_manager.view_2d.setCursor(Qt.CursorShape.CrossCursor)
80
84
  elif mode_str.startswith(("charge", "radical")):
81
85
  self.host.init_manager.view_2d.setCursor(Qt.CursorShape.CrossCursor)
@@ -101,6 +105,14 @@ class UIManager(QObject):
101
105
  )
102
106
  self.host.init_manager.view_2d.setDragMode(QGraphicsView.DragMode.NoDrag)
103
107
  self.host.init_manager.view_2d.setMouseTracking(True)
108
+ elif mode_str == "chain":
109
+ self.host.set_scene_atom_symbol("C")
110
+ self.host.set_scene_bond_properties(1, 0)
111
+ self.host.update_status_message(
112
+ "Mode: Alkyl Chain (Drag to set the chain length)"
113
+ )
114
+ self.host.init_manager.view_2d.setDragMode(QGraphicsView.DragMode.NoDrag)
115
+ self.host.init_manager.view_2d.setMouseTracking(True)
104
116
  elif mode_str.startswith("template"):
105
117
  if mode_str.startswith("template_user"):
106
118
  # User template mode
@@ -113,7 +113,7 @@ class ZoomableView(QGraphicsView):
113
113
  current_mode = self.scene().mode if self.scene() else "select" # type: ignore[union-attr]
114
114
  if current_mode == "select":
115
115
  self.setCursor(Qt.CursorShape.ArrowCursor)
116
- elif current_mode.startswith(("atom", "bond", "template")):
116
+ elif current_mode.startswith(("atom", "bond", "template", "chain")):
117
117
  self.setCursor(Qt.CursorShape.CrossCursor)
118
118
  elif current_mode.startswith(("charge", "radical")):
119
119
  self.setCursor(Qt.CursorShape.CrossCursor)
File without changes