ms-components 0.3.0__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. {ms_components-0.3.0 → ms_components-0.3.2}/PKG-INFO +1 -1
  2. ms_components-0.3.2/docs/api_reference.md +102 -0
  3. {ms_components-0.3.0 → ms_components-0.3.2}/docs/usage.md +13 -0
  4. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/base.qss +21 -0
  5. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_dockwidget/widget.py +4 -1
  6. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_pymol/__init__.py +2 -0
  7. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_pymol/widget.py +212 -4
  8. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/cell_delegate.py +36 -24
  9. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/query_builder.py +6 -4
  10. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/smart_table_view.py +12 -2
  11. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/table_config.py +8 -1
  12. ms_components-0.3.2/src/ms_components/opengl.py +139 -0
  13. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/theme.py +20 -1
  14. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components.egg-info/PKG-INFO +1 -1
  15. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components.egg-info/SOURCES.txt +3 -0
  16. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components.egg-info/scm_file_list.json +3 -0
  17. ms_components-0.3.2/src/ms_components.egg-info/scm_version.json +8 -0
  18. ms_components-0.3.2/tests/test_opengl.py +166 -0
  19. ms_components-0.3.2/tests/test_progress_column.py +48 -0
  20. ms_components-0.3.2/tests/test_pymol_grid.py +278 -0
  21. {ms_components-0.3.0 → ms_components-0.3.2}/tests/test_query_builder_keyset.py +54 -0
  22. ms_components-0.3.0/docs/api_reference.md +0 -48
  23. ms_components-0.3.0/src/ms_components.egg-info/scm_version.json +0 -8
  24. ms_components-0.3.0/tests/test_progress_column.py +0 -36
  25. {ms_components-0.3.0 → ms_components-0.3.2}/.github/workflows/docs.yml +0 -0
  26. {ms_components-0.3.0 → ms_components-0.3.2}/.github/workflows/publish.yml +0 -0
  27. {ms_components-0.3.0 → ms_components-0.3.2}/.gitignore +0 -0
  28. {ms_components-0.3.0 → ms_components-0.3.2}/LICENSE +0 -0
  29. {ms_components-0.3.0 → ms_components-0.3.2}/README.md +0 -0
  30. {ms_components-0.3.0 → ms_components-0.3.2}/docs/examples.md +0 -0
  31. {ms_components-0.3.0 → ms_components-0.3.2}/docs/index.md +0 -0
  32. {ms_components-0.3.0 → ms_components-0.3.2}/docs/ms_tutorial.md +0 -0
  33. {ms_components-0.3.0 → ms_components-0.3.2}/examples/tutorial_demo.py +0 -0
  34. {ms_components-0.3.0 → ms_components-0.3.2}/examples/tutorial_demo.toml +0 -0
  35. {ms_components-0.3.0 → ms_components-0.3.2}/pyproject.toml +0 -0
  36. {ms_components-0.3.0 → ms_components-0.3.2}/setup.cfg +0 -0
  37. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/__init__.py +0 -0
  38. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/chips.py +0 -0
  39. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/listview.py +0 -0
  40. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_dockwidget/__init__.py +0 -0
  41. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_dockwidget/resources/__init__.py +0 -0
  42. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_dockwidget/resources/icons/__init__.py +0 -0
  43. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_dockwidget/resources/icons/folder.svg +0 -0
  44. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_executors.py +0 -0
  45. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_monitor/__init__.py +0 -0
  46. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_monitor/bridge.py +0 -0
  47. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_monitor/cards.py +0 -0
  48. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_monitor/config.py +0 -0
  49. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_monitor/detail_panel.py +0 -0
  50. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_monitor/formatting.py +0 -0
  51. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_monitor/job_cards.py +0 -0
  52. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_monitor/models.py +0 -0
  53. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_monitor/palette.py +0 -0
  54. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_monitor/polling.py +0 -0
  55. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_monitor/widget.py +0 -0
  56. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_projects/__init__.py +0 -0
  57. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_projects/app.py +0 -0
  58. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_projects/browser.py +0 -0
  59. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_projects/scratch.py +0 -0
  60. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_projects/widget.py +0 -0
  61. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_pymol/icons/actions.svg +0 -0
  62. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_pymol/icons/box.svg +0 -0
  63. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_pymol/icons/center.svg +0 -0
  64. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_pymol/icons/display.svg +0 -0
  65. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_pymol/icons/hide.svg +0 -0
  66. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_pymol/icons/menu.svg +0 -0
  67. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_pymol/icons/orient.svg +0 -0
  68. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_pymol/icons/presets.svg +0 -0
  69. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_pymol/icons/rock.svg +0 -0
  70. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_pymol/icons/selection.svg +0 -0
  71. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_pymol/icons/settings.svg +0 -0
  72. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_pymol/icons/zoom.svg +0 -0
  73. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_pymol/plugins.py +0 -0
  74. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_settings/__init__.py +0 -0
  75. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_settings/panel.py +0 -0
  76. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_stepper.py +0 -0
  77. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/__init__.py +0 -0
  78. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/action_header.py +0 -0
  79. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/column_panel.py +0 -0
  80. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/example_usage.py +0 -0
  81. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/filter_panel.py +0 -0
  82. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/icons/columns.svg +0 -0
  83. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/icons/export.svg +0 -0
  84. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/icons/filter.svg +0 -0
  85. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/icons/menu.svg +0 -0
  86. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/icons/reload.svg +0 -0
  87. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/icons/search.svg +0 -0
  88. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/icons/settings.svg +0 -0
  89. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/icons/sort-asc.svg +0 -0
  90. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/icons/sort-desc.svg +0 -0
  91. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/icons/sort.svg +0 -0
  92. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/metric_filter_bar.py +0 -0
  93. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/pagination_control.py +0 -0
  94. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/session_provider.py +0 -0
  95. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/sort_panel.py +0 -0
  96. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/table_model.py +0 -0
  97. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/test_external_filters.py +0 -0
  98. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_table/test_toolbar.py +0 -0
  99. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_tutorial/__init__.py +0 -0
  100. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_tutorial/bindings.py +0 -0
  101. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_tutorial/controller.py +0 -0
  102. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_tutorial/models.py +0 -0
  103. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/ms_tutorial/overlay.py +0 -0
  104. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/pagination.py +0 -0
  105. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/palettes.py +0 -0
  106. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/step_dialog.py +0 -0
  107. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/tool_panel.py +0 -0
  108. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components/wheel.py +0 -0
  109. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components.egg-info/dependency_links.txt +0 -0
  110. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components.egg-info/requires.txt +0 -0
  111. {ms_components-0.3.0 → ms_components-0.3.2}/src/ms_components.egg-info/top_level.txt +0 -0
  112. {ms_components-0.3.0 → ms_components-0.3.2}/tests/test_app_settings.py +0 -0
  113. {ms_components-0.3.0 → ms_components-0.3.2}/tests/test_chips.py +0 -0
  114. {ms_components-0.3.0 → ms_components-0.3.2}/tests/test_dock_window.py +0 -0
  115. {ms_components-0.3.0 → ms_components-0.3.2}/tests/test_executors_ui.py +0 -0
  116. {ms_components-0.3.0 → ms_components-0.3.2}/tests/test_molsuite_monitor_bridge.py +0 -0
  117. {ms_components-0.3.0 → ms_components-0.3.2}/tests/test_molsuite_monitor_widgets.py +0 -0
  118. {ms_components-0.3.0 → ms_components-0.3.2}/tests/test_monitor_config.py +0 -0
  119. {ms_components-0.3.0 → ms_components-0.3.2}/tests/test_ms_projects_widget.py +0 -0
  120. {ms_components-0.3.0 → ms_components-0.3.2}/tests/test_project_browser.py +0 -0
  121. {ms_components-0.3.0 → ms_components-0.3.2}/tests/test_pymol_toolbar.py +0 -0
  122. {ms_components-0.3.0 → ms_components-0.3.2}/tests/test_shift_hscroll.py +0 -0
  123. {ms_components-0.3.0 → ms_components-0.3.2}/tests/test_split_button_style.py +0 -0
  124. {ms_components-0.3.0 → ms_components-0.3.2}/tests/test_step_dialog.py +0 -0
  125. {ms_components-0.3.0 → ms_components-0.3.2}/tests/test_table_toolbar.py +0 -0
  126. {ms_components-0.3.0 → ms_components-0.3.2}/tests/test_table_view_state.py +0 -0
  127. {ms_components-0.3.0 → ms_components-0.3.2}/tests/test_theme_contrast.py +0 -0
  128. {ms_components-0.3.0 → ms_components-0.3.2}/tests/test_tutorial.py +0 -0
  129. {ms_components-0.3.0 → ms_components-0.3.2}/zensical.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ms_components
