ms-components 0.3.3__tar.gz → 0.3.5__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 (134) hide show
  1. {ms_components-0.3.3/src/ms_components.egg-info → ms_components-0.3.5}/PKG-INFO +1 -1
  2. {ms_components-0.3.3 → ms_components-0.3.5}/docs/index.md +1 -1
  3. {ms_components-0.3.3 → ms_components-0.3.5}/docs/ms_tutorial.md +20 -0
  4. ms_components-0.3.5/docs/project_form_events.md +17 -0
  5. ms_components-0.3.5/src/ms_components/ms_projects/__init__.py +4 -0
  6. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_projects/browser.py +13 -3
  7. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_projects/widget.py +3 -0
  8. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/__init__.py +2 -0
  9. ms_components-0.3.5/src/ms_components/ms_pymol/icons/hydrogens-polar.svg +5 -0
  10. ms_components-0.3.5/src/ms_components/ms_pymol/icons/hydrogens.svg +4 -0
  11. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/widget.py +100 -2
  12. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/__init__.py +2 -0
  13. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/query_builder.py +68 -36
  14. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/smart_table_view.py +155 -57
  15. ms_components-0.3.5/src/ms_components/ms_table/table_interaction.py +42 -0
  16. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_tutorial/bindings.py +1 -0
  17. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_tutorial/controller.py +30 -6
  18. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_tutorial/overlay.py +18 -8
  19. {ms_components-0.3.3 → ms_components-0.3.5/src/ms_components.egg-info}/PKG-INFO +1 -1
  20. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components.egg-info/SOURCES.txt +5 -0
  21. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components.egg-info/scm_file_list.json +5 -0
  22. ms_components-0.3.5/src/ms_components.egg-info/scm_version.json +8 -0
  23. {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_ms_projects_widget.py +45 -0
  24. {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_opengl.py +2 -1
  25. {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_pymol_grid.py +78 -0
  26. {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_pymol_toolbar.py +57 -1
  27. {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_query_builder_keyset.py +79 -19
  28. ms_components-0.3.5/tests/test_table_interaction.py +58 -0
  29. {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_tutorial.py +80 -4
  30. ms_components-0.3.3/src/ms_components/ms_projects/__init__.py +0 -4
  31. ms_components-0.3.3/src/ms_components.egg-info/scm_version.json +0 -8
  32. {ms_components-0.3.3 → ms_components-0.3.5}/.github/workflows/docs.yml +0 -0
  33. {ms_components-0.3.3 → ms_components-0.3.5}/.github/workflows/publish.yml +0 -0
  34. {ms_components-0.3.3 → ms_components-0.3.5}/.gitignore +0 -0
  35. {ms_components-0.3.3 → ms_components-0.3.5}/LICENSE +0 -0
  36. {ms_components-0.3.3 → ms_components-0.3.5}/README.md +0 -0
  37. {ms_components-0.3.3 → ms_components-0.3.5}/docs/api_reference.md +0 -0
  38. {ms_components-0.3.3 → ms_components-0.3.5}/docs/examples.md +0 -0
  39. {ms_components-0.3.3 → ms_components-0.3.5}/docs/usage.md +0 -0
  40. {ms_components-0.3.3 → ms_components-0.3.5}/examples/tutorial_demo.py +0 -0
  41. {ms_components-0.3.3 → ms_components-0.3.5}/examples/tutorial_demo.toml +0 -0
  42. {ms_components-0.3.3 → ms_components-0.3.5}/pyproject.toml +0 -0
  43. {ms_components-0.3.3 → ms_components-0.3.5}/setup.cfg +0 -0
  44. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/__init__.py +0 -0
  45. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/backdrop.py +0 -0
  46. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/base.qss +0 -0
  47. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/chips.py +0 -0
  48. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/listview.py +0 -0
  49. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_dockwidget/__init__.py +0 -0
  50. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_dockwidget/resources/__init__.py +0 -0
  51. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_dockwidget/resources/icons/__init__.py +0 -0
  52. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_dockwidget/resources/icons/folder.svg +0 -0
  53. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_dockwidget/widget.py +0 -0
  54. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_executors.py +0 -0
  55. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_monitor/__init__.py +0 -0
  56. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_monitor/bridge.py +0 -0
  57. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_monitor/cards.py +0 -0
  58. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_monitor/config.py +0 -0
  59. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_monitor/detail_panel.py +0 -0
  60. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_monitor/formatting.py +0 -0
  61. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_monitor/job_cards.py +0 -0
  62. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_monitor/models.py +0 -0
  63. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_monitor/palette.py +0 -0
  64. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_monitor/polling.py +0 -0
  65. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_monitor/widget.py +0 -0
  66. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_projects/app.py +0 -0
  67. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_projects/scratch.py +0 -0
  68. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/actions.svg +0 -0
  69. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/box.svg +0 -0
  70. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/center.svg +0 -0
  71. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/display.svg +0 -0
  72. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/hide.svg +0 -0
  73. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/menu.svg +0 -0
  74. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/orient.svg +0 -0
  75. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/presets.svg +0 -0
  76. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/rock.svg +0 -0
  77. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/selection.svg +0 -0
  78. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/settings.svg +0 -0
  79. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/zoom.svg +0 -0
  80. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/plugins.py +0 -0
  81. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_settings/__init__.py +0 -0
  82. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_settings/panel.py +0 -0
  83. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_stepper.py +0 -0
  84. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/action_header.py +0 -0
  85. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/cell_delegate.py +0 -0
  86. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/column_panel.py +0 -0
  87. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/example_usage.py +0 -0
  88. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/filter_panel.py +0 -0
  89. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/icons/columns.svg +0 -0
  90. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/icons/export.svg +0 -0
  91. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/icons/filter.svg +0 -0
  92. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/icons/menu.svg +0 -0
  93. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/icons/reload.svg +0 -0
  94. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/icons/search.svg +0 -0
  95. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/icons/settings.svg +0 -0
  96. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/icons/sort-asc.svg +0 -0
  97. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/icons/sort-desc.svg +0 -0
  98. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/icons/sort.svg +0 -0
  99. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/metric_filter_bar.py +0 -0
  100. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/pagination_control.py +0 -0
  101. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/session_provider.py +0 -0
  102. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/sort_panel.py +0 -0
  103. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/table_config.py +0 -0
  104. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/table_model.py +0 -0
  105. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/test_external_filters.py +0 -0
  106. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/test_toolbar.py +0 -0
  107. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_tutorial/__init__.py +0 -0
  108. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_tutorial/models.py +0 -0
  109. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/opengl.py +0 -0
  110. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/pagination.py +0 -0
  111. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/palettes.py +0 -0
  112. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/step_dialog.py +0 -0
  113. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/theme.py +0 -0
  114. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/tool_panel.py +0 -0
  115. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/wheel.py +0 -0
  116. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components.egg-info/dependency_links.txt +0 -0
  117. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components.egg-info/requires.txt +0 -0
  118. {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components.egg-info/top_level.txt +0 -0
  119. {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_app_settings.py +0 -0
  120. {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_chips.py +0 -0
  121. {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_dock_window.py +0 -0
  122. {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_executors_ui.py +0 -0
  123. {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_molsuite_monitor_bridge.py +0 -0
  124. {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_molsuite_monitor_widgets.py +0 -0
  125. {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_monitor_config.py +0 -0
  126. {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_progress_column.py +0 -0
  127. {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_project_browser.py +0 -0
  128. {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_shift_hscroll.py +0 -0
  129. {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_split_button_style.py +0 -0
  130. {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_step_dialog.py +0 -0
  131. {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_table_toolbar.py +0 -0
  132. {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_table_view_state.py +0 -0
  133. {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_theme_contrast.py +0 -0
  134. {ms_components-0.3.3 → ms_components-0.3.5}/zensical.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ms_components
3
- Version: 0.3.3
3
+ Version: 0.3.5
4
4
  Summary: PySide6 UI components for MolSuite: smart table, project browser, job monitor, PyMOL dock, steppers.
5
5
  Author: Mario S. Valdés-Tresanco, Mario E. Valdés-Tresanco
6
6
  License-Expression: MIT
@@ -21,7 +21,7 @@ pip install ms_components
21
21
  | `ms_components.ms_table` | Declarative, paginated table over SQLModel |
22
22
  | `ms_components.ms_monitor` | Job monitor for an `ms_flow` workspace: cancel, resume, move |
23
23
  | `ms_components.ms_executors` | Hosts and executors: Settings pages and the panel that starts them |
24
- | `ms_components.ms_projects` | Project browser and project menu |
24
+ | [`ms_components.ms_projects`](project_form_events.md) | Project browser, project menu, and form lifecycle events |
25
25
  | `ms_components.ms_settings` | Typed editor for application settings |
26
26
  | `ms_components.ms_pymol` | PyMOL dock and control bar |
27
27
  | `ms_components.ms_stepper` | Step-by-step wizard widget |
@@ -90,6 +90,14 @@ window.tutorials.start(overview)
90
90
  without resetting application data. Multiple queued copies of an old event do
91
91
  not complete a later step, even if both steps use the same signal.
92
92
 
93
+ For asynchronous operations that may finish while the user has gone Back, supply
94
+ `TutorialEvent(signal, accept=..., is_complete=lambda: cached_result_is_ready)`.
95
+ The controller checks `is_complete` when entering an unfinished step and advances
96
+ if it returns true. The application records successful completion before emitting
97
+ its signal; the callback reads that state, without blocking or querying a database.
98
+ `accept` validates incoming signal arguments; `is_complete` independently checks
99
+ the operation and result that the step requires.
100
+
93
101
  `TutorialEvent.signal` also accepts a zero-argument resolver for signals belonging
94
102
  to dialogs or controls that are recreated. It is resolved when entering the step.
95
103
 
@@ -118,6 +126,18 @@ the application automatically. Hidden targets temporarily hide the overlay; an
118
126
  initial hidden target without an existing overlay fails with a diagnostic. Missing
119
127
  or deleted targets and callback failures stop the tour and emit `failed`.
120
128
 
129
+ Call the public `controller.refresh()` after an application UI change when the
130
+ spotlight should update immediately. Normal tracking continues automatically.
131
+ Accepting a host `QDialog` preserves the tutorial so its target can move back to
132
+ the parent window; closing or rejecting the host still cancels the tutorial.
133
+
134
+ `TutorialController(dim_background=False)` preserves the application's background
135
+ colors and draws only the target outline. Input gating remains the same.
136
+ `instruction_provider=lambda step: current_instruction` supplies dynamic plain-text
137
+ instructions. They are measured again only when their text changes, without
138
+ resetting the reader's scroll position on every tracking tick. These options do not
139
+ depend on any application or project package.
140
+
121
141
  Lifecycle signals:
122
142
 
123
143
  | Signal | Arguments |
@@ -0,0 +1,17 @@
1
+ # Project form events
2
+
3
+ `ProjectsMenuWidget` and `ProjectBrowserPanel` emit
4
+ `project_form_opened(ProjectFormDialog)` before entering a create/edit form's modal
5
+ loop. The form's `mode` is `"create"` or `"edit"`. Hosts can bind controls and
6
+ observe the normal `accepted`, `rejected`, and `finished` Qt signals without
7
+ searching the browser's widget tree or replacing its actions.
8
+
9
+ `ProjectFormDialog` is exported from `ms_components.ms_projects`. Its public
10
+ controls include `name_input`, `path_input`, `path_row`, `browse_button`, and
11
+ `save_button`. `name_committed(str)` emits the trimmed name when editing finishes.
12
+ `path_selected(str)` emits only when Browse confirms a folder, including selecting
13
+ the current folder again. Cancelling the chooser emits no selection event.
14
+
15
+ `project_created(project_id)` means that project creation succeeded. It remains
16
+ separate from opening the form or accepting its fields, which may still be followed
17
+ by a backend error. None of these APIs depend on `ms_tutorial` or a host application.
@@ -0,0 +1,4 @@
1
+ from .browser import ProjectBrowserPanel, ProjectBrowserWindow, ProjectFormDialog
2
+ from .widget import ProjectsMenuWidget
3
+
4
+ __all__ = ["ProjectBrowserPanel", "ProjectBrowserWindow", "ProjectFormDialog", "ProjectsMenuWidget"]
@@ -988,6 +988,9 @@ class StatsDialog(QDialog):
988
988
 
989
989
 
990
990
  class ProjectFormDialog(QDialog):
991
+ name_committed = Signal(str)
992
+ path_selected = Signal(str)
993
+
991
994
  def __init__(self, *, apps: list, mode: str,
992
995
  initial_data: dict | None = None,
993
996
  allow_app_selection: bool = True, parent=None):
@@ -1005,6 +1008,7 @@ class ProjectFormDialog(QDialog):
1005
1008
  layout = QVBoxLayout(self)
1006
1009
  form = QFormLayout()
1007
1010
  self.name_input = QLineEdit(self.initial_data.get("name", ""))
1011
+ self.name_input.editingFinished.connect(lambda: self.name_committed.emit(self.name_input.text().strip()))
1008
1012
  self.path_input = QLineEdit(str(self.initial_data.get("path", "")))
1009
1013
  self.path_input.setReadOnly(True)
1010
1014
  self.description_input = QTextEdit(self.initial_data.get("description", ""))
@@ -1022,11 +1026,11 @@ class ProjectFormDialog(QDialog):
1022
1026
  idx = self.app_combo.findData(cid)
1023
1027
  if idx >= 0: self.app_combo.setCurrentIndex(idx)
1024
1028
  self.app_combo.setEnabled(self.allow_app_selection and not is_edit)
1025
- path_row = QWidget()
1029
+ path_row = self.path_row = QWidget()
1026
1030
  pl = QHBoxLayout(path_row)
1027
1031
  pl.setContentsMargins(0,0,0,0)
1028
1032
  pl.addWidget(self.path_input)
1029
- browse_btn = QPushButton("Browse...")
1033
+ browse_btn = self.browse_button = QPushButton("Browse...")
1030
1034
  browse_btn.clicked.connect(self._on_browse)
1031
1035
  browse_btn.setEnabled(not is_edit)
1032
1036
  pl.addWidget(browse_btn)
@@ -1037,6 +1041,7 @@ class ProjectFormDialog(QDialog):
1037
1041
  form.addRow("Description:", self.description_input)
1038
1042
  layout.addLayout(form)
1039
1043
  btns = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
1044
+ self.save_button = btns.button(QDialogButtonBox.Save)
1040
1045
  btns.accepted.connect(self.accept)
1041
1046
  btns.rejected.connect(self.reject)
1042
1047
  layout.addWidget(btns)
@@ -1045,7 +1050,9 @@ class ProjectFormDialog(QDialog):
1045
1050
  folder = QFileDialog.getExistingDirectory(
1046
1051
  self, "Select folder",
1047
1052
  self.path_input.text().strip() or str(Path.home()))
1048
- if folder: self.path_input.setText(folder)
1053
+ if folder:
1054
+ self.path_input.setText(folder)
1055
+ self.path_selected.emit(folder)
1049
1056
 
1050
1057
  def accept(self):
1051
1058
  if not self.name_input.text().strip():
@@ -1091,6 +1098,7 @@ class StatTile(QFrame):
1091
1098
  class ProjectBrowserPanel(QWidget):
1092
1099
  project_requested = Signal(str)
1093
1100
  project_created = Signal(str)
1101
+ project_form_opened = Signal(object)
1094
1102
  projects_changed = Signal()
1095
1103
 
1096
1104
  def __init__(self, *, app_id: str | None = None, header_title: str,
@@ -1288,6 +1296,7 @@ class ProjectBrowserPanel(QWidget):
1288
1296
  "description": raw.description, "tags": raw.tags,
1289
1297
  "app_id": raw.app_id},
1290
1298
  allow_app_selection=self._dialog_allows_app_selection(), parent=self)
1299
+ self.project_form_opened.emit(dlg)
1291
1300
  if dlg.exec() != QDialog.Accepted:
1292
1301
  return
1293
1302
  data = dlg.get_data()
@@ -1355,6 +1364,7 @@ class ProjectBrowserPanel(QWidget):
1355
1364
  initial_data={"path": str(Path.home()/"molsuite_projects"),
1356
1365
  "app_id": self._active_tool_app_id or apps[0].app_id},
1357
1366
  allow_app_selection=self._dialog_allows_app_selection(), parent=self)
1367
+ self.project_form_opened.emit(dlg)
1358
1368
  if dlg.exec() != QDialog.Accepted:
1359
1369
  return
1360
1370
  fd = dlg.get_data()
@@ -38,6 +38,7 @@ def _project_color(index: int) -> str:
38
38
  class ProjectsMenuWidget(QFrame):
39
39
  project_requested = Signal(str)
40
40
  project_created = Signal(str)
41
+ project_form_opened = Signal(object)
41
42
  # The host app's open project was confirmed for deletion: the host closes it, then deletes it.
42
43
  active_project_delete_requested = Signal(str)
43
44
 
@@ -173,6 +174,7 @@ class ProjectsMenuWidget(QFrame):
173
174
  allow_app_selection=self.allow_app_selection,
174
175
  parent=self,
175
176
  )
177
+ self.project_form_opened.emit(dialog)
176
178
  if dialog.exec() != ProjectFormDialog.Accepted:
177
179
  return
178
180
  data = dialog.get_data()
@@ -224,6 +226,7 @@ class ProjectsMenuWidget(QFrame):
224
226
  allow_app_selection=False,
225
227
  parent=self,
226
228
  )
229
+ self.project_form_opened.emit(dialog)
227
230
  if dialog.exec() != ProjectFormDialog.Accepted:
228
231
  return
229
232
  data = dialog.get_data()
@@ -1,5 +1,6 @@
1
1
  from .widget import (
2
2
  COMPARISON_COLORS,
3
+ associate_scene_objects,
3
4
  PymolControlBar,
4
5
  PymolDockWidget,
5
6
  PymolPresetSpec,
@@ -8,6 +9,7 @@ from .widget import (
8
9
 
9
10
  __all__ = [
10
11
  "COMPARISON_COLORS",
12
+ "associate_scene_objects",
11
13
  "PymolControlBar",
12
14
  "PymolDockWidget",
13
15
  "PymolPresetSpec",
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
2
+ <rect x="2" y="2" width="20" height="20" rx="5"/>
3
+ <path d="M7 7v10M13 7v10M7 12h6"/>
4
+ <path d="M16 12V7h1.5a1.5 1.5 0 0 1 0 3H16" stroke-width="1.2"/>
5
+ </svg>
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
2
+ <rect x="2" y="2" width="20" height="20" rx="5"/>
3
+ <path d="M8 7v10M16 7v10M8 12h8"/>
4
+ </svg>
@@ -39,6 +39,8 @@ from . import plugins as _pymol_plugins # noqa: F401
39
39
 
40
40
 
41
41
  class _PymolGLWidget(PyMOLGLWidget):
42
+ before_render = Signal()
43
+
42
44
  def __init__(self, parent):
43
45
  super().__init__(parent)
44
46
  self._opengl_error: str | None = None
@@ -69,6 +71,7 @@ class _PymolGLWidget(PyMOLGLWidget):
69
71
 
70
72
  def paintGL(self):
71
73
  if self._opengl_error is None:
74
+ self.before_render.emit()
72
75
  super().paintGL()
73
76
 
74
77
  def resizeGL(self, width, height):
@@ -123,6 +126,7 @@ class PymolPresetSpec:
123
126
  callback: Callable[[Any, PymolSceneContext], None]
124
127
  contexts: frozenset[str] = field(default_factory=frozenset)
125
128
  tooltip: str = ""
129
+ per_grid_slot: bool = False
126
130
 
127
131
  def supports(self, context_kind: str) -> bool:
128
132
  return not self.contexts or str(context_kind) in self.contexts
@@ -138,6 +142,14 @@ def _builtin_preset(method_name: str):
138
142
  return apply
139
143
 
140
144
 
145
+ def associate_scene_objects(cmd, objects, *, reference: str) -> None:
146
+ """Keep generated interactions/geometry in their structure's comparison panel."""
147
+ slot = cmd.get_setting_int("grid_slot", reference)
148
+ for obj in objects:
149
+ if cmd.get_type(obj) != "object:group":
150
+ cmd.set("grid_slot", slot, obj)
151
+
152
+
141
153
  class PymolControlBar(QFrame):
142
154
  """Compact, reusable controls for an embedded PyMOL scene."""
143
155
 
@@ -150,7 +162,9 @@ class PymolControlBar(QFrame):
150
162
  self._cmd = cmd
151
163
  self._context = PymolSceneContext()
152
164
  self._grid_label_objects: set[str] = set()
165
+ self._preset_objects: set[str] = set()
153
166
  self._comparison_layout = "grid"
167
+ self._hydrogen_mode = "none"
154
168
  self._presets: dict[str, PymolPresetSpec] = {}
155
169
  self.setObjectName("PymolControlBar")
156
170
 
@@ -246,6 +260,14 @@ class PymolControlBar(QFrame):
246
260
  menu=hide_menu,
247
261
  )
248
262
 
263
+ self.hydrogens_action = self._add_toolbar_action(
264
+ "Hydrogens", "hydrogens.svg",
265
+ "Hydrogens: hidden. Click to show all H in this PyMOL session.",
266
+ self.toggle_hydrogens,
267
+ checkable=True,
268
+ )
269
+ self.hydrogens_action.setData("none")
270
+
249
271
  self.preset_menu = QMenu("Presets", bar)
250
272
  self.preset_action = self._add_toolbar_action(
251
273
  "Presets", "presets.svg",
@@ -264,6 +286,7 @@ class PymolControlBar(QFrame):
264
286
  self._sync_selection_mode()
265
287
  self.refresh_context_label()
266
288
  self._refresh_preset_menu()
289
+ self.apply_hydrogen_visibility()
267
290
  self._configure_overflow_button()
268
291
  if self._cmd is None:
269
292
  self.setEnabled(False)
@@ -443,6 +466,7 @@ class PymolControlBar(QFrame):
443
466
  self.comparison_menu.menuAction().setVisible(normalized.is_comparison)
444
467
  if was_comparison or normalized.is_comparison:
445
468
  self._run_command("Grid labels", self._sync_grid_labels)
469
+ self.apply_hydrogen_visibility()
446
470
  self.refresh_context_label()
447
471
  self._refresh_preset_menu()
448
472
 
@@ -473,7 +497,7 @@ class PymolControlBar(QFrame):
473
497
  self._cmd.set("grid_mode", int(self._comparison_layout == "grid"))
474
498
  slot_names: dict[int, list[str]] = {}
475
499
  for obj in self._cmd.get_object_list(self._context.target):
476
- if obj in self._grid_label_objects:
500
+ if obj in self._grid_label_objects or obj in self._preset_objects:
477
501
  continue
478
502
  slot = self._cmd.get_setting_int("grid_slot", obj)
479
503
  if slot > 0:
@@ -555,6 +579,10 @@ class PymolControlBar(QFrame):
555
579
  self._cmd.color(color, f"({temporary}) and elem C")
556
580
  self._cmd.delete(name)
557
581
  self._cmd.set_name(temporary, name)
582
+ for obj in self._preset_objects & set(self._cmd.get_names("objects")):
583
+ if self._cmd.get_setting_int("grid_slot", obj) == slot:
584
+ self._cmd.delete(obj)
585
+ self._preset_objects.discard(obj)
558
586
  self._context = replace(
559
587
  self._context, grid_labels={**self._context.grid_labels, slot: label},
560
588
  )
@@ -623,6 +651,7 @@ class PymolControlBar(QFrame):
623
651
  def apply() -> None:
624
652
  self._cmd.hide("everything", target)
625
653
  self._cmd.show(normalized, target)
654
+ self.apply_hydrogen_visibility()
626
655
 
627
656
  self._run_command(f"Display {normalized}", apply)
628
657
 
@@ -636,6 +665,41 @@ class PymolControlBar(QFrame):
636
665
  lambda: self._cmd.hide(normalized, self.active_target()),
637
666
  )
638
667
 
668
+ def toggle_hydrogens(self) -> None:
669
+ previous = self._hydrogen_mode
670
+ modes = ("none", "all", "polar")
671
+ self._hydrogen_mode = modes[(modes.index(previous) + 1) % len(modes)]
672
+ if not self.apply_hydrogen_visibility():
673
+ self._hydrogen_mode = previous
674
+ mode = self._hydrogen_mode
675
+ icon = "hydrogens-polar.svg" if mode == "polar" else "hydrogens.svg"
676
+ self._toolbar_action_icons[self.hydrogens_action] = icon
677
+ self.hydrogens_action.setIcon(self._toolbar_icon(icon))
678
+ self.hydrogens_action.setChecked(mode != "none")
679
+ self.hydrogens_action.setData(mode)
680
+ state, next_state = {
681
+ "none": ("hidden", "show all H"),
682
+ "all": ("all H", "show only polar H"),
683
+ "polar": ("polar H only (bonded to N, O or S)", "hide all H"),
684
+ }[mode]
685
+ self.hydrogens_action.setToolTip(
686
+ f"Hydrogens: {state}. Click to {next_state} in this PyMOL session."
687
+ )
688
+
689
+ def apply_hydrogen_visibility(self) -> bool:
690
+ # Enforce the session policy before drawing, including externally loaded objects.
691
+ polar = "hydro and neighbor (elem N+O+S)"
692
+ shown = {"none": "", "all": "hydro", "polar": polar}[self._hydrogen_mode]
693
+ hidden = {"none": "hydro", "all": "", "polar": f"hydro and not ({polar})"}[self._hydrogen_mode]
694
+
695
+ def apply() -> None:
696
+ if hidden and self._cmd.count_atoms(f"({hidden}) and visible"):
697
+ self._cmd.hide("everything", hidden)
698
+ if shown and self._cmd.count_atoms(f"({shown}) and not rep sticks"):
699
+ self._cmd.show("sticks", shown)
700
+
701
+ return self._run_command("Hydrogens", apply)
702
+
639
703
  def clear_selection(self) -> None:
640
704
  if self._run_command("Clear selection", self._cmd.deselect):
641
705
  self.refresh_context_label()
@@ -695,7 +759,38 @@ class PymolControlBar(QFrame):
695
759
  )
696
760
 
697
761
  def apply() -> None:
698
- spec.callback(self._cmd, self._context)
762
+ existing = set(self._cmd.get_names("objects"))
763
+ for obj in self._preset_objects & existing:
764
+ self._cmd.delete(obj)
765
+ self._preset_objects.clear()
766
+ if self._context.is_comparison and spec.per_grid_slot:
767
+ slots: dict[int, list[str]] = {}
768
+ source = " or ".join(f"({s})" for s in self._context.selections.values() if s) or self._context.target
769
+ for obj in self._cmd.get_object_list(source):
770
+ if obj not in self._grid_label_objects:
771
+ slot = self._cmd.get_setting_int("grid_slot", obj)
772
+ if slot > 0:
773
+ slots.setdefault(slot, []).append(obj)
774
+ if not slots:
775
+ spec.callback(self._cmd, self._context)
776
+ for slot, objects in sorted(slots.items()):
777
+ target = " or ".join(objects)
778
+ context = replace(
779
+ self._context, target=target,
780
+ selections={role: f"({selection}) and ({target})"
781
+ for role, selection in self._context.selections.items()},
782
+ )
783
+ before = set(self._cmd.get_names("objects"))
784
+ spec.callback(self._cmd, context)
785
+ for obj in set(self._cmd.get_names("objects")) - before:
786
+ # PyMOL reuses names such as polar_contacts on its next preset call.
787
+ name = self._cmd.get_unused_name(f"{obj}_slot{slot}")
788
+ self._cmd.set_name(obj, name)
789
+ self._preset_objects.add(name)
790
+ associate_scene_objects(self._cmd, [name], reference=objects[0])
791
+ else:
792
+ spec.callback(self._cmd, self._context)
793
+ self.apply_hydrogen_visibility()
699
794
  if self._context.is_comparison:
700
795
  self._sync_grid_labels()
701
796
 
@@ -714,6 +809,7 @@ class PymolControlBar(QFrame):
714
809
  key=key,
715
810
  label=label,
716
811
  callback=_builtin_preset(method),
812
+ per_grid_slot=True,
717
813
  )
718
814
 
719
815
  def _sync_selection_mode(self) -> None:
@@ -781,6 +877,7 @@ class PymolDockWidget(MSDockWidget):
781
877
  self.cmd.set("internal_gui", False)
782
878
 
783
879
  self.control_bar = PymolControlBar(self.cmd, self)
880
+ self.pymol_widget.before_render.connect(self.control_bar.apply_hydrogen_visibility)
784
881
  self.toolbar = self.control_bar
785
882
  self.pymol_widget.installEventFilter(self)
786
883
 
@@ -899,6 +996,7 @@ class PymolDockWidget(MSDockWidget):
899
996
 
900
997
  __all__ = [
901
998
  "COMPARISON_COLORS",
999
+ "associate_scene_objects",
902
1000
  "PymolControlBar",
903
1001
  "PymolDockWidget",
904
1002
  "PymolPresetSpec",
@@ -40,9 +40,11 @@ from .table_config import (
40
40
  DEFAULT_PAGE_SIZES,
41
41
  )
42
42
  from .table_model import SmartTableModel
43
+ from .table_interaction import TableInteraction
43
44
 
44
45
  __all__ = [
45
46
  "SmartTableView",
47
+ "TableInteraction",
46
48
  "SessionProvider",
47
49
  "TableConfig",
48
50
  "ToolbarAction",
@@ -24,6 +24,10 @@ from .table_config import (
24
24
  )
25
25
 
26
26
 
27
+ # Past this many rows a DuckDB seek numbers the rows instead of an ORDER BY + OFFSET.
28
+ DUCKDB_RANK_FROM = 100_000
29
+
30
+
27
31
  class QueryBuilder:
28
32
  """Builds and runs paginated queries from a TableConfig.
29
33
 
@@ -147,7 +151,8 @@ class QueryBuilder:
147
151
  return []
148
152
  stmt = self._build_base_statement().with_only_columns(pk)
149
153
  with self._db.get_session() as session:
150
- return [int(value) for value in session.exec(stmt).all() if value is not None]
154
+ # scalars(): with expr columns the base select has several entities and exec() yields Rows.
155
+ return [int(value) for value in session.scalars(stmt).all() if value is not None]
151
156
 
152
157
  def count(self) -> int:
153
158
  """Row total under the current filters/search, without fetching any row.
@@ -205,19 +210,51 @@ class QueryBuilder:
205
210
  has_more = len(raw) > self._page_size
206
211
  return [self._row_to_dict(row) for row in raw[:self._page_size]], has_more
207
212
 
208
- def fetch_window_at(self, page_index: int) -> tuple[list[dict], bool]:
213
+ def fetch_window_at(self, page_index: int, anchor: tuple[Any, Any] | None = None,
214
+ anchor_page: int = 0) -> tuple[list[dict], bool]:
209
215
  """Direct load for a scrollbar jump to a page that has not been walked yet.
210
216
 
211
- The normal path is still keyset via :meth:`fetch_window_after`. OFFSET only
212
- shows up when the user drags the scrollbar to a position with no known
213
- cursor.
217
+ `anchor` is the keyset cursor that starts `anchor_page` (None = the top). The
218
+ walk from it to the target reads only (sort value, pk) - never whole rows -
219
+ and then the page itself is a keyset seek, so jumping to the end of a 4M-row
220
+ table no longer materializes 4M rows.
214
221
  """
215
- offset = max(0, int(page_index)) * self._page_size
216
- stmt = self._build_base_statement().offset(offset).limit(self._page_size + 1)
222
+ offset = max(0, int(page_index) - int(anchor_page)) * self._page_size
223
+ if self._get_pk_attr() is None: # no keyset possible: plain OFFSET
224
+ stmt = self._build_base_statement().offset(int(page_index) * self._page_size)
225
+ with self._db.get_session() as session:
226
+ raw = session.exec(stmt.limit(self._page_size + 1)).all()
227
+ return [self._row_to_dict(row) for row in raw[:self._page_size]], len(raw) > self._page_size
228
+ cursor = self._seek(anchor, offset) if offset else anchor
229
+ if offset and cursor is None:
230
+ return [], False # past the end (rows vanished under the scrollbar)
231
+ return self.fetch_window_after(cursor)
232
+
233
+ def _seek(self, anchor: tuple[Any, Any] | None, offset: int) -> tuple[Any, Any] | None:
234
+ """Cursor of the `offset`-th row after `anchor`, reading only the key columns."""
235
+ pk = self._get_pk_attr()
236
+ attr = self._resolve_attr(self._sorts[0].field) if self._sorts else None
237
+ keys = [attr.label("_seek_sort") if attr is not None else None, pk.label("_seek_pk")]
238
+ stmt = self._build_base_statement()
239
+ clause = self._keyset_clause(anchor)
240
+ if clause is not None:
241
+ stmt = stmt.where(clause)
217
242
  with self._db.get_session() as session:
218
- raw = session.exec(stmt).all()
219
- has_more = len(raw) > self._page_size
220
- return [self._row_to_dict(row) for row in raw[:self._page_size]], has_more
243
+ if offset > DUCKDB_RANK_FROM and session.get_bind().dialect.name == "duckdb":
244
+ # DuckDB turns ORDER BY + OFFSET into a top-N heap the size of the offset
245
+ # (2.3 s at 3.85M rows); numbering the rows sorts once (0.2-0.7 s), which
246
+ # loses to the heap on short walks. SQLite: OFFSET 0.3 s, row_number() 2 s.
247
+ rank = func.row_number().over(order_by=self._order_clauses()).label("_seek_rank")
248
+ inner = stmt.with_only_columns(*[k for k in keys if k is not None], rank) \
249
+ .order_by(None).subquery()
250
+ row = session.execute(select(*[c for c in inner.c if c.name != "_seek_rank"])
251
+ .where(inner.c._seek_rank == offset)).first()
252
+ else:
253
+ row = session.execute(stmt.with_only_columns(*[k for k in keys if k is not None])
254
+ .offset(offset - 1).limit(1)).first()
255
+ if row is None:
256
+ return None
257
+ return (row[0], row[1]) if attr is not None else (None, row[0])
221
258
 
222
259
  def _keyset_clause(self, cursor: tuple[Any, Any] | None):
223
260
  if cursor is None:
@@ -240,20 +277,19 @@ class QueryBuilder:
240
277
  return or_(attr.is_not(None), rest_of_nulls) if not sort.descending else rest_of_nulls
241
278
  if sort.descending:
242
279
  return or_(attr < sort_value, and_(attr == sort_value, pk > pk_value), attr.is_(None))
243
- return or_(attr > sort_value, and_(attr == sort_value, pk > pk_value))
280
+ # The redundant `attr >= value` is what lets SQLite seek the index when the values
281
+ # are bound parameters (without it: a SCAN, 0.2 s per page at 3.85M rows) and
282
+ # DuckDB prune row groups by zone map.
283
+ # ponytail: descending keeps its `IS NULL` arm and so its scan; same fix needs the
284
+ # null tail fetched as a second query.
285
+ return and_(attr >= sort_value, or_(attr > sort_value, and_(attr == sort_value, pk > pk_value)))
244
286
 
245
287
  def fetch(self) -> tuple[list[dict], int]:
246
288
  """Run the query and return (rows_as_dicts, total_count)."""
247
- stmt = self._build_base_statement()
248
- count_stmt = self._build_count_statement()
249
289
  with self._db.get_session() as session:
250
- total: int = session.exec(count_stmt).one() # type: ignore[arg-type]
251
-
252
- offset = (self._page - 1) * self._page_size
253
- stmt = stmt.offset(offset).limit(self._page_size)
290
+ total: int = session.exec(self._build_count_statement()).one() # type: ignore[arg-type]
254
291
 
255
- raw_rows = session.exec(stmt).all()
256
- rows = [self._row_to_dict(r) for r in raw_rows]
292
+ rows, _has_more = self.fetch_window_at(self._page - 1)
257
293
  return rows, total
258
294
 
259
295
  # ──────────────────────────────────────────
@@ -302,24 +338,20 @@ class QueryBuilder:
302
338
  if search_clause is not None:
303
339
  stmt = stmt.where(search_clause)
304
340
 
305
- # ORDER BY
306
- if self._sorts:
307
- for sort in self._sorts:
308
- attr = self._resolve_attr(sort.field)
309
- if attr is not None:
310
- stmt = stmt.order_by(attr.desc() if sort.descending else attr.asc())
311
- # PK tie-break: without it two rows with the same score have undefined
312
- # order and the keyset cursor skips or repeats rows across pages.
313
- pk = self._get_pk_attr()
314
- if pk is not None:
315
- stmt = stmt.order_by(pk)
316
- else:
317
- # Default order: primary key of the main table
318
- pk = self._get_pk_attr()
319
- if pk is not None:
320
- stmt = stmt.order_by(pk)
341
+ return stmt.order_by(*self._order_clauses())
321
342
 
322
- return stmt
343
+ def _order_clauses(self) -> list:
344
+ clauses = []
345
+ for sort in self._sorts:
346
+ attr = self._resolve_attr(sort.field)
347
+ if attr is not None:
348
+ clauses.append(attr.desc() if sort.descending else attr.asc())
349
+ # PK tie-break (and default order): without it two rows with the same score have
350
+ # undefined order and the keyset cursor skips or repeats rows across pages.
351
+ pk = self._get_pk_attr()
352
+ if pk is not None:
353
+ clauses.append(pk)
354
+ return clauses
323
355
 
324
356
  def _build_count_statement(self):
325
357
  model = self._config.model_class