ms-components 0.3.4__tar.gz → 0.3.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. {ms_components-0.3.4/src/ms_components.egg-info → ms_components-0.3.5}/PKG-INFO +1 -1
  2. {ms_components-0.3.4 → ms_components-0.3.5}/docs/index.md +1 -1
  3. {ms_components-0.3.4 → ms_components-0.3.5}/docs/ms_tutorial.md +20 -0
  4. ms_components-0.3.5/docs/project_form_events.md +17 -0
  5. ms_components-0.3.5/src/ms_components/ms_projects/__init__.py +4 -0
  6. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_projects/browser.py +13 -3
  7. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_projects/widget.py +3 -0
  8. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/__init__.py +2 -0
  9. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/smart_table_view.py +15 -1
  10. ms_components-0.3.5/src/ms_components/ms_table/table_interaction.py +42 -0
  11. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_tutorial/bindings.py +1 -0
  12. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_tutorial/controller.py +30 -6
  13. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_tutorial/overlay.py +18 -8
  14. {ms_components-0.3.4 → ms_components-0.3.5/src/ms_components.egg-info}/PKG-INFO +1 -1
  15. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components.egg-info/SOURCES.txt +3 -0
  16. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components.egg-info/scm_file_list.json +3 -0
  17. ms_components-0.3.5/src/ms_components.egg-info/scm_version.json +8 -0
  18. {ms_components-0.3.4 → ms_components-0.3.5}/tests/test_ms_projects_widget.py +45 -0
  19. ms_components-0.3.5/tests/test_table_interaction.py +58 -0
  20. {ms_components-0.3.4 → ms_components-0.3.5}/tests/test_tutorial.py +80 -4
  21. ms_components-0.3.4/src/ms_components/ms_projects/__init__.py +0 -4
  22. ms_components-0.3.4/src/ms_components.egg-info/scm_version.json +0 -8
  23. {ms_components-0.3.4 → ms_components-0.3.5}/.github/workflows/docs.yml +0 -0
  24. {ms_components-0.3.4 → ms_components-0.3.5}/.github/workflows/publish.yml +0 -0
  25. {ms_components-0.3.4 → ms_components-0.3.5}/.gitignore +0 -0
  26. {ms_components-0.3.4 → ms_components-0.3.5}/LICENSE +0 -0
  27. {ms_components-0.3.4 → ms_components-0.3.5}/README.md +0 -0
  28. {ms_components-0.3.4 → ms_components-0.3.5}/docs/api_reference.md +0 -0
  29. {ms_components-0.3.4 → ms_components-0.3.5}/docs/examples.md +0 -0
  30. {ms_components-0.3.4 → ms_components-0.3.5}/docs/usage.md +0 -0
  31. {ms_components-0.3.4 → ms_components-0.3.5}/examples/tutorial_demo.py +0 -0
  32. {ms_components-0.3.4 → ms_components-0.3.5}/examples/tutorial_demo.toml +0 -0
  33. {ms_components-0.3.4 → ms_components-0.3.5}/pyproject.toml +0 -0
  34. {ms_components-0.3.4 → ms_components-0.3.5}/setup.cfg +0 -0
  35. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/__init__.py +0 -0
  36. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/backdrop.py +0 -0
  37. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/base.qss +0 -0
  38. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/chips.py +0 -0
  39. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/listview.py +0 -0
  40. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_dockwidget/__init__.py +0 -0
  41. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_dockwidget/resources/__init__.py +0 -0
  42. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_dockwidget/resources/icons/__init__.py +0 -0
  43. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_dockwidget/resources/icons/folder.svg +0 -0
  44. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_dockwidget/widget.py +0 -0
  45. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_executors.py +0 -0
  46. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_monitor/__init__.py +0 -0
  47. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_monitor/bridge.py +0 -0
  48. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_monitor/cards.py +0 -0
  49. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_monitor/config.py +0 -0
  50. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_monitor/detail_panel.py +0 -0
  51. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_monitor/formatting.py +0 -0
  52. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_monitor/job_cards.py +0 -0
  53. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_monitor/models.py +0 -0
  54. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_monitor/palette.py +0 -0
  55. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_monitor/polling.py +0 -0
  56. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_monitor/widget.py +0 -0
  57. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_projects/app.py +0 -0
  58. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_projects/scratch.py +0 -0
  59. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_pymol/__init__.py +0 -0
  60. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/actions.svg +0 -0
  61. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/box.svg +0 -0
  62. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/center.svg +0 -0
  63. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/display.svg +0 -0
  64. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/hide.svg +0 -0
  65. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/hydrogens-polar.svg +0 -0
  66. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/hydrogens.svg +0 -0
  67. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/menu.svg +0 -0
  68. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/orient.svg +0 -0
  69. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/presets.svg +0 -0
  70. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/rock.svg +0 -0
  71. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/selection.svg +0 -0
  72. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/settings.svg +0 -0
  73. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_pymol/icons/zoom.svg +0 -0
  74. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_pymol/plugins.py +0 -0
  75. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_pymol/widget.py +0 -0
  76. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_settings/__init__.py +0 -0
  77. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_settings/panel.py +0 -0
  78. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_stepper.py +0 -0
  79. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/action_header.py +0 -0
  80. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/cell_delegate.py +0 -0
  81. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/column_panel.py +0 -0
  82. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/example_usage.py +0 -0
  83. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/filter_panel.py +0 -0
  84. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/icons/columns.svg +0 -0
  85. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/icons/export.svg +0 -0
  86. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/icons/filter.svg +0 -0
  87. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/icons/menu.svg +0 -0
  88. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/icons/reload.svg +0 -0
  89. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/icons/search.svg +0 -0
  90. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/icons/settings.svg +0 -0
  91. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/icons/sort-asc.svg +0 -0
  92. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/icons/sort-desc.svg +0 -0
  93. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/icons/sort.svg +0 -0
  94. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/metric_filter_bar.py +0 -0
  95. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/pagination_control.py +0 -0
  96. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/query_builder.py +0 -0
  97. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/session_provider.py +0 -0
  98. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/sort_panel.py +0 -0
  99. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/table_config.py +0 -0
  100. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/table_model.py +0 -0
  101. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/test_external_filters.py +0 -0
  102. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_table/test_toolbar.py +0 -0
  103. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_tutorial/__init__.py +0 -0
  104. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/ms_tutorial/models.py +0 -0
  105. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/opengl.py +0 -0
  106. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/pagination.py +0 -0
  107. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/palettes.py +0 -0
  108. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/step_dialog.py +0 -0
  109. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/theme.py +0 -0
  110. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/tool_panel.py +0 -0
  111. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components/wheel.py +0 -0
  112. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components.egg-info/dependency_links.txt +0 -0
  113. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components.egg-info/requires.txt +0 -0
  114. {ms_components-0.3.4 → ms_components-0.3.5}/src/ms_components.egg-info/top_level.txt +0 -0
  115. {ms_components-0.3.4 → ms_components-0.3.5}/tests/test_app_settings.py +0 -0
  116. {ms_components-0.3.4 → ms_components-0.3.5}/tests/test_chips.py +0 -0
  117. {ms_components-0.3.4 → ms_components-0.3.5}/tests/test_dock_window.py +0 -0
  118. {ms_components-0.3.4 → ms_components-0.3.5}/tests/test_executors_ui.py +0 -0
  119. {ms_components-0.3.4 → ms_components-0.3.5}/tests/test_molsuite_monitor_bridge.py +0 -0
  120. {ms_components-0.3.4 → ms_components-0.3.5}/tests/test_molsuite_monitor_widgets.py +0 -0
  121. {ms_components-0.3.4 → ms_components-0.3.5}/tests/test_monitor_config.py +0 -0
  122. {ms_components-0.3.4 → ms_components-0.3.5}/tests/test_opengl.py +0 -0
  123. {ms_components-0.3.4 → ms_components-0.3.5}/tests/test_progress_column.py +0 -0
  124. {ms_components-0.3.4 → ms_components-0.3.5}/tests/test_project_browser.py +0 -0
  125. {ms_components-0.3.4 → ms_components-0.3.5}/tests/test_pymol_grid.py +0 -0
  126. {ms_components-0.3.4 → ms_components-0.3.5}/tests/test_pymol_toolbar.py +0 -0
  127. {ms_components-0.3.4 → ms_components-0.3.5}/tests/test_query_builder_keyset.py +0 -0
  128. {ms_components-0.3.4 → ms_components-0.3.5}/tests/test_shift_hscroll.py +0 -0
  129. {ms_components-0.3.4 → ms_components-0.3.5}/tests/test_split_button_style.py +0 -0
  130. {ms_components-0.3.4 → ms_components-0.3.5}/tests/test_step_dialog.py +0 -0
  131. {ms_components-0.3.4 → ms_components-0.3.5}/tests/test_table_toolbar.py +0 -0
  132. {ms_components-0.3.4 → ms_components-0.3.5}/tests/test_table_view_state.py +0 -0
  133. {ms_components-0.3.4 → ms_components-0.3.5}/tests/test_theme_contrast.py +0 -0
  134. {ms_components-0.3.4 → 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.4
3
+ Version: 0.3.5
4
4
  Summary: PySide6 UI components for MolSuite: smart table, project browser, job monitor, PyMOL dock, steppers.
5
5
  Author: Mario S. Valdés-Tresanco, Mario E. Valdés-Tresanco
6
6
  License-Expression: MIT
@@ -21,7 +21,7 @@ pip install ms_components
21
21
  | `ms_components.ms_table` | Declarative, paginated table over SQLModel |
22
22
  | `ms_components.ms_monitor` | Job monitor for an `ms_flow` workspace: cancel, resume, move |
23
23
  | `ms_components.ms_executors` | Hosts and executors: Settings pages and the panel that starts them |
24
- | `ms_components.ms_projects` | Project browser and project menu |
24
+ | [`ms_components.ms_projects`](project_form_events.md) | Project browser, project menu, and form lifecycle events |
25
25
  | `ms_components.ms_settings` | Typed editor for application settings |
26
26
  | `ms_components.ms_pymol` | PyMOL dock and control bar |
27
27
  | `ms_components.ms_stepper` | Step-by-step wizard widget |
@@ -90,6 +90,14 @@ window.tutorials.start(overview)
90
90
  without resetting application data. Multiple queued copies of an old event do
91
91
  not complete a later step, even if both steps use the same signal.
92
92
 
93
+ For asynchronous operations that may finish while the user has gone Back, supply
94
+ `TutorialEvent(signal, accept=..., is_complete=lambda: cached_result_is_ready)`.
95
+ The controller checks `is_complete` when entering an unfinished step and advances
96
+ if it returns true. The application records successful completion before emitting
97
+ its signal; the callback reads that state, without blocking or querying a database.
98
+ `accept` validates incoming signal arguments; `is_complete` independently checks
99
+ the operation and result that the step requires.
100
+
93
101
  `TutorialEvent.signal` also accepts a zero-argument resolver for signals belonging
94
102
  to dialogs or controls that are recreated. It is resolved when entering the step.
95
103
 
@@ -118,6 +126,18 @@ the application automatically. Hidden targets temporarily hide the overlay; an
118
126
  initial hidden target without an existing overlay fails with a diagnostic. Missing
119
127
  or deleted targets and callback failures stop the tour and emit `failed`.
120
128
 
129
+ Call the public `controller.refresh()` after an application UI change when the
130
+ spotlight should update immediately. Normal tracking continues automatically.
131
+ Accepting a host `QDialog` preserves the tutorial so its target can move back to
132
+ the parent window; closing or rejecting the host still cancels the tutorial.
133
+
134
+ `TutorialController(dim_background=False)` preserves the application's background
135
+ colors and draws only the target outline. Input gating remains the same.
136
+ `instruction_provider=lambda step: current_instruction` supplies dynamic plain-text
137
+ instructions. They are measured again only when their text changes, without
138
+ resetting the reader's scroll position on every tracking tick. These options do not
139
+ depend on any application or project package.
140
+
121
141
  Lifecycle signals:
122
142
 
123
143
  | Signal | Arguments |
@@ -0,0 +1,17 @@
1
+ # Project form events
2
+
3
+ `ProjectsMenuWidget` and `ProjectBrowserPanel` emit
4
+ `project_form_opened(ProjectFormDialog)` before entering a create/edit form's modal
5
+ loop. The form's `mode` is `"create"` or `"edit"`. Hosts can bind controls and
6
+ observe the normal `accepted`, `rejected`, and `finished` Qt signals without
7
+ searching the browser's widget tree or replacing its actions.
8
+
9
+ `ProjectFormDialog` is exported from `ms_components.ms_projects`. Its public
10
+ controls include `name_input`, `path_input`, `path_row`, `browse_button`, and
11
+ `save_button`. `name_committed(str)` emits the trimmed name when editing finishes.
12
+ `path_selected(str)` emits only when Browse confirms a folder, including selecting
13
+ the current folder again. Cancelling the chooser emits no selection event.
14
+
15
+ `project_created(project_id)` means that project creation succeeded. It remains
16
+ separate from opening the form or accepting its fields, which may still be followed
17
+ by a backend error. None of these APIs depend on `ms_tutorial` or a host application.
@@ -0,0 +1,4 @@
1
+ from .browser import ProjectBrowserPanel, ProjectBrowserWindow, ProjectFormDialog
2
+ from .widget import ProjectsMenuWidget
3
+
4
+ __all__ = ["ProjectBrowserPanel", "ProjectBrowserWindow", "ProjectFormDialog", "ProjectsMenuWidget"]
@@ -988,6 +988,9 @@ class StatsDialog(QDialog):
988
988
 
989
989
 
990
990
  class ProjectFormDialog(QDialog):
991
+ name_committed = Signal(str)
992
+ path_selected = Signal(str)
993
+
991
994
  def __init__(self, *, apps: list, mode: str,
992
995
  initial_data: dict | None = None,
993
996
  allow_app_selection: bool = True, parent=None):
@@ -1005,6 +1008,7 @@ class ProjectFormDialog(QDialog):
1005
1008
  layout = QVBoxLayout(self)
1006
1009
  form = QFormLayout()
1007
1010
  self.name_input = QLineEdit(self.initial_data.get("name", ""))
1011
+ self.name_input.editingFinished.connect(lambda: self.name_committed.emit(self.name_input.text().strip()))
1008
1012
  self.path_input = QLineEdit(str(self.initial_data.get("path", "")))
1009
1013
  self.path_input.setReadOnly(True)
1010
1014
  self.description_input = QTextEdit(self.initial_data.get("description", ""))
@@ -1022,11 +1026,11 @@ class ProjectFormDialog(QDialog):
1022
1026
  idx = self.app_combo.findData(cid)
1023
1027
  if idx >= 0: self.app_combo.setCurrentIndex(idx)
1024
1028
  self.app_combo.setEnabled(self.allow_app_selection and not is_edit)
1025
- path_row = QWidget()
1029
+ path_row = self.path_row = QWidget()
1026
1030
  pl = QHBoxLayout(path_row)
1027
1031
  pl.setContentsMargins(0,0,0,0)
1028
1032
  pl.addWidget(self.path_input)
1029
- browse_btn = QPushButton("Browse...")
1033
+ browse_btn = self.browse_button = QPushButton("Browse...")
1030
1034
  browse_btn.clicked.connect(self._on_browse)
1031
1035
  browse_btn.setEnabled(not is_edit)
1032
1036
  pl.addWidget(browse_btn)
@@ -1037,6 +1041,7 @@ class ProjectFormDialog(QDialog):
1037
1041
  form.addRow("Description:", self.description_input)
1038
1042
  layout.addLayout(form)
1039
1043
  btns = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
1044
+ self.save_button = btns.button(QDialogButtonBox.Save)
1040
1045
  btns.accepted.connect(self.accept)
1041
1046
  btns.rejected.connect(self.reject)
1042
1047
  layout.addWidget(btns)
@@ -1045,7 +1050,9 @@ class ProjectFormDialog(QDialog):
1045
1050
  folder = QFileDialog.getExistingDirectory(
1046
1051
  self, "Select folder",
1047
1052
  self.path_input.text().strip() or str(Path.home()))
1048
- if folder: self.path_input.setText(folder)
1053
+ if folder:
1054
+ self.path_input.setText(folder)
1055
+ self.path_selected.emit(folder)
1049
1056
 
1050
1057
  def accept(self):
1051
1058
  if not self.name_input.text().strip():
@@ -1091,6 +1098,7 @@ class StatTile(QFrame):
1091
1098
  class ProjectBrowserPanel(QWidget):
1092
1099
  project_requested = Signal(str)
1093
1100
  project_created = Signal(str)
1101
+ project_form_opened = Signal(object)
1094
1102
  projects_changed = Signal()
1095
1103
 
1096
1104
  def __init__(self, *, app_id: str | None = None, header_title: str,
@@ -1288,6 +1296,7 @@ class ProjectBrowserPanel(QWidget):
1288
1296
  "description": raw.description, "tags": raw.tags,
1289
1297
  "app_id": raw.app_id},
1290
1298
  allow_app_selection=self._dialog_allows_app_selection(), parent=self)