3
- Version: 0.3.0
3
+ Version: 0.3.2
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,102 @@
1
+ # API reference
2
+
3
+ The reference below is generated directly from the current Python sources.
4
+ Names beginning with an underscore are implementation details.
5
+
6
+ ## Smart table
7
+
8
+ ::: ms_components.ms_table
9
+
10
+ ## Monitor
11
+
12
+ ::: ms_components.ms_monitor
13
+
14
+ ## Hosts and executors
15
+
16
+ ::: ms_components.ms_executors
17
+
18
+ ## Projects
19
+
20
+ ::: ms_components.ms_projects
21
+
22
+ ## Settings
23
+
24
+ ::: ms_components.ms_settings
25
+
26
+ ## PyMOL
27
+
28
+ ::: ms_components.ms_pymol
29
+
30
+ Comparison scenes provide grid and overlay layouts without reloading structures
31
+ or changing the camera. Assign each structure/pose pair the same positive PyMOL
32
+ `grid_slot`, then pass its display name:
33
+
34
+ ```python
35
+ dock.set_scene_context(
36
+ "comparison",
37
+ grid_labels={1: "Complex A", 3: "Complex B"},
38
+ )
39
+ dock.set_comparison_layout("overlay")
40
+ ```
41
+
42
+ `grid_labels` is also accepted by `PymolControlBar` and `PymolSceneContext`.
43
+ Without explicit labels, molecular object titles (or object names when no title
44
+ is available) are joined per positive `grid_slot` within the context's `target`.
45
+ Applications must pass display names if those names are not present in PyMOL.
46
+
47
+ The **Display > Comparison > Grid / Overlay** menu appears only in comparison
48
+ scenes. The chosen layout persists when changing poses or applying presets.
49
+ Overlay displays the existing coordinates together; alignment remains the
50
+ caller's responsibility.
51
+
52
+ Each label is a pseudoatom named `panel_text<slot>`, initially sized 14.
53
+ Grid labels appear at the top-left of their panel; overlay labels form a stacked
54
+ legend that follows viewport resizing. Receptor cartoons, carbon atoms and label
55
+ text share the slot's `COMPARISON_COLORS` color. Heteroatom colors are preserved.
56
+ The label outline uses PyMOL's background-adaptive `front` color for readability
57
+ on light and dark backgrounds. The palette has ten colors and repeats beyond ten
58
+ slots.
59
+ Existing labels are updated in place, so individual settings remain editable,
60
+ for example `cmd.set("label_size", 18, "panel_text3")`. Presets restore label
61
+ visibility after changing molecular representations.
62
+
63
+ To replace one pose without rebuilding the scene, pass its bytes directly:
64
+
65
+ ```python
66
+ dock.replace_comparison_object(
67
+ "pose_a", pdb_bytes, "pdb",
68
+ reference="receptor_a", label="Complex A [P2]",
69
+ )
70
+ ```
71
+
72
+ The new object inherits the reference's transformation and slot, is shown as
73
+ sticks, and receives the same comparison color. Camera and auto-zoom settings
74
+ are preserved. The previous object remains available until the replacement loads
75
+ successfully. This API neither fetches data nor calls the hosting application.
76
+ Hosts remain responsible for reading their data and discarding stale requests.
77
+
78
+ Setting any non-comparison scene context after a comparison context removes
79
+ the managed labels and sets `grid_mode` to zero. Applications should update
80
+ the scene context when leaving their off-target view, even without loading a
81
+ new structure. Unrelated grid scenes and user-created annotations are not
82
+ removed. `"offtarget"` remains accepted as an alias for `"comparison"`.
83
+
84
+ ## Stepper
85
+
86
+ ::: ms_components.ms_stepper
87
+
88
+ ## Guided tutorials
89
+
90
+ ::: ms_components.ms_tutorial
91
+
92
+ ## Tool panel
93
+
94
+ ::: ms_components.tool_panel
95
+
96
+ ## Step dialog
97
+
98
+ ::: ms_components.step_dialog
99
+
100
+ ## Theme
101
+
102
+ ::: ms_components.theme
@@ -178,6 +178,19 @@ panel.show()
178
178
 
