ms-components 0.3.2__tar.gz → 0.3.4__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 (132) hide show
  1. {ms_components-0.3.2/src/ms_components.egg-info → ms_components-0.3.4}/PKG-INFO +1 -1
  2. ms_components-0.3.4/src/ms_components/backdrop.py +34 -0
  3. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/base.qss +16 -4
  4. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_pymol/__init__.py +2 -0
  5. ms_components-0.3.4/src/ms_components/ms_pymol/icons/hydrogens-polar.svg +5 -0
  6. ms_components-0.3.4/src/ms_components/ms_pymol/icons/hydrogens.svg +4 -0
  7. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_pymol/widget.py +100 -2
  8. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_settings/panel.py +3 -2
  9. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/query_builder.py +68 -36
  10. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/smart_table_view.py +153 -64
  11. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/table_config.py +2 -0
  12. ms_components-0.3.4/src/ms_components/palettes.py +39 -0
  13. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/theme.py +80 -135
  14. {ms_components-0.3.2 → ms_components-0.3.4/src/ms_components.egg-info}/PKG-INFO +1 -1
  15. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components.egg-info/SOURCES.txt +3 -0
  16. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components.egg-info/scm_file_list.json +3 -0
  17. ms_components-0.3.4/src/ms_components.egg-info/scm_version.json +8 -0
  18. {ms_components-0.3.2 → ms_components-0.3.4}/tests/test_opengl.py +2 -1
  19. {ms_components-0.3.2 → ms_components-0.3.4}/tests/test_pymol_grid.py +78 -0
  20. {ms_components-0.3.2 → ms_components-0.3.4}/tests/test_pymol_toolbar.py +57 -1
  21. {ms_components-0.3.2 → ms_components-0.3.4}/tests/test_query_builder_keyset.py +79 -19
  22. ms_components-0.3.4/tests/test_theme_contrast.py +68 -0
  23. ms_components-0.3.2/src/ms_components/palettes.py +0 -29
  24. ms_components-0.3.2/src/ms_components.egg-info/scm_version.json +0 -8
  25. ms_components-0.3.2/tests/test_theme_contrast.py +0 -31
  26. {ms_components-0.3.2 → ms_components-0.3.4}/.github/workflows/docs.yml +0 -0
  27. {ms_components-0.3.2 → ms_components-0.3.4}/.github/workflows/publish.yml +0 -0
  28. {ms_components-0.3.2 → ms_components-0.3.4}/.gitignore +0 -0
  29. {ms_components-0.3.2 → ms_components-0.3.4}/LICENSE +0 -0
  30. {ms_components-0.3.2 → ms_components-0.3.4}/README.md +0 -0
  31. {ms_components-0.3.2 → ms_components-0.3.4}/docs/api_reference.md +0 -0
  32. {ms_components-0.3.2 → ms_components-0.3.4}/docs/examples.md +0 -0
  33. {ms_components-0.3.2 → ms_components-0.3.4}/docs/index.md +0 -0
  34. {ms_components-0.3.2 → ms_components-0.3.4}/docs/ms_tutorial.md +0 -0
  35. {ms_components-0.3.2 → ms_components-0.3.4}/docs/usage.md +0 -0
  36. {ms_components-0.3.2 → ms_components-0.3.4}/examples/tutorial_demo.py +0 -0
  37. {ms_components-0.3.2 → ms_components-0.3.4}/examples/tutorial_demo.toml +0 -0
  38. {ms_components-0.3.2 → ms_components-0.3.4}/pyproject.toml +0 -0
  39. {ms_components-0.3.2 → ms_components-0.3.4}/setup.cfg +0 -0
  40. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/__init__.py +0 -0
  41. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/chips.py +0 -0
  42. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/listview.py +0 -0
  43. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_dockwidget/__init__.py +0 -0
  44. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_dockwidget/resources/__init__.py +0 -0
  45. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_dockwidget/resources/icons/__init__.py +0 -0
  46. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_dockwidget/resources/icons/folder.svg +0 -0
  47. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_dockwidget/widget.py +0 -0
  48. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_executors.py +0 -0
  49. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_monitor/__init__.py +0 -0
  50. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_monitor/bridge.py +0 -0
  51. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_monitor/cards.py +0 -0
  52. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_monitor/config.py +0 -0
  53. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_monitor/detail_panel.py +0 -0
  54. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_monitor/formatting.py +0 -0
  55. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_monitor/job_cards.py +0 -0
  56. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_monitor/models.py +0 -0
  57. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_monitor/palette.py +0 -0
  58. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_monitor/polling.py +0 -0
  59. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_monitor/widget.py +0 -0
  60. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_projects/__init__.py +0 -0
  61. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_projects/app.py +0 -0
  62. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_projects/browser.py +0 -0
  63. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_projects/scratch.py +0 -0
  64. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_projects/widget.py +0 -0
  65. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_pymol/icons/actions.svg +0 -0
  66. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_pymol/icons/box.svg +0 -0
  67. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_pymol/icons/center.svg +0 -0
  68. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_pymol/icons/display.svg +0 -0
  69. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_pymol/icons/hide.svg +0 -0
  70. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_pymol/icons/menu.svg +0 -0
  71. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_pymol/icons/orient.svg +0 -0
  72. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_pymol/icons/presets.svg +0 -0
  73. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_pymol/icons/rock.svg +0 -0
  74. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_pymol/icons/selection.svg +0 -0
  75. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_pymol/icons/settings.svg +0 -0
  76. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_pymol/icons/zoom.svg +0 -0
  77. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_pymol/plugins.py +0 -0
  78. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_settings/__init__.py +0 -0
  79. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_stepper.py +0 -0
  80. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/__init__.py +0 -0
  81. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/action_header.py +0 -0
  82. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/cell_delegate.py +0 -0
  83. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/column_panel.py +0 -0
  84. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/example_usage.py +0 -0
  85. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/filter_panel.py +0 -0
  86. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/icons/columns.svg +0 -0
  87. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/icons/export.svg +0 -0
  88. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/icons/filter.svg +0 -0
  89. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/icons/menu.svg +0 -0
  90. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/icons/reload.svg +0 -0
  91. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/icons/search.svg +0 -0
  92. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/icons/settings.svg +0 -0
  93. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/icons/sort-asc.svg +0 -0
  94. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/icons/sort-desc.svg +0 -0
  95. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/icons/sort.svg +0 -0
  96. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/metric_filter_bar.py +0 -0
  97. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/pagination_control.py +0 -0
  98. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/session_provider.py +0 -0
  99. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/sort_panel.py +0 -0
  100. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/table_model.py +0 -0
  101. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/test_external_filters.py +0 -0
  102. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_table/test_toolbar.py +0 -0
  103. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_tutorial/__init__.py +0 -0
  104. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_tutorial/bindings.py +0 -0
  105. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_tutorial/controller.py +0 -0
  106. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_tutorial/models.py +0 -0
  107. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/ms_tutorial/overlay.py +0 -0
  108. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/opengl.py +0 -0
  109. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/pagination.py +0 -0
  110. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/step_dialog.py +0 -0
  111. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/tool_panel.py +0 -0
  112. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components/wheel.py +0 -0
  113. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components.egg-info/dependency_links.txt +0 -0
  114. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components.egg-info/requires.txt +0 -0
  115. {ms_components-0.3.2 → ms_components-0.3.4}/src/ms_components.egg-info/top_level.txt +0 -0
  116. {ms_components-0.3.2 → ms_components-0.3.4}/tests/test_app_settings.py +0 -0
  117. {ms_components-0.3.2 → ms_components-0.3.4}/tests/test_chips.py +0 -0
  118. {ms_components-0.3.2 → ms_components-0.3.4}/tests/test_dock_window.py +0 -0
  119. {ms_components-0.3.2 → ms_components-0.3.4}/tests/test_executors_ui.py +0 -0
  120. {ms_components-0.3.2 → ms_components-0.3.4}/tests/test_molsuite_monitor_bridge.py +0 -0
  121. {ms_components-0.3.2 → ms_components-0.3.4}/tests/test_molsuite_monitor_widgets.py +0 -0
  122. {ms_components-0.3.2 → ms_components-0.3.4}/tests/test_monitor_config.py +0 -0
  123. {ms_components-0.3.2 → ms_components-0.3.4}/tests/test_ms_projects_widget.py +0 -0
  124. {ms_components-0.3.2 → ms_components-0.3.4}/tests/test_progress_column.py +0 -0
  125. {ms_components-0.3.2 → ms_components-0.3.4}/tests/test_project_browser.py +0 -0
  126. {ms_components-0.3.2 → ms_components-0.3.4}/tests/test_shift_hscroll.py +0 -0
  127. {ms_components-0.3.2 → ms_components-0.3.4}/tests/test_split_button_style.py +0 -0
  128. {ms_components-0.3.2 → ms_components-0.3.4}/tests/test_step_dialog.py +0 -0
  129. {ms_components-0.3.2 → ms_components-0.3.4}/tests/test_table_toolbar.py +0 -0
  130. {ms_components-0.3.2 → ms_components-0.3.4}/tests/test_table_view_state.py +0 -0
  131. {ms_components-0.3.2 → ms_components-0.3.4}/tests/test_tutorial.py +0 -0
  132. {ms_components-0.3.2 → ms_components-0.3.4}/zensical.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ms_components