1299
+ self.project_form_opened.emit(dlg)
1291
1300
  if dlg.exec() != QDialog.Accepted:
1292
1301
  return
1293
1302
  data = dlg.get_data()
@@ -1355,6 +1364,7 @@ class ProjectBrowserPanel(QWidget):
1355
1364
  initial_data={"path": str(Path.home()/"molsuite_projects"),
1356
1365
  "app_id": self._active_tool_app_id or apps[0].app_id},
1357
1366
  allow_app_selection=self._dialog_allows_app_selection(), parent=self)
1367
+ self.project_form_opened.emit(dlg)
1358
1368
  if dlg.exec() != QDialog.Accepted:
1359
1369
  return
1360
1370
  fd = dlg.get_data()
@@ -38,6 +38,7 @@ def _project_color(index: int) -> str:
38
38
  class ProjectsMenuWidget(QFrame):
39
39
  project_requested = Signal(str)
40
40
  project_created = Signal(str)
41
+ project_form_opened = Signal(object)
41
42
  # The host app's open project was confirmed for deletion: the host closes it, then deletes it.
42
43
  active_project_delete_requested = Signal(str)
43
44
 
@@ -173,6 +174,7 @@ class ProjectsMenuWidget(QFrame):
173
174
  allow_app_selection=self.allow_app_selection,