179
179
  ## Projects, settings, and PyMOL
180
180
 
181
+ Applications embedding PyMOL should call
182
+ `ms_components.opengl.prepare_pymol_opengl()` **before creating `QApplication`**.
183
+ It requests desktop OpenGL and shared contexts, then checks the actual context
184
+ and compiles legacy GLSL shaders in an isolated process. A failed native check
185
+ can select `xcb` on Linux only when an X11 display passes the same check and
186
+ `QT_QPA_PLATFORM` was not explicitly set. Windows and macOS retain their native
187
+ platforms. If no tested backend works, it raises `RuntimeError` before the
188
+ application starts. Skip this call when PyMOL is disabled or running headlessly.
189
+
190
+ `PymolDockWidget` also requests desktop OpenGL on its own widget and validates
191
+ the actual context before drawing. An incompatible context displays an error
192
+ in the viewer instead of passing it to PyMOL.
193
+
181
194
  - `ProjectsMenuWidget(app_id=...)` and `ProjectBrowserWindow(app_id=...)` list
182
195
  and create projects through the public `ms_flow` project catalog. Connect
183
196
  `project_requested` to open the chosen project; do not read the MolSuite
@@ -64,6 +64,19 @@ QProgressBar#ToolProgress::chunk {
64
64
  background: palette(highlight);
65
65
  }
66
66
 
67
+ /* ms_table: a PROGRESS cell, a filled bar with its text on top. The fill color is
68
+ the `tone` property, from the theme accents (theme.tone_qss). The track is the
69
+ header tone: it keeps text contrast and stands out from the rows. */
70
+ QProgressBar#CellProgress {
71
+ border: none;
72
+ border-radius: 3px;
73
+ background: palette(dark);
74
+ }
75
+
76
+ QProgressBar#CellProgress::chunk {
77
+ border-radius: 3px;
78
+ }
79
+
67
80
 
68
81
  /* Left gap for the first tab: `tab-bar { left }` shifts the whole bar.
69
82
  Do NOT use margins on ::tab -- Qt places the close button flush to the full
@@ -154,6 +167,14 @@ QToolButton[popupMode="MenuButtonPopup"]::menu-button {
154
167
  width: 18px;
155
168
  }
156
169
 
170
+ /* Qt's own tooltip colours are a pale grey that dark themes leave unreadable. */
171
+ QToolTip {
172
+ background: palette(window);
173
+ color: palette(text);
174
+ border: 1px solid palette(highlight);
175
+ padding: 4px;
176
+ }
177
+
157
178
  /* Card = content surface. Nav trees/lists stay flat unless opted in. */