3
- Version: 0.3.2
3
+ Version: 0.3.4
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
@@ -0,0 +1,34 @@
1
+ """A faint, centred icon painted under a widget's own children (welcome pages, empty states)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from PySide6.QtCore import QRect, QSize
6
+ from PySide6.QtGui import QIcon, QPainter
7
+ from PySide6.QtWidgets import QWidget
8
+
9
+
10
+ class Backdrop(QWidget):
11
+ """``icon`` drawn at ``opacity``, scaled to 80 % of the shorter side; ``None`` draws nothing."""
12
+
13
+ # One fixed-size render, scaled: asking the icon for each widget size would cache a
14
+ # multi-MB pixmap per size (~1 GB after a long window resize with a caching icon engine).
15
+ RENDER_PX = 512
16
+
17
+ def __init__(self, icon: QIcon | None = None, parent: QWidget | None = None, *, opacity: float = 0.06):
18
+ super().__init__(parent)
19
+ self._icon = icon
20
+ self._opacity = opacity
21
+
22
+ def paintEvent(self, event) -> None:
23
+ if self._icon is not None:
24
+ side = int(min(self.width(), self.height()) * 0.8)
25
+ painter = QPainter(self)
26
+ painter.setOpacity(self._opacity)
27
+ painter.setRenderHint(QPainter.SmoothPixmapTransform)
28
+ painter.drawPixmap(QRect((self.width() - side) // 2, (self.height() - side) // 2, side, side),
29
+ self._icon.pixmap(QSize(self.RENDER_PX, self.RENDER_PX)))
30
+ painter.end()
31
+ super().paintEvent(event)
32
+
33
+
34
+ __all__ = ["Backdrop"]
@@ -111,7 +111,8 @@ QTextEdit,
111
111
  QSpinBox,
112
112
  QDoubleSpinBox,
113
113
  QComboBox {
114
- border: 1px solid palette(mid);
114
+ /* shadow = the control edge (3:1 on every surface); mid stays for hairlines */
115
+ border: 1px solid palette(shadow);
115
116
  border-radius: 4px;
116
117
  padding: 3px 6px;
117
118
  }
@@ -136,7 +137,7 @@ QComboBox:focus {
136
137
  }
137
138
 
138
139
  QPushButton {
139
- border: 1px solid palette(mid);
140
+ border: 1px solid palette(shadow);
140
141
  border-radius: 4px;
141
142
  padding: 5px 14px;
142
143
  }
@@ -154,16 +155,27 @@ QPushButton:default {
154
155
  border-color: palette(highlight);
155
156
  }
156
157
 
158
+ QLineEdit:disabled,
159
+ QPlainTextEdit:disabled,
160
+ QTextEdit:disabled,
161
+ QSpinBox:disabled,
162
+ QDoubleSpinBox:disabled,
163
+ QComboBox:disabled,
164
+ QPushButton:disabled,
165
+ QToolButton[popupMode="MenuButtonPopup"]:disabled {
166
+ border-color: palette(mid);
167
+ }
168
+
157
169
  /* A split button (QToolButton with a menu arrow) is an action button, not a toolbar icon:
158
170
  framed like QPushButton, the arrow in its own segment. */
159
171
  QToolButton[popupMode="MenuButtonPopup"] {
160
- border: 1px solid palette(mid);
172
+ border: 1px solid palette(shadow);
161
173
  padding: 5px 14px;
162
174
  padding-right: 24px;
163
175
  }
164
176
 
165
177
  QToolButton[popupMode="MenuButtonPopup"]::menu-button {
166
- border-left: 1px solid palette(mid);
178
+ border-left: 1px solid palette(shadow);
167
179
  width: 18px;
168
180
  }
169
181
 
@@ -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",
@@ -311,8 +311,8 @@ class AppSettingsPanel(QWidget):
311
311
  font-weight: 700;
312
312
  }
313
313
  QLabel#sourceDefault { color: palette(text); background: rgba(128, 128, 128, 0.18); }
314
- QLabel#sourceGlobal { color: #4c9be8; background: rgba(76, 155, 232, 0.16); }
315
- QLabel#sourceProject { color: #34c88a; background: rgba(52, 200, 138, 0.16); }
314
+ QLabel#sourceGlobal { background: rgba(76, 155, 232, 0.16); }
315
+ QLabel#sourceProject { background: rgba(52, 200, 138, 0.16); }
316
316
  """
317
317
  )
318
318
 
@@ -743,6 +743,7 @@ class AppSettingsPanel(QWidget):
743
743
  normalized = source if source in _SOURCE_LABELS else "default"
744
744
  label.setText(_SOURCE_LABELS[normalized])
745
745
  label.setObjectName(f"source{normalized.title()}")
746
+ label.setProperty("tone", {"global": "blue", "project": "green"}.get(normalized, "")) # theme text hue
746
747
  label.style().unpolish(label)
747
748
  label.style().polish(label)
748
749
 
@@ -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