174
175
  parent=self,
175
176
  )
177
+ self.project_form_opened.emit(dialog)
176
178
  if dialog.exec() != ProjectFormDialog.Accepted:
177
179
  return
178
180
  data = dialog.get_data()
@@ -224,6 +226,7 @@ class ProjectsMenuWidget(QFrame):
224
226
  allow_app_selection=False,
225
227
  parent=self,
226
228
  )
229
+ self.project_form_opened.emit(dialog)
227
230
  if dialog.exec() != ProjectFormDialog.Accepted:
228
231
  return
229
232
  data = dialog.get_data()
@@ -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",
@@ -65,7 +65,8 @@ from .pagination_control import PaginationControl
65
65
  from .query_builder import QueryBuilder
66
66
  from .session_provider import SessionProvider
67
67
  from .table_config import ColumnDef, ColumnKind, FilterOperator, FilterSpec, SortSpec, TableConfig, TableLoadMode, ToolbarAction
68
- from .table_model import RAW_OBJECT_ROLE, SmartTableModel
68
+ from .table_model import COLUMN_DEF_ROLE, RAW_OBJECT_ROLE, SmartTableModel
69
+ from .table_interaction import TableInteraction
69
70
 
70
71
  _TOOLBAR_ICONS = Path(__file__).parent / "icons"