158
179
  QTableView,
159
180
  QTreeView[card="true"],
@@ -734,7 +734,10 @@ class VerticalToolbar(QWidget):
734
734
  yield w
735
735
 
736
736
  def _layout_has_buttons(self, layout: QVBoxLayout) -> bool:
737
- return any(True for _ in self._iter_buttons(layout))
737
+ # Action buttons (no dock_id) count too: a section holding only those is not empty.
738
+ return any(
739
+ isinstance(layout.itemAt(i).widget(), QToolButton) for i in range(layout.count())
740
+ )
738
741
 
739
742
  def _update_separator_visibility(self):
740
743
  self._separator.setVisible(
@@ -1,4 +1,5 @@
1
1
  from .widget import (
2
+ COMPARISON_COLORS,
2
3
  PymolControlBar,
3
4
  PymolDockWidget,
4
5
  PymolPresetSpec,
@@ -6,6 +7,7 @@ from .widget import (
6
7
  )
7
8
 
8
9
  __all__ = [
10
+ "COMPARISON_COLORS",
9
11
  "PymolControlBar",
10
12
  "PymolDockWidget",
11
13
  "PymolPresetSpec",
@@ -1,17 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  import os
4
5
  from collections.abc import Callable, Mapping
5
- from dataclasses import dataclass, field
6
+ from dataclasses import dataclass, field, replace
6
7
  from pathlib import Path
7
8
  from typing import Any
8
9
 
9
10
  from ms_components.ms_dockwidget.widget import MSDockWidget
11
+ from ms_components.opengl import pymol_context_error, pymol_surface_format
10
12
 
11
13
  os.environ.setdefault("QT_API", "pyside6")
12
14
 
13
15
  from PySide6.QtCore import QByteArray, QEvent, QSize, Qt, QTimer, Signal
14
- from PySide6.QtGui import QAction, QActionGroup, QIcon, QPainter, QPixmap
16
+ from PySide6.QtGui import QAction, QActionGroup, QGuiApplication, QIcon, QPainter, QPixmap
15
17
  from PySide6.QtSvg import QSvgRenderer
16
18
  from PySide6.QtWidgets import (
17
19
  QFrame,
@@ -36,6 +38,44 @@ except ImportError:
36
38
  from . import plugins as _pymol_plugins # noqa: F401
37
39
 
38
40
 
41
+ class _PymolGLWidget(PyMOLGLWidget):
42
+ def __init__(self, parent):
43
+ super().__init__(parent)
44
+ self._opengl_error: str | None = None
45
+ self._opengl_error_label: QLabel | None = None
46
+ if hasattr(self, "setFormat"):
47
+ self.setFormat(pymol_surface_format(self.format()))
48
+
49
+ def initializeGL(self):
50
+ context = self.context()
51
+ self._opengl_error = pymol_context_error(context)
52
+ if self._opengl_error_label is not None:
53
+ self._opengl_error_label.hide()
54
+ if self._opengl_error:
55
+ logging.getLogger(__name__).error(self._opengl_error)
56
+ if self._opengl_error_label is None:
57
+ self._opengl_error_label = QLabel(self)
58
+ self._opengl_error_label.setWordWrap(True)
59
+ QVBoxLayout(self).addWidget(self._opengl_error_label)
60
+ self._opengl_error_label.setText(f"PyMOL viewer unavailable.\n{self._opengl_error}")
61
+ self._opengl_error_label.show()
62
+ return
63
+ fmt = context.format()
64
+ logging.getLogger(__name__).info(
65
+ "PyMOL context: %s, OpenGL %s.%s %s",
66
+ QGuiApplication.platformName(), *fmt.version(), fmt.profile().name,
67
+ )
68
+ super().initializeGL()
69
+
70
+ def paintGL(self):
71
+ if self._opengl_error is None:
72
+ super().paintGL()
73
+
74
+ def resizeGL(self, width, height):
75
+ if self._opengl_error is None:
76
+ super().resizeGL(width, height)
77
+
78
+
39
79
  SELECTION_MODES = (
40
80
  ("Atoms", 0),
41
81
  ("Residues", 1),
@@ -53,6 +93,10 @@ REPRESENTATIONS = (
53
93
  )
54
94
 
55
95
  _TOOLBAR_ICONS = Path(__file__).parent / "icons"
96
+ COMPARISON_COLORS = (
97
+ "orange", "tv_green", "cyan", "magenta", "yellow",
98
+ "salmon", "violet", "lime", "deepteal", "hotpink",
99
+ )
56
100
 
57
101
 
58
102
  @dataclass(frozen=True)
@@ -61,6 +105,11 @@ class PymolSceneContext:
61
105
  target: str = "all"
62
106
  selections: Mapping[str, str] = field(default_factory=dict)
63
107
  default_preset: str = ""
108
+ grid_labels: Mapping[int, str] = field(default_factory=dict)
109
+
110
+ @property
111
+ def is_comparison(self) -> bool:
112
+ return self.kind in {"comparison", "offtarget"}
64
113
 
65
114
  def selection(self, role: str, fallback: str = "") -> str:
66
115
  value = str(dict(self.selections or {}).get(str(role)) or "").strip()
@@ -100,6 +149,8 @@ class PymolControlBar(QFrame):
100
149
  super().__init__(parent)
101
150
  self._cmd = cmd
102
151
  self._context = PymolSceneContext()
152
+ self._grid_label_objects: set[str] = set()
153
+ self._comparison_layout = "grid"
103
154
  self._presets: dict[str, PymolPresetSpec] = {}
104
155
  self.setObjectName("PymolControlBar")
105
156
 
@@ -168,6 +219,18 @@ class PymolControlBar(QFrame):
168
219
  "Apply a representation to the current selection or scene target.",
169
220
  menu=representation_menu,
170
221
  )
222
+ self.comparison_menu = representation_menu.addMenu("Comparison")
223
+ self.comparison_menu.menuAction().setVisible(False)
224
+ comparison_group = QActionGroup(self.comparison_menu)
225
+ comparison_group.setExclusive(True)
226
+ self._comparison_layout_actions: dict[str, QAction] = {}
227
+ for label, value in (("Grid", "grid"), ("Overlay", "overlay")):
228
+ action = self.comparison_menu.addAction(label)
229
+ action.setCheckable(True)
230
+ action.setChecked(value == self._comparison_layout)
231
+ comparison_group.addAction(action)
232
+ action.triggered.connect(lambda checked=False, layout=value: self.set_comparison_layout(layout))
233
+ self._comparison_layout_actions[value] = action
171
234
 
172
235
  hide_menu = QMenu("Hide", bar)
173
236
  for label, value in (("Everything", "everything"), *REPRESENTATIONS):
@@ -359,6 +422,7 @@ class PymolControlBar(QFrame):
359
422
  target: str = "all",
360
423
  selections: Mapping[str, str] | None = None,
361
424
  default_preset: str = "",
425
+ grid_labels: Mapping[int, str] | None = None,
362
426
  ) -> None:
363
427
  if isinstance(context, PymolSceneContext):
364
428
  normalized = context
@@ -372,11 +436,136 @@ class PymolControlBar(QFrame):
372
436
  if str(key).strip() and str(value).strip()
373
437
  },
374
438
  default_preset=str(default_preset or "").strip(),
439
+ grid_labels=dict(grid_labels or {}),
375
440
  )
441
+ was_comparison = self._context.is_comparison
376
442
  self._context = normalized
443
+ self.comparison_menu.menuAction().setVisible(normalized.is_comparison)
444
+ if was_comparison or normalized.is_comparison:
445
+ self._run_command("Grid labels", self._sync_grid_labels)
377
446
  self.refresh_context_label()
378
447
  self._refresh_preset_menu()
379
448
 
449
+ @property
450
+ def comparison_layout(self) -> str:
451
+ return self._comparison_layout
452
+
453
+ def set_comparison_layout(self, layout: str) -> None:
454
+ if layout not in self._comparison_layout_actions:
455
+ raise ValueError(f"Unsupported comparison layout: {layout}")
456
+ if not self._context.is_comparison:
457
+ raise ValueError("A comparison scene is required to change its layout.")
458
+ previous = self._comparison_layout
459
+ self._comparison_layout = layout
460
+
461
+ def apply() -> None:
462
+ self._sync_grid_labels()
463
+ self._cmd.refresh()
464
+
465
+ if not self._run_command("Comparison layout", apply):
466
+ self._comparison_layout = previous
467
+ for value, action in self._comparison_layout_actions.items():
468
+ action.setChecked(value == self._comparison_layout)
469
+
470
+ def _sync_grid_labels(self, *, color_objects: bool = True) -> None:
471
+ labels = dict(self._context.grid_labels)
472
+ if self._context.is_comparison:
473
+ self._cmd.set("grid_mode", int(self._comparison_layout == "grid"))
474
+ slot_names: dict[int, list[str]] = {}
475
+ for obj in self._cmd.get_object_list(self._context.target):
476
+ if obj in self._grid_label_objects:
477
+ continue
478
+ slot = self._cmd.get_setting_int("grid_slot", obj)
479
+ if slot > 0:
480
+ color = COMPARISON_COLORS[(slot - 1) % len(COMPARISON_COLORS)]
481
+ if color_objects:
482
+ self._cmd.set("cartoon_color", color, selection=obj)
483
+ self._cmd.color(color, f"({obj}) and elem C")
484
+ slot_names.setdefault(slot, []).append(self._cmd.get_title(obj, 1) or obj)
485
+ if not labels:
486
+ labels = {slot: " + ".join(names) for slot, names in slot_names.items()}
487
+ else:
488
+ labels = {}
489
+ self._cmd.set("grid_mode", 0)
490
+
491
+ wanted = {f"panel_text{slot}" for slot in labels}
492
+ for obj in self._grid_label_objects - wanted:
493
+ self._cmd.delete(obj)
494
+ self._grid_label_objects.intersection_update(wanted)
495
+ existing = set(self._cmd.get_names("objects"))
496
+ for slot, title in sorted(labels.items()):
497
+ if slot <= 0:
498
+ raise ValueError("Grid label slots must be positive integers.")
499
+ obj = f"panel_text{slot}"
500
+ if obj not in existing:
501
+ self._cmd.pseudoatom(obj, label=title)
502
+ self._grid_label_objects.add(obj)
503
+ self._cmd.hide("everything", obj)
504
+ self._cmd.set("label_relative_mode", 1, selection=obj)
505
+ self._cmd.set("label_size", 14, selection=obj)
506
+ self._cmd.set("label_position", [1, -1, 0], selection=obj)
507
+ self._cmd.set("grid_slot", slot, selection=obj)
508
+ elif obj in self._grid_label_objects:
509
+ self._cmd.label(obj, repr(title))
510
+ else:
511
+ raise ValueError(f"Grid label object '{obj}' already exists.")
512
+ color = COMPARISON_COLORS[(slot - 1) % len(COMPARISON_COLORS)]
513
+ self._cmd.set("label_color", color, selection=obj)
514
+ self._cmd.set("label_outline_color", "front", selection=obj)
515
+ self._cmd.show("labels", obj)
516
+ self._position_comparison_labels()
517
+
518
+ def _position_comparison_labels(self) -> None:
519
+ if not self._context.is_comparison:
520
+ return
521
+ existing = set(self._cmd.get_names("objects"))
522
+ height = max(1, self._cmd.get_viewport()[1])
523
+ offset = 0.0
524
+ for obj in sorted(self._grid_label_objects & existing,
525
+ key=lambda name: self._cmd.get_setting_int("grid_slot", name)):
526
+ self._cmd.set("label_screen_point", [-1, 1 - offset, 0], selection=obj)
527
+ if self._comparison_layout == "overlay":
528
+ offset += 2 * (abs(self._cmd.get_setting_float("label_size", obj)) + 6) / height
529
+
530
+ def replace_comparison_object(
531
+ self, name: str, data: bytes, fmt: str, *, reference: str, label: str,
532
+ ) -> bool:
533
+ def apply() -> None:
534
+ if not self._context.is_comparison:
535
+ raise ValueError("A comparison scene is required to replace an object.")
536
+ objects = self._cmd.get_names("objects")
537
+ if name not in objects or reference not in objects:
538
+ raise ValueError("The comparison object and its reference must already exist.")
539
+ if name == reference:
540
+ raise ValueError("The comparison object must differ from its reference.")
541
+ slot = self._cmd.get_setting_int("grid_slot", reference)
542
+ if slot <= 0:
543
+ raise ValueError("The comparison reference must have a positive grid slot.")
544
+ auto_zoom = self._cmd.get_setting_int("auto_zoom")
545
+ temporary = self._cmd.get_unused_name("_comparison_pose")
546
+ try:
547
+ self._cmd.set("auto_zoom", 0)
548
+ self._cmd.load_raw(data, fmt, temporary)
549
+ self._cmd.matrix_copy(reference, temporary)
550
+ self._cmd.set("grid_slot", slot, temporary)
551
+ self._cmd.show("sticks", temporary)
552
+ self._cmd.color("atomic", temporary)
553
+ color = COMPARISON_COLORS[(slot - 1) % len(COMPARISON_COLORS)]
554
+ self._cmd.set("cartoon_color", color, temporary)
555
+ self._cmd.color(color, f"({temporary}) and elem C")
556
+ self._cmd.delete(name)
557
+ self._cmd.set_name(temporary, name)
558
+ self._context = replace(
559
+ self._context, grid_labels={**self._context.grid_labels, slot: label},
560
+ )
561
+ self._sync_grid_labels(color_objects=False)
562
+ finally:
563
+ if temporary in self._cmd.get_names("objects"):
564
+ self._cmd.delete(temporary)
565
+ self._cmd.set("auto_zoom", auto_zoom)
566
+
567
+ return self._run_command("Replace comparison object", apply)
568
+
380
569
  def register_preset(self, spec: PymolPresetSpec) -> None:
381
570
  key = str(spec.key or "").strip()
382
571
  if not key:
@@ -507,6 +696,8 @@ class PymolControlBar(QFrame):
507
696
 
508
697
  def apply() -> None:
509
698
  spec.callback(self._cmd, self._context)
699
+ if self._context.is_comparison:
700
+ self._sync_grid_labels()
510
701
 
511
702
  if self._run_command(spec.label, apply):
512
703
  self.preset_applied.emit(normalized_key)
@@ -585,13 +776,13 @@ class PymolDockWidget(MSDockWidget):
585
776
 
586
777
  def __init__(self, title: str, manager, parent=None):
587
778
  super().__init__(title, manager, parent)
588
- self.pymol_widget = PyMOLGLWidget(self)
589
- self.pymol_widget.installEventFilter(self)
779
+ self.pymol_widget = _PymolGLWidget(self)
590
780
  if self.cmd is not None:
591
781
  self.cmd.set("internal_gui", False)
592
782
 
593
783
  self.control_bar = PymolControlBar(self.cmd, self)
594
784
  self.toolbar = self.control_bar
785
+ self.pymol_widget.installEventFilter(self)
595
786
 
596
787
  self._side_panel_widget: QWidget | None = None
597
788
  self._splitter = QSplitter(Qt.Horizontal, self)
@@ -640,17 +831,31 @@ class PymolDockWidget(MSDockWidget):
640
831
  target: str = "all",
641
832
  selections: Mapping[str, str] | None = None,
642
833
  default_preset: str = "",
834
+ grid_labels: Mapping[int, str] | None = None,
643
835
  ) -> None:
644
836
  self.control_bar.set_scene_context(
645
837
  context,
646
838
  target=target,
647
839
  selections=selections,
648
840
  default_preset=default_preset,
841
+ grid_labels=grid_labels,
649
842
  )
650
843
 
651
844
  def register_preset(self, spec: PymolPresetSpec) -> None:
652
845
  self.control_bar.register_preset(spec)
653
846
 
847
+ def set_comparison_layout(self, layout: str) -> None:
848
+ self.control_bar.set_comparison_layout(layout)
849
+
850
+ @property
851
+ def comparison_layout(self) -> str:
852
+ return self.control_bar.comparison_layout
853
+
854
+ def replace_comparison_object(
855
+ self, name: str, data: bytes, fmt: str, *, reference: str, label: str,
856
+ ) -> bool:
857
+ return self.control_bar.replace_comparison_object(name, data, fmt, reference=reference, label=label)
858
+
654
859
  def unregister_preset(self, key: str) -> None:
655
860
  self.control_bar.unregister_preset(key)
656
861
 
@@ -687,10 +892,13 @@ class PymolDockWidget(MSDockWidget):
687
892
  def eventFilter(self, watched, event):
688
893
  if watched is self.pymol_widget and event.type() == QEvent.Type.MouseButtonRelease:
689
894
  QTimer.singleShot(0, self.control_bar.refresh_context_label)
