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.
- {ms_components-0.3.3/src/ms_components.egg-info → ms_components-0.3.5}/PKG-INFO +1 -1
- {ms_components-0.3.3 → ms_components-0.3.5}/docs/index.md +1 -1
- {ms_components-0.3.3 → ms_components-0.3.5}/docs/ms_tutorial.md +20 -0
- ms_components-0.3.5/docs/project_form_events.md +17 -0
- ms_components-0.3.5/src/ms_components/ms_projects/__init__.py +4 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_projects/browser.py +13 -3
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_projects/widget.py +3 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/__init__.py +2 -0
- ms_components-0.3.5/src/ms_components/ms_pymol/icons/hydrogens-polar.svg +5 -0
- ms_components-0.3.5/src/ms_components/ms_pymol/icons/hydrogens.svg +4 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/widget.py +100 -2
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/__init__.py +2 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/query_builder.py +68 -36
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/smart_table_view.py +155 -57
- ms_components-0.3.5/src/ms_components/ms_table/table_interaction.py +42 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_tutorial/bindings.py +1 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_tutorial/controller.py +30 -6
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_tutorial/overlay.py +18 -8
- {ms_components-0.3.3 → ms_components-0.3.5/src/ms_components.egg-info}/PKG-INFO +1 -1
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components.egg-info/SOURCES.txt +5 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components.egg-info/scm_file_list.json +5 -0
- ms_components-0.3.5/src/ms_components.egg-info/scm_version.json +8 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_ms_projects_widget.py +45 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_opengl.py +2 -1
- {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_pymol_grid.py +78 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_pymol_toolbar.py +57 -1
- {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_query_builder_keyset.py +79 -19
- ms_components-0.3.5/tests/test_table_interaction.py +58 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_tutorial.py +80 -4
- ms_components-0.3.3/src/ms_components/ms_projects/__init__.py +0 -4
- ms_components-0.3.3/src/ms_components.egg-info/scm_version.json +0 -8
- {ms_components-0.3.3 → ms_components-0.3.5}/.github/workflows/docs.yml +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/.github/workflows/publish.yml +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/.gitignore +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/LICENSE +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/README.md +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/docs/api_reference.md +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/docs/examples.md +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/docs/usage.md +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/examples/tutorial_demo.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/examples/tutorial_demo.toml +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/pyproject.toml +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/setup.cfg +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/__init__.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/backdrop.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/base.qss +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/chips.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/listview.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_dockwidget/__init__.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_dockwidget/resources/__init__.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_dockwidget/resources/icons/__init__.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_dockwidget/resources/icons/folder.svg +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_dockwidget/widget.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_executors.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_monitor/__init__.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_monitor/bridge.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_monitor/cards.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_monitor/config.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_monitor/detail_panel.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_monitor/formatting.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_monitor/job_cards.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_monitor/models.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_monitor/palette.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_monitor/polling.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_monitor/widget.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_projects/app.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_projects/scratch.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/actions.svg +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/box.svg +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/center.svg +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/display.svg +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/hide.svg +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/menu.svg +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/orient.svg +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/presets.svg +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/rock.svg +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/selection.svg +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/settings.svg +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/zoom.svg +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_pymol/plugins.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_settings/__init__.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_settings/panel.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_stepper.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/action_header.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/cell_delegate.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/column_panel.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/example_usage.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/filter_panel.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/icons/columns.svg +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/icons/export.svg +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/icons/filter.svg +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/icons/menu.svg +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/icons/reload.svg +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/icons/search.svg +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/icons/settings.svg +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/icons/sort-asc.svg +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/icons/sort-desc.svg +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/icons/sort.svg +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/metric_filter_bar.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/pagination_control.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/session_provider.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/sort_panel.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/table_config.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/table_model.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/test_external_filters.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_table/test_toolbar.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_tutorial/__init__.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/ms_tutorial/models.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/opengl.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/pagination.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/palettes.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/step_dialog.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/theme.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/tool_panel.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components/wheel.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components.egg-info/dependency_links.txt +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components.egg-info/requires.txt +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/src/ms_components.egg-info/top_level.txt +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_app_settings.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_chips.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_dock_window.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_executors_ui.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_molsuite_monitor_bridge.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_molsuite_monitor_widgets.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_monitor_config.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_progress_column.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_project_browser.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_shift_hscroll.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_split_button_style.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_step_dialog.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_table_toolbar.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_table_view_state.py +0 -0
- {ms_components-0.3.3 → ms_components-0.3.5}/tests/test_theme_contrast.py +0 -0
- {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
|
+
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
|
|
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.
|
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|