71
72
 
@@ -93,7 +94,10 @@ class SmartTableView(QWidget):
93
94
  Smart table with pagination, filtering, sorting and column configuration.
94
95
 
95
96
  Signals:
97
+ cell_pressed(object, str, Qt.KeyboardModifiers):
98
+ (raw_obj, field, modifiers) before a left press changes the selection.
96
99
  row_clicked(object): SQLModel object for the clicked row.
100
+ row_reselected(object): An already selected row was clicked again.
97
101
  row_double_clicked(object): SQLModel object for the double-clicked row.
98
102
  selection_changed(list): List of selected objects.
99
103
  edit_committed(object, str, object):
@@ -101,7 +105,9 @@ class SmartTableView(QWidget):
101
105
  data_refreshed(int): Total item count after each load.
102
106
  """
103
107
 
108
+ cell_pressed = Signal(object, str, Qt.KeyboardModifiers)
104
109
  row_clicked = Signal(object)
110
+ row_reselected = Signal(object)
105
111
  row_double_clicked = Signal(object)
106
112
  selection_changed = Signal(list)
107
113
  edit_committed = Signal(object, str, object)
@@ -321,6 +327,9 @@ class SmartTableView(QWidget):
321
327
  # ── Table View ───────────────────────
322
328
  self._table = QTableView()
323
329
  self._table.setModel(self._model)
330
+ self._input = TableInteraction(self._table)
331
+ self._input.pressed.connect(self._on_cell_pressed)
332
+ self._input.reselected.connect(lambda _view, index, _mods: self.row_reselected.emit(index.data(RAW_OBJECT_ROLE)))
324
333
  self._table.setObjectName("SmartTable")
325
334
 
326
335
  sel_mode = (QAbstractItemView.ExtendedSelection
@@ -746,6 +755,11 @@ class SmartTableView(QWidget):
746
755
  self._refresh_persistent_widgets()
747
756
  self._schedule_infinite_autofill()
748
757
 
758
+ def _on_cell_pressed(self, _view, index, modifiers):
759
+ raw, column = index.data(RAW_OBJECT_ROLE), index.data(COLUMN_DEF_ROLE)
760
+ if raw is not None and column is not None:
761
+ self.cell_pressed.emit(raw, column.field, modifiers)
762
+
749
763
  def _on_row_clicked(self, index):
750
764
  raw = self._model.data(index, RAW_OBJECT_ROLE)
751
765
  if raw is not None:
@@ -0,0 +1,42 @@
1
+ """Table input signals, independent of any viewer or data model."""
2
+
3
+ from PySide6.QtCore import QEvent, QObject, Qt, Signal
4
+ from PySide6.QtWidgets import QAbstractItemView, QApplication, QWidget
5
+
6
+
7
+ class TableInteraction(QObject):
8
+ """Observe one Qt table or the tables under a window, including tables added later.
9
+
10
+ ``pressed(view, index, modifiers)`` precedes native selection changes;
11
+ ``reselected`` reports a click on an already selected cell.
12
+ """
13
+
14
+ pressed = Signal(object, object, Qt.KeyboardModifiers)
15
+ reselected = Signal(object, object, Qt.KeyboardModifiers)
16
+
17
+ def __init__(self, parent: QWidget):
18
+ super().__init__(parent)
19
+ self._pressed = None
20
+ if isinstance(parent, QAbstractItemView):
21
+ parent.viewport().installEventFilter(self)
22
+ else:
23
+ QApplication.instance().installEventFilter(self)
24
+
25
+ def eventFilter(self, watched, event):
26
+ if (event.type() == QEvent.Type.MouseButtonPress
27
+ and event.button() == Qt.MouseButton.LeftButton
28
+ and isinstance(watched, QWidget)):
29
+ view = watched.parentWidget()
30
+ if (isinstance(view, QAbstractItemView) and watched is view.viewport()
31
+ and (view is self.parent() or self.parent().isAncestorOf(view))):
32
+ index = view.indexAt(event.position().toPoint())
33
+ if index.isValid():
34
+ self._pressed = (view, index, event.modifiers(), view.selectionModel().isSelected(index))
35
+ self.pressed.emit(view, index, event.modifiers())
36
+ elif event.type() == QEvent.Type.MouseButtonRelease and self._pressed is not None:
37
+ view, index, modifiers, was_selected = self._pressed
38
+ self._pressed = None
39
+ if (event.button() == Qt.MouseButton.LeftButton and watched is view.viewport()
40
+ and was_selected and view.indexAt(event.position().toPoint()) == index):
41
+ self.reselected.emit(view, index, modifiers)
42
+ return super().eventFilter(watched, event)
@@ -19,3 +19,4 @@ class TutorialTarget:
19
19
  class TutorialEvent:
20
20
  signal: SignalInstance | Callable[[], SignalInstance]
21
21
  accept: Callable[..., bool] | None = None
22
+ is_complete: Callable[[], bool] | None = None
@@ -1,11 +1,11 @@
1
1
  """Attach a tour to existing widgets/signals without changing their slots."""
2
2
  from __future__ import annotations
3
3
 
4
- from collections.abc import Mapping
4
+ from collections.abc import Callable, Mapping
5
5
  import weakref
6
6
 
7
7
  from PySide6.QtCore import QEvent, QObject, Qt, QTimer, Signal, SignalInstance, Slot
8
- from PySide6.QtWidgets import QApplication, QWidget
8
+ from PySide6.QtWidgets import QApplication, QDialog, QWidget
9
9
  from shiboken6 import isValid
10
10
 
11
11
  from .bindings import TutorialEvent, TutorialTarget
@@ -54,10 +54,14 @@ class TutorialController(QObject):
54
54
 
55
55
  def __init__(self, *, targets: Mapping[str, TutorialTarget],
56
56
  events: Mapping[str, TutorialEvent] | None = None,
57
+ instruction_provider: Callable[[TutorialStep], str] | None = None,
58
+ dim_background: bool = True,
57
59
  parent: QObject | None = None):
58
60
  super().__init__(parent)
59
61
  self.targets = dict(targets)
60
62
  self.events = dict(events or {})
63
+ self.instruction_provider = instruction_provider
64
+ self.dim_background = dim_background
61
65
  self._tutorial: Tutorial | None = None
62
66
  self._index = -1
63
67
  self._completed: set[str] = set()
@@ -67,7 +71,7 @@ class TutorialController(QObject):
67
71
  self._previous_focus = None
68
72
  self._tracker = QTimer(self)
69
73
  self._tracker.setInterval(80)
70
- self._tracker.timeout.connect(self._refresh)
74
+ self._tracker.timeout.connect(self.refresh)
71
75
 
72
76
  @property
73
77
  def is_active(self) -> bool:
@@ -170,7 +174,7 @@ class TutorialController(QObject):
170
174
  # overlay (and comes back). Otherwise the tour would stay active, unseen, eating Escape.
171
175
  if isinstance(target, QWidget) and isValid(target) and not target.isVisible():
172
176
  raise ValueError(f"Target '{step.target}' is hidden; provide a prepare callback")
173
- self._refresh()
177
+ self.refresh()
174
178
  if not self.is_active:
175
179
  return
176
180
  if step.advance_on is not None:
@@ -181,12 +185,18 @@ class TutorialController(QObject):
181
185
  self._target.setFocus()
182
186
  else:
183
187
  self.overlay.focus_guide()
188
+ if step.advance_on is not None and step.id not in self._completed:
189
+ check = self.events[step.advance_on].is_complete
190
+ if check is not None and check():
191
+ self._completed.add(step.id)
192
+ self.next()
184
193
  except Exception as exc:
185
194
  if self.is_active:
186
195
  self._fail(f"Cannot show tutorial step '{step.id}': {exc}")
187
196
 
188
197
  @Slot()
189
- def _refresh(self):
198
+ def refresh(self):
199
+ """Resolve the current target and instructions after an application UI change."""
190
200
  step = self.current_step
191
201
  if step is None:
192
202
  return
@@ -207,13 +217,15 @@ class TutorialController(QObject):
207
217
  if self.overlay is not None:
208
218
  self.overlay.hide()
209
219
  self.overlay.deleteLater()
210
- self._overlay = TutorialOverlay(host)
220
+ self._overlay = TutorialOverlay(host, dim_background=self.dim_background)
211
221
  self._overlay.next_requested.connect(self.next)
212
222
  self._overlay.back_requested.connect(self.back)
213
223
  self._overlay.exit_requested.connect(self.stop)
214
224
  self.destroyed.connect(self._overlay.deleteLater)
215
225
  if self.overlay.step is not step:
216
226
  self.overlay.set_step(self._tutorial.title, step, self._index, len(self._tutorial.steps))
227
+ if self.instruction_provider is not None:
228
+ self.overlay.set_text(self.instruction_provider(step))
217
229
  self.overlay.next_button.setEnabled(step.advance_on is None or step.id in self._completed)
218
230
  region = self.targets[step.target].rect
219
231
  self.overlay.reposition(target, interactive=step.advance_on is not None,
@@ -233,6 +245,14 @@ class TutorialController(QObject):
233
245
  self._completed.add(self.current_step.id)
234
246
  self.next()
235
247
 
248
+ @Slot(int)
249
+ def _on_dialog_finished(self, result: int):
250
+ if self.is_active:
251
+ if result == QDialog.DialogCode.Accepted:
252
+ self.refresh()
253
+ else:
254
+ self.stop()
255
+
236
256
  def eventFilter(self, watched: QObject, event: QEvent) -> bool:
237
257
  if not self.is_active or not isinstance(watched, QWidget) or self.overlay is None:
238
258
  return False
@@ -242,6 +262,10 @@ class TutorialController(QObject):
242
262
  self.stop()
243
263
  return True
244
264
  if event.type() == QEvent.Type.Close and watched is self.overlay.parentWidget():
265
+ if isinstance(watched, QDialog):
266
+ self.overlay.hide()
267
+ watched.finished.connect(self._on_dialog_finished, Qt.ConnectionType.SingleShotConnection)
268
+ return False
245
269
  self.stop()
246
270
  return False
247
271
  if not self.overlay.isVisible():
@@ -19,8 +19,9 @@ class TutorialOverlay(QWidget):
19
19
  back_requested = Signal()
20
20
  exit_requested = Signal()
21
21
 
22
- def __init__(self, host: QWidget):
22
+ def __init__(self, host: QWidget, *, dim_background: bool = True):
23
23
  super().__init__(host)
24
+ self.dim_background = dim_background
24
25
  self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
25
26
  self.hole = QRect()
26
27
  self.step: TutorialStep | None = None
@@ -74,6 +75,14 @@ class TutorialOverlay(QWidget):
74
75
  self.setAccessibleName(tutorial_title)
75
76
  self.setAccessibleDescription(f"{step.title}. {step.text}")
76
77
 
78
+ def set_text(self, text: str):
79
+ """Update instructions without resetting their layout or scroll position each tick."""
80
+ if self.body.toPlainText() != text:
81
+ self.body.setPlainText(text)
82
+ self._size_key = None
83
+ self._place_key = None
84
+ self.setAccessibleDescription(f"{self.step.title}. {text}")
85
+
77
86
  def reposition(self, target: QWidget, *, interactive: bool, rect: QRect | None = None):
78
87
  host = self.parentWidget()
79
88
  self.setGeometry(host.rect())
@@ -143,12 +152,13 @@ class TutorialOverlay(QWidget):
143
152
  def paintEvent(self, event):
144
153
  painter = QPainter(self)
145
154
  painter.setRenderHint(QPainter.RenderHint.Antialiasing)
146
- shade = self.palette().color(QPalette.ColorRole.Shadow)
147
- shade.setAlpha(170)
148
- path = QPainterPath()
149
- path.addRect(QRectF(self.rect()))
150
- opening = QPainterPath()
151
- opening.addRect(QRectF(self.hole))
152
- painter.fillPath(path.subtracted(opening), shade)
155
+ if self.dim_background:
156
+ shade = self.palette().color(QPalette.ColorRole.Shadow)
157
+ shade.setAlpha(170)
158
+ path = QPainterPath()
159
+ path.addRect(QRectF(self.rect()))
160
+ opening = QPainterPath()
161
+ opening.addRect(QRectF(self.hole))
162
+ painter.fillPath(path.subtracted(opening), shade)
153
163
  painter.setPen(QPen(self.palette().color(QPalette.ColorRole.Highlight), 3))
154
164
  painter.drawRoundedRect(QRectF(self.hole.adjusted(-3, -3, 3, 3)), 6, 6)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ms_components
3
- Version: 0.3.4
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
@@ -9,6 +9,7 @@ docs/api_reference.md
9
9
  docs/examples.md
10
10
  docs/index.md
11
11
  docs/ms_tutorial.md
12
+ docs/project_form_events.md
12
13
  docs/usage.md
13
14
  examples/tutorial_demo.py
14
15
  examples/tutorial_demo.toml
@@ -86,6 +87,7 @@ src/ms_components/ms_table/session_provider.py
86
87
  src/ms_components/ms_table/smart_table_view.py
87
88
  src/ms_components/ms_table/sort_panel.py
88
89
  src/ms_components/ms_table/table_config.py
90
+ src/ms_components/ms_table/table_interaction.py
89
91
  src/ms_components/ms_table/table_model.py
90
92
  src/ms_components/ms_table/test_external_filters.py
91
93
  src/ms_components/ms_table/test_toolbar.py
@@ -121,6 +123,7 @@ tests/test_query_builder_keyset.py
121
123
  tests/test_shift_hscroll.py
122
124
  tests/test_split_button_style.py
123
125
  tests/test_step_dialog.py
126
+ tests/test_table_interaction.py
124
127
  tests/test_table_toolbar.py
125
128
  tests/test_table_view_state.py
126
129
  tests/test_theme_contrast.py
@@ -9,6 +9,7 @@
9
9
  "docs/examples.md",
10
10
  "docs/index.md",
11
11
  "docs/ms_tutorial.md",
12
+ "docs/project_form_events.md",
12
13
  "docs/usage.md",
13
14
  "examples/tutorial_demo.py",
14
15
  "examples/tutorial_demo.toml",
@@ -83,6 +84,7 @@
83
84
  "src/ms_components/ms_table/smart_table_view.py",
84
85
  "src/ms_components/ms_table/sort_panel.py",
85
86
  "src/ms_components/ms_table/table_config.py",
87
+ "src/ms_components/ms_table/table_interaction.py",
86
88
  "src/ms_components/ms_table/table_model.py",
87
89
  "src/ms_components/ms_table/test_external_filters.py",
88
90
  "src/ms_components/ms_table/test_toolbar.py",
@@ -115,6 +117,7 @@
115
117
  "tests/test_shift_hscroll.py",
116
118
  "tests/test_split_button_style.py",
117
119
  "tests/test_step_dialog.py",
120
+ "tests/test_table_interaction.py",
118
121
  "tests/test_table_toolbar.py",
119
122
  "tests/test_table_view_state.py",
120
123
  "tests/test_theme_contrast.py",
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.3.5",
3
+ "distance": 0,
4
+ "node": "gcdd26ba15e13bca1b11b96c6099074cde8ce3c4f",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-27"
8
+ }
@@ -9,6 +9,51 @@ from PySide6.QtWidgets import QDialog, QMessageBox
9
9
 
10
10
  from ms_flow.core.apps import AppManifest
11
11
  from ms_components.ms_projects import ProjectsMenuWidget
12
+ from ms_components.ms_projects import ProjectFormDialog
13
+
14
+
15
+ def test_folder_selection_emits_only_when_confirmed_including_the_same_folder(monkeypatch, tmp_path):
16
+ QApplication.instance() or QApplication([])
17
+ backend = _FakeBackend()
18
+ form = ProjectFormDialog(apps=backend.list_apps(), mode="create",
19
+ initial_data={"path": str(tmp_path)})
20
+ selected, names = [], []
21
+ form.path_selected.connect(selected.append)
22
+ form.name_committed.connect(names.append)
23
+ try:
24
+ form.name_input.setText("Practice")
25
+ form.name_input.editingFinished.emit()
26
+ assert names == ["Practice"]
27
+ monkeypatch.setattr("ms_components.ms_projects.browser.QFileDialog.getExistingDirectory", lambda *a: "")
28
+ form.browse_button.click()
29
+ assert selected == [] and form.path_input.text() == str(tmp_path)
30
+ monkeypatch.setattr("ms_components.ms_projects.browser.QFileDialog.getExistingDirectory",
31
+ lambda *a: str(tmp_path))
32
+ form.browse_button.click()
33
+ assert selected == [str(tmp_path)]
34
+ assert form.path_row.isAncestorOf(form.browse_button)
35
+ assert form.save_button.text()
36
+ finally:
37
+ form.deleteLater()
38
+
39
+
40
+ def test_project_form_is_announced_before_its_modal_loop_and_cancel_does_not_create(monkeypatch):
41
+ QApplication.instance() or QApplication([])
42
+ widget = ProjectsMenuWidget(app_id="amdockvs", backend=_FakeBackend())
43
+ opened, created = [], []
44
+ widget.project_form_opened.connect(opened.append)
45
+ widget.project_created.connect(created.append)
46
+
47
+ def cancel(form):
48
+ assert opened == [form] and form.mode == "create"
49
+ return QDialog.Rejected
50
+
51
+ monkeypatch.setattr(ProjectFormDialog, "exec", cancel)
52
+ try:
53
+ widget.on_create_project()
54
+ assert len(opened) == 1 and created == []
55
+ finally:
56
+ widget.deleteLater()
12
57
 
13
58
 
14
59
  class _FakeBackend:
@@ -0,0 +1,58 @@
1
+ import pytest
2
+ from PySide6.QtCore import QPoint, Qt
3
+ from PySide6.QtTest import QTest
4
+ from PySide6.QtWidgets import QApplication
5
+ from sqlmodel import Field, SQLModel
6
+
7
+ from ms_components.ms_table import ColumnDef, SmartTableView, TableConfig
8
+
9
+
10
+ class _InteractionRow(SQLModel, table=True):
11
+ id: int = Field(primary_key=True)
12
+
13
+
14
+ @pytest.fixture
15
+ def table(monkeypatch):
16
+ app = QApplication.instance() or QApplication(["table-interaction-test"])
17
+ monkeypatch.setattr(SmartTableView, "refresh", lambda self: None)
18
+ widget = SmartTableView(
19
+ db=object(), config=TableConfig(
20
+ model_class=_InteractionRow, columns=[ColumnDef("id")], show_row_numbers=False))
21
+ widget._model.load_data([
22
+ {"id": number, "__raw__": _InteractionRow(id=number)} for number in (1, 2)
23
+ ], 2, 1)
24
+ widget.resize(320, 260)
25
+ widget.show()
26
+ app.processEvents()
27
+ yield widget
28
+ widget.close()
29
+
30
+
31
+ @pytest.mark.parametrize("modifiers", [Qt.NoModifier, Qt.ShiftModifier, Qt.ControlModifier])
32
+ def test_cell_pressed_reports_modifiers_before_selection_and_keeps_row_clicked(table, modifiers):
33
+ events = []
34
+ table.cell_pressed.connect(lambda obj, field, mods: events.append(
35
+ ("pressed", obj.id, field, mods, table.get_selected_objects())))
36
+ table.row_clicked.connect(lambda obj: events.append(("clicked", obj.id)))
37
+ position = table.view.visualRect(table._model.index(1, 0)).center()
38
+ QTest.mouseClick(table.view.viewport(), Qt.LeftButton, modifiers, position)
39
+ assert events == [("pressed", 2, "id", modifiers, []), ("clicked", 2)]
40
+
41
+
42
+ def test_empty_space_emits_no_cell_press(table):
43
+ pressed = []
44
+ table.cell_pressed.connect(lambda *args: pressed.append(args))
45
+ QTest.mouseClick(table.view.viewport(), Qt.LeftButton, Qt.NoModifier,
46
+ QPoint(10, table.view.viewport().height() - 2))
47
+ assert pressed == []
48
+
49
+
50
+ def test_reselecting_a_row_has_its_own_signal_without_faking_a_selection_change(table):
51
+ selected, reselected = [], []
52
+ table.selection_changed.connect(lambda rows: selected.append([row.id for row in rows]))
53
+ table.row_reselected.connect(lambda row: reselected.append(row.id))
54
+ position = table.view.visualRect(table._model.index(1, 0)).center()
55
+ QTest.mouseClick(table.view.viewport(), Qt.LeftButton, Qt.NoModifier, position)
56
+ QTest.mouseClick(table.view.viewport(), Qt.LeftButton, Qt.NoModifier, position)
57
+ assert selected == [[2]]
58
+ assert reselected == [2]
@@ -100,6 +100,82 @@ class Emitter(QObject):
100
100
  done = Signal(str, str)
101
101
 
102
102
 
103
+ def test_completion_while_on_another_step_is_checked_on_return(ui):
104
+ app, window, button, other, controller = ui
105
+ emitter = Emitter()
106
+ completed = set()
107
+ controller.events["job.done"] = TutorialEvent(
108
+ emitter.done, accept=lambda job, status: job == "expected" and status == "completed",
109
+ is_complete=lambda: "expected" in completed,
110
+ )
111
+ controller.start(Tutorial("jobs", "Jobs", (
112
+ TutorialStep("first", "run", "Input", "Review input."),
113
+ TutorialStep("job", "run", "Run", "Wait.", "job.done"),
114
+ TutorialStep("last", "other", "Output", "Review output."),
115
+ )))
116
+ controller.next()
117
+ assert controller.index == 1 and not controller.overlay.next_button.isEnabled()
118
+ controller.back()
119
+ completed.add("expected")
120
+ emitter.done.emit("expected", "completed")
121
+ app.processEvents()
122
+ assert controller.index == 0
123
+ controller.next()
124
+ assert controller.index == 2
125
+ controller.back()
126
+ assert controller.index == 1 and controller.overlay.next_button.isEnabled()
127
+
128
+
129
+ def test_dynamic_instructions_refresh_without_reflowing_unchanged_text(ui):
130
+ app, window, button, other, controller = ui
131
+ instructions = ["Wait for the operation."]
132
+ controller.instruction_provider = lambda step: instructions[0]
133
+ controller.start(tour())
134
+ assert controller.overlay.body.toPlainText() == instructions[0]
135
+ instructions[0] = "The operation is ready. " * 100
136
+ controller.refresh()
137
+ assert controller.overlay.body.toPlainText() == instructions[0]
138
+ assert instructions[0] in controller.overlay.accessibleDescription()
139
+ controller.overlay.body.verticalScrollBar().setValue(20)
140
+ controller.refresh()
141
+ assert controller.overlay.body.verticalScrollBar().value() == 20
142
+
143
+
144
+ def test_outline_only_presentation_preserves_the_background(ui):
145
+ app, window, button, other, controller = ui
146
+ before = window.grab().toImage()
147
+ controller.dim_background = False
148
+ controller.start(tour(interactive=True))
149
+ overlay = controller.overlay
150
+ sample = next(QPoint(x, y) for x in range(10, window.width(), 20)
151
+ for y in range(10, window.height(), 20)
152
+ if not overlay.panel.geometry().contains(x, y)
153
+ and not overlay.hole.adjusted(-5, -5, 5, 5).contains(x, y))
154
+ after = window.grab().toImage()
155
+ assert after.pixelColor(sample) == before.pixelColor(sample)
156
+ assert overlay.hole.size() == button.size()
157
+ assert QApplication.widgetAt(button.mapToGlobal(button.rect().center())) is button
158
+
159
+
160
+ def test_accepting_a_dialog_keeps_the_tutorial_active_but_closing_cancels(ui):
161
+ app, window, button, other, controller = ui
162
+ dialog = QDialog(window)
163
+ action = QPushButton("Save", dialog)
164
+ dialog.show()
165
+ app.processEvents()
166
+ controller.targets["dialog"] = TutorialTarget(lambda: action if dialog.isVisible() else button)
167
+ controller.start(Tutorial("dialog", "Dialog", (
168
+ TutorialStep("save", "dialog", "Save", "Save the form."),
169
+ )))
170
+ dialog.accept()
171
+ app.processEvents()
172
+ assert controller.is_active and controller.overlay.parentWidget() is window
173
+ dialog.show()
174
+ controller.refresh()
175
+ dialog.close()
176
+ assert not controller.is_active
177
+
178
+
103
179
  def test_job_predicate_runs_in_ui_thread_and_ignores_stale_duplicate_events(ui):
104
180
  app, window, button, other, controller = ui
105
181
  emitter = Emitter()
@@ -184,13 +260,13 @@ def test_resize_and_long_text_keep_callout_visible_and_scrollable(ui):
184
260
  controller.start(definition)
185
261
  window.resize(480, 400)
186
262
  app.processEvents()
187
- controller._refresh()
263
+ controller.refresh()
188
264
  overlay = controller.overlay
189
265
  assert overlay.rect().contains(overlay.panel.geometry())
190
266
  assert not overlay.panel.geometry().intersects(overlay.hole)
191
267
  assert overlay.body.verticalScrollBar().maximum() > 0
192
268
  overlay.body.verticalScrollBar().setValue(20)
193
- controller._refresh()
269
+ controller.refresh()
194
270
  assert overlay.body.verticalScrollBar().value() == 20
195
271
  assert overlay.hole.contains(overlay.mapFromGlobal(button.mapToGlobal(QPoint(2, 2))))
196
272
 
@@ -217,7 +293,7 @@ def test_menu_target_region_follows_the_action_geometry(ui):
217
293
  previous = controller.overlay.hole
218
294
  file_menu.menuAction().setText("Projects and input files")
219
295
  app.processEvents()
220
- controller._refresh()
296
+ controller.refresh()
221
297
  assert controller.overlay.hole == expected_region()
222
298
  assert controller.overlay.hole.left() > previous.left()
223
299
 
@@ -263,7 +339,7 @@ def test_deleted_target_and_prepare_errors_fail_with_cleanup(ui):
263
339
  controller.start(tour())
264
340
  button.deleteLater()
265
341
  QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
266
- controller._refresh()
342
+ controller.refresh()
267
343
  assert failures and "unavailable" in failures[-1]
268
344
  assert not controller.is_active and controller.overlay is None
269
345
 
@@ -1,4 +0,0 @@
1
- from .browser import ProjectBrowserPanel, ProjectBrowserWindow
2
- from .widget import ProjectsMenuWidget
3
-
4
- __all__ = ["ProjectBrowserPanel", "ProjectBrowserWindow", "ProjectsMenuWidget"]
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.3.4",
3
- "distance": 0,
4
- "node": "g005ee17c754e432cd527d9eaf5e3cc9ce26f6412",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-10-25"
8
- }
File without changes
File without changes
File without changes
File without changes