895
+ elif watched is self.pymol_widget and event.type() == QEvent.Type.Resize and self.scene_context.is_comparison:
896
+ QTimer.singleShot(0, self.control_bar._position_comparison_labels)
690
897
  return super().eventFilter(watched, event)
691
898
 
692
899
 
693
900
  __all__ = [
901
+ "COMPARISON_COLORS",
694
902
  "PymolControlBar",
695
903
  "PymolDockWidget",
696
904
  "PymolPresetSpec",
@@ -58,10 +58,10 @@ from __future__ import annotations
58
58
 
59
59
  from typing import Any, Callable, Optional
60
60
 
61
- from PySide6.QtCore import QModelIndex, QRect, QRectF, QSize, Qt
62
- from PySide6.QtGui import QImage, QPainter, QPalette, QPixmap
61
+ from PySide6.QtCore import QModelIndex, QPoint, QRect, QRectF, QSize, Qt
62
+ from PySide6.QtGui import QImage, QPainter, QPixmap, QRegion
63
63
  from PySide6.QtWidgets import (
64
- QApplication, QStyle, QStyledItemDelegate, QStyleOptionViewItem,
64
+ QApplication, QProgressBar, QStyle, QStyledItemDelegate, QStyleOptionViewItem,
65
65
  QWidget,
66
66
  )
67
67
 
@@ -159,6 +159,8 @@ class CellWidgetDelegate(QStyledItemDelegate):
159
159
  # Persistent widget cache: row → QWidget
160
160
  # Invalidated when the page changes (load_data)
161
161
  self._persistent_widgets: dict[int, QWidget] = {}
162
+ # PROGRESS cells: one hidden bar per tone, so the tone property is set once.
163
+ self._progress_bars: dict[str, QProgressBar] = {}
162
164
 
163
165
  # ── Invalidate the cache on page change ───────────────────────────────
164
166
 
@@ -185,30 +187,40 @@ class CellWidgetDelegate(QStyledItemDelegate):
185
187
 
186
188
  row_data = self._get_row_data(index)
187
189
 
188
- # ── progress mode: a fraction 0..1 as the style's own progress bar ──
190
+ # ── progress mode: a fraction 0..1 (or value/max) as the style's own progress bar ──
189
191
  if col_def.kind == ColumnKind.PROGRESS:
190
192
  try:
191
- fraction = min(max(float(row_data.get(col_def.display_key) or 0.0), 0.0), 1.0)
193
+ value = float(row_data.get(col_def.display_key) or 0.0)
194
+ maximum = float(row_data.get(col_def.progress_max) or 0.0) if col_def.progress_max else 1.0
195
+ fraction = min(max(value / maximum, 0.0), 1.0) if maximum > 0 else 0.0
192
196
  except (TypeError, ValueError):
193
- fraction = 0.0
194
- # ponytail: painted by hand: CE_ProgressBar under an app stylesheet draws the
195
- # table's rules, not QProgressBar's, and a partial chunk came out as a line.
196
- rect, pal = QRectF(option.rect.adjusted(4, 5, -4, -5)), option.palette
197
- painter.save()
198
- painter.setRenderHint(QPainter.Antialiasing)
199
- # A selected row is Highlight already: the track goes Base so the fill still shows.
200
- track = pal.color(QPalette.Base) if option.state & QStyle.State_Selected else pal.color(QPalette.Text)
201
- if not option.state & QStyle.State_Selected:
202
- track.setAlpha(40)
203
- painter.setPen(Qt.NoPen)
204
- painter.setBrush(track)
205
- painter.drawRoundedRect(rect, 3, 3)
206
- if fraction > 0:
207
- painter.setBrush(pal.color(QPalette.Highlight))
208
- painter.drawRoundedRect(QRectF(rect.x(), rect.y(), rect.width() * fraction, rect.height()), 3, 3)
209
- painter.setPen(pal.color(QPalette.Text))
210
- painter.drawText(rect, Qt.AlignCenter, f"{int(fraction * 100)}%") # floored: 99.9% is not "100%"
211
- painter.restore()
197
+ value, maximum, fraction = 0.0, 0.0, 0.0
198
+ text = f"{int(fraction * 100)}%" # floored: 99.9% is not "100%"
199
+ if col_def.progress_max:
200
+ text = f"{int(value):,}/{int(maximum):,} ({text})"
201
+ tone = col_def.progress_tone(row_data) if col_def.progress_tone else "green"
202
+ # A hidden QProgressBar paints the cell, so the bar is the native one and its look is
203
+ # QSS (`QProgressBar#CellProgress`, `[tone=...]`). render(), not drawControl(): under a
204
+ # stylesheet drawControl lays the chunk out from the widget, not from the option. Into a
205
+ # pixmap: rendered on the view's painter it ignores the viewport offset and lands under
206
+ # the headers.
207
+ bar = self._progress_bars.get(tone)
208
+ if bar is None:
209
+ bar = self._progress_bars[tone] = QProgressBar()
210
+ bar.setObjectName("CellProgress")
211
+ bar.setProperty("tone", tone)
212
+ bar.setRange(0, 1000)
213
+ bar.setAlignment(Qt.AlignCenter)
214
+ bar.setValue(int(fraction * 1000))
215
+ bar.setFormat(text)
216
+ rect = option.rect.adjusted(4, 5, -4, -5)
217
+ bar.resize(rect.size())
218
+ ratio = painter.device().devicePixelRatioF()
219
+ pixmap = QPixmap(rect.size() * ratio)
220
+ pixmap.setDevicePixelRatio(ratio)
221
+ pixmap.fill(Qt.transparent)
222
+ bar.render(pixmap, QPoint(), QRegion(), QWidget.DrawChildren)
223
+ painter.drawPixmap(rect.topLeft(), pixmap)
212
224
  return
213
225
 
214
226
  # ── paint_factory mode (SVG / image) ───────────────────────────────
@@ -234,11 +234,13 @@ class QueryBuilder:
234
234
  attr = self._resolve_attr(sort.field)
235
235
  if attr is None:
236
236
  return pk > pk_value
237
+ # SQLite puts NULLs first ascending and last descending.
238
+ rest_of_nulls = and_(attr.is_(None), pk > pk_value)
237
239
  if sort_value is None:
238
- # NULLs come first in ascending SQLite: past the null block, anything goes.
239
- return attr.is_not(None) if not sort.descending else and_(attr.is_(None), pk > pk_value)
240
- ahead = attr < sort_value if sort.descending else attr > sort_value
241
- return or_(ahead, and_(attr == sort_value, pk > pk_value))
240
+ return or_(attr.is_not(None), rest_of_nulls) if not sort.descending else rest_of_nulls
241
+ if sort.descending:
242
+ 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))
242
244
 
243
245
  def fetch(self) -> tuple[list[dict], int]:
244
246
  """Run the query and return (rows_as_dicts, total_count)."""
@@ -154,6 +154,15 @@ class SmartTableView(QWidget):
154
154
  self._infinite_total_hint = total
155
155
  self._infinite_total_exact = True
156
156
  self._infinite_has_more = self._infinite_loaded_end < total
157
+ tail = self._infinite_pages.get(self._infinite_frontier_page)
158
+ if tail is not None and len(tail) < self._builder.page_size and self._infinite_has_more:
159
+ # The last page came back short and rows were added since: forget it, so the
160
+ # autofill set_total() triggers fetches it again once it is in view. Kept, its
161
+ # rows past the old end would stay blank forever.
162
+ del self._infinite_pages[self._infinite_frontier_page]
163
+ # The next page's cursor is the short page's last row: wrong now, so it is
164
+ # found by offset instead.
165
+ self._infinite_page_cursors.pop(self._infinite_frontier_page + 1, None)
157
166
  self._model.set_total(total)
158
167
  return True
159
168
 
@@ -749,8 +758,9 @@ class SmartTableView(QWidget):
749
758
  self.load_next_page()
750
759
 
751
760
  def _on_refresh_clicked(self):
752
- # Reload this table, then let the host (e.g. DockingStudio) refresh surrounding state.
753
- self.refresh()
761
+ # Reload this table where the user is looking (scroll and selection kept), then let
762
+ # the host (e.g. DockingStudio) refresh surrounding state.
763
+ self.refresh_preserving_view()
754
764
  self.refresh_clicked.emit()
755
765
 
756
766
  # ──────────────────────────────────────────