ms-components 0.3.1__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 (127) hide show
  1. {ms_components-0.3.1/src/ms_components.egg-info → ms_components-0.3.2}/PKG-INFO +1 -1
  2. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/base.qss +21 -0
  3. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_dockwidget/widget.py +4 -1
  4. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/cell_delegate.py +36 -24
  5. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/query_builder.py +6 -4
  6. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/smart_table_view.py +12 -2
  7. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/table_config.py +8 -1
  8. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/theme.py +20 -1
  9. {ms_components-0.3.1 → ms_components-0.3.2/src/ms_components.egg-info}/PKG-INFO +1 -1
  10. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components.egg-info/scm_version.json +2 -2
  11. ms_components-0.3.2/tests/test_progress_column.py +48 -0
  12. {ms_components-0.3.1 → ms_components-0.3.2}/tests/test_query_builder_keyset.py +54 -0
  13. ms_components-0.3.1/tests/test_progress_column.py +0 -36
  14. {ms_components-0.3.1 → ms_components-0.3.2}/.github/workflows/docs.yml +0 -0
  15. {ms_components-0.3.1 → ms_components-0.3.2}/.github/workflows/publish.yml +0 -0
  16. {ms_components-0.3.1 → ms_components-0.3.2}/.gitignore +0 -0
  17. {ms_components-0.3.1 → ms_components-0.3.2}/LICENSE +0 -0
  18. {ms_components-0.3.1 → ms_components-0.3.2}/README.md +0 -0
  19. {ms_components-0.3.1 → ms_components-0.3.2}/docs/api_reference.md +0 -0
  20. {ms_components-0.3.1 → ms_components-0.3.2}/docs/examples.md +0 -0
  21. {ms_components-0.3.1 → ms_components-0.3.2}/docs/index.md +0 -0
  22. {ms_components-0.3.1 → ms_components-0.3.2}/docs/ms_tutorial.md +0 -0
  23. {ms_components-0.3.1 → ms_components-0.3.2}/docs/usage.md +0 -0
  24. {ms_components-0.3.1 → ms_components-0.3.2}/examples/tutorial_demo.py +0 -0
  25. {ms_components-0.3.1 → ms_components-0.3.2}/examples/tutorial_demo.toml +0 -0
  26. {ms_components-0.3.1 → ms_components-0.3.2}/pyproject.toml +0 -0
  27. {ms_components-0.3.1 → ms_components-0.3.2}/setup.cfg +0 -0
  28. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/__init__.py +0 -0
  29. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/chips.py +0 -0
  30. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/listview.py +0 -0
  31. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_dockwidget/__init__.py +0 -0
  32. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_dockwidget/resources/__init__.py +0 -0
  33. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_dockwidget/resources/icons/__init__.py +0 -0
  34. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_dockwidget/resources/icons/folder.svg +0 -0
  35. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_executors.py +0 -0
  36. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_monitor/__init__.py +0 -0
  37. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_monitor/bridge.py +0 -0
  38. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_monitor/cards.py +0 -0
  39. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_monitor/config.py +0 -0
  40. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_monitor/detail_panel.py +0 -0
  41. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_monitor/formatting.py +0 -0
  42. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_monitor/job_cards.py +0 -0
  43. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_monitor/models.py +0 -0
  44. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_monitor/palette.py +0 -0
  45. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_monitor/polling.py +0 -0
  46. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_monitor/widget.py +0 -0
  47. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_projects/__init__.py +0 -0
  48. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_projects/app.py +0 -0
  49. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_projects/browser.py +0 -0
  50. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_projects/scratch.py +0 -0
  51. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_projects/widget.py +0 -0
  52. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_pymol/__init__.py +0 -0
  53. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_pymol/icons/actions.svg +0 -0
  54. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_pymol/icons/box.svg +0 -0
  55. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_pymol/icons/center.svg +0 -0
  56. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_pymol/icons/display.svg +0 -0
  57. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_pymol/icons/hide.svg +0 -0
  58. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_pymol/icons/menu.svg +0 -0
  59. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_pymol/icons/orient.svg +0 -0
  60. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_pymol/icons/presets.svg +0 -0
  61. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_pymol/icons/rock.svg +0 -0
  62. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_pymol/icons/selection.svg +0 -0
  63. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_pymol/icons/settings.svg +0 -0
  64. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_pymol/icons/zoom.svg +0 -0
  65. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_pymol/plugins.py +0 -0
  66. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_pymol/widget.py +0 -0
  67. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_settings/__init__.py +0 -0
  68. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_settings/panel.py +0 -0
  69. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_stepper.py +0 -0
  70. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/__init__.py +0 -0
  71. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/action_header.py +0 -0
  72. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/column_panel.py +0 -0
  73. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/example_usage.py +0 -0
  74. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/filter_panel.py +0 -0
  75. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/icons/columns.svg +0 -0
  76. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/icons/export.svg +0 -0
  77. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/icons/filter.svg +0 -0
  78. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/icons/menu.svg +0 -0
  79. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/icons/reload.svg +0 -0
  80. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/icons/search.svg +0 -0
  81. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/icons/settings.svg +0 -0
  82. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/icons/sort-asc.svg +0 -0
  83. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/icons/sort-desc.svg +0 -0
  84. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/icons/sort.svg +0 -0
  85. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/metric_filter_bar.py +0 -0
  86. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/pagination_control.py +0 -0
  87. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/session_provider.py +0 -0
  88. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/sort_panel.py +0 -0
  89. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/table_model.py +0 -0
  90. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/test_external_filters.py +0 -0
  91. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_table/test_toolbar.py +0 -0
  92. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_tutorial/__init__.py +0 -0
  93. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_tutorial/bindings.py +0 -0
  94. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_tutorial/controller.py +0 -0
  95. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_tutorial/models.py +0 -0
  96. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/ms_tutorial/overlay.py +0 -0
  97. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/opengl.py +0 -0
  98. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/pagination.py +0 -0
  99. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/palettes.py +0 -0
  100. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/step_dialog.py +0 -0
  101. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/tool_panel.py +0 -0
  102. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components/wheel.py +0 -0
  103. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components.egg-info/SOURCES.txt +0 -0
  104. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components.egg-info/dependency_links.txt +0 -0
  105. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components.egg-info/requires.txt +0 -0
  106. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components.egg-info/scm_file_list.json +0 -0
  107. {ms_components-0.3.1 → ms_components-0.3.2}/src/ms_components.egg-info/top_level.txt +0 -0
  108. {ms_components-0.3.1 → ms_components-0.3.2}/tests/test_app_settings.py +0 -0
  109. {ms_components-0.3.1 → ms_components-0.3.2}/tests/test_chips.py +0 -0
  110. {ms_components-0.3.1 → ms_components-0.3.2}/tests/test_dock_window.py +0 -0
  111. {ms_components-0.3.1 → ms_components-0.3.2}/tests/test_executors_ui.py +0 -0
  112. {ms_components-0.3.1 → ms_components-0.3.2}/tests/test_molsuite_monitor_bridge.py +0 -0
  113. {ms_components-0.3.1 → ms_components-0.3.2}/tests/test_molsuite_monitor_widgets.py +0 -0
  114. {ms_components-0.3.1 → ms_components-0.3.2}/tests/test_monitor_config.py +0 -0
  115. {ms_components-0.3.1 → ms_components-0.3.2}/tests/test_ms_projects_widget.py +0 -0
  116. {ms_components-0.3.1 → ms_components-0.3.2}/tests/test_opengl.py +0 -0
  117. {ms_components-0.3.1 → ms_components-0.3.2}/tests/test_project_browser.py +0 -0
  118. {ms_components-0.3.1 → ms_components-0.3.2}/tests/test_pymol_grid.py +0 -0
  119. {ms_components-0.3.1 → ms_components-0.3.2}/tests/test_pymol_toolbar.py +0 -0
  120. {ms_components-0.3.1 → ms_components-0.3.2}/tests/test_shift_hscroll.py +0 -0
  121. {ms_components-0.3.1 → ms_components-0.3.2}/tests/test_split_button_style.py +0 -0
  122. {ms_components-0.3.1 → ms_components-0.3.2}/tests/test_step_dialog.py +0 -0
  123. {ms_components-0.3.1 → ms_components-0.3.2}/tests/test_table_toolbar.py +0 -0
  124. {ms_components-0.3.1 → ms_components-0.3.2}/tests/test_table_view_state.py +0 -0
  125. {ms_components-0.3.1 → ms_components-0.3.2}/tests/test_theme_contrast.py +0 -0
  126. {ms_components-0.3.1 → ms_components-0.3.2}/tests/test_tutorial.py +0 -0
  127. {ms_components-0.3.1 → 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.1
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
@@ -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(
@@ -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
  # ──────────────────────────────────────────
@@ -70,7 +70,7 @@ class ColumnKind(str, Enum):
70
70
  NUMBER = "number"
71
71
  INTEGER = "integer"
72
72
  CHOICE = "choice"
73
- # A fraction 0..1 drawn as a progress bar with its percentage (no formatter: the bar needs the number).
73
+ # A fraction 0..1 (or a count, with `progress_max`) drawn as a progress bar with its percentage.
74
74
  PROGRESS = "progress"
75
75
 
76
76
 
@@ -109,6 +109,11 @@ class ColumnDef:
109
109
  expr: SQLAlchemy expression producing the column value, for anything
110
110
  that is not a model attribute (JSON_EXTRACT, CASE, func.*).
111
111
  When set, `field` is only the key/label.
112
+ progress_max: PROGRESS columns only: the row key holding the maximum. Set, the
113
+ value is a count and the bar reads "value/max (pct%)", as
114
+ QProgressBar's "%v/%m (%p%)"; unset, the value is a 0..1 fraction.
115
+ progress_tone: PROGRESS columns only: callable(row_data) → a theme accent
116
+ ("green", "red"), the bar's `tone` property. Unset, "green".
112
117
  """
113
118
  field: str
114
119
  label: Optional[str] = None
@@ -130,6 +135,8 @@ class ColumnDef:
130
135
  tooltip: Any = None # str | Callable
131
136
  db_column: Optional[str] = None
132
137
  expr: Any = None # SQLAlchemy ColumnElement
138
+ progress_max: Optional[str] = None
139
+ progress_tone: Optional[Callable] = None
133
140
 
134
141
  # ── Fields for rendering widgets inside a cell ──────────────────────
135
142
  #
@@ -276,6 +276,25 @@ def resolve(name: str) -> str:
276
276
  return DEFAULT_DARK if scheme == Qt.ColorScheme.Dark else DEFAULT_LIGHT
277
277
 
278
278
 
279
+ #: The accents a widget can wear through `setProperty("tone", name)`.
280
+ TONES = ("green", "red")
281
+
282
+
283
+ def tone_qss(theme: dict[str, str] | None = None) -> str:
284
+ """`QProgressBar[tone="green"]` and siblings, from the live theme.
285
+
286
+ QSS has no palette role for the accents, so these rules are written per theme.
287
+ Translucent, so the bar's text stays readable on dark and light themes alike.
288
+ """
289
+ theme = theme or _current
290
+ rules = []
291
+ for tone in TONES:
292
+ accent = QColor(theme[tone])
293
+ rgba = f"rgba({accent.red()}, {accent.green()}, {accent.blue()}, 150)"
294
+ rules.append(f'QProgressBar[tone="{tone}"]::chunk {{ background: {rgba}; }}')
295
+ return "\n".join(rules) + "\n"
296
+
297
+
279
298
  def base_qss() -> str:
280
299
  """The shared geometry stylesheet (colors resolved from the live palette)."""
281
300
  return files(__package__).joinpath("base.qss").read_text(encoding="utf-8")
@@ -297,7 +316,7 @@ def apply_theme(name: str, app: QApplication | None = None, *,
297
316
  set_base_font(base_font_pt, app=app)
298
317
  app.setStyle("Fusion") # Fusion honors QPalette ColorRoles most faithfully
299
318
  app.setPalette(build_palette(_current))
300
- app.setStyleSheet(base_qss())
319
+ app.setStyleSheet(base_qss() + tone_qss())
301
320
 
302
321
 
303
322
  if __name__ == "__main__":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ms_components
3
- Version: 0.3.1
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
@@ -1,7 +1,7 @@
1
1
  {
2
- "tag": "0.3.1",
2
+ "tag": "0.3.2",
3
3
  "distance": 0,
4
- "node": "g7e2c3d4e709ddabdcf4c8915ba010410c3014634",
4
+ "node": "gca390289c6a1cb6d4a8c445570bbc0eb87ebd291",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-10-21"
@@ -0,0 +1,48 @@
1
+ """A PROGRESS column fills the share of the cell its fraction says, clamped to 0..1,
2
+ in the tone its row asks for."""
3
+ from __future__ import annotations
4
+
5
+ import sqlmodel # noqa: F401 (pydantic before PySide6, or its lazy import breaks)
6
+ from PySide6.QtGui import QColor, QImage, QPainter, QStandardItemModel
7
+ from PySide6.QtWidgets import QApplication, QStyleOptionViewItem, QTableView
8
+
9
+ from ms_components import theme
10
+ from ms_components.ms_table.cell_delegate import CellWidgetDelegate
11
+ from ms_components.ms_table.table_config import ColumnDef, ColumnKind
12
+
13
+
14
+ class _Model(QStandardItemModel):
15
+ rows = [{"done": 0.5}, {"done": None}, {"done": 3.0, "state": "failed"}]
16
+
17
+ def get_row_data(self, row):
18
+ return self.rows[row]
19
+
20
+
21
+ def _tone(pixel: QColor) -> str:
22
+ if pixel.green() - pixel.red() > 40:
23
+ return "green"
24
+ return "red" if pixel.red() - pixel.green() > 40 else "track"
25
+
26
+
27
+ def test_a_progress_column_fills_its_fraction_of_the_cell():
28
+ app = QApplication.instance() or QApplication([])
29
+ app.setStyleSheet(theme.base_qss() + theme.tone_qss(theme.THEMES["dracula"]))
30
+ try:
31
+ view, model = QTableView(), _Model(3, 1)
32
+ view.setModel(model)
33
+ column = ColumnDef("done", kind=ColumnKind.PROGRESS,
34
+ progress_tone=lambda row: "red" if row.get("state") else "green")
35
+ delegate = CellWidgetDelegate(0, column, view)
36
+ filled = []
37
+ for row in range(3):
38
+ image = QImage(200, 30, QImage.Format_ARGB32)
39
+ image.fill(QColor("black"))
40
+ option = QStyleOptionViewItem()
41
+ option.widget, option.rect = view, image.rect()
42
+ painter = QPainter(image)
43
+ delegate.paint(painter, option, model.index(row, 0))
44
+ painter.end()
45
+ filled.append([_tone(image.pixelColor(x, 8)) for x in (20, 180)])
46
+ assert filled == [["green", "track"], ["track", "track"], ["red", "red"]]
47
+ finally:
48
+ app.setStyleSheet("")
@@ -169,3 +169,57 @@ def test_refresh_while_scrolled_loads_the_rows_on_screen(db):
169
169
  # and the right row, not a neighbouring page's: the window is one contiguous run
170
170
  assert view._model.get_raw_object(row).id == row + 1
171
171
  view.close()
172
+
173
+
174
+ def test_rows_added_after_a_short_last_page_get_fetched_not_left_blank():
175
+ """A running job: the first load brought 3 rows, refresh_counts() then says 30. The
176
+ short page must be fetched again, or rows 3..29 show as blank rows forever."""
177
+ app = QApplication.instance() or QApplication(["ms-table-tail-test"])
178
+ engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=None)
179
+ SQLModel.metadata.create_all(engine, tables=[Hit.__table__])
180
+ add = lambda ids: (lambda s: (s.add_all([Hit(id=i, name=f"l{i}") for i in ids]), s.commit()))(Session(engine))
181
+ add(range(1, 4))
182
+ view = SmartTableView(_Db(engine), _config())
183
+ view.resize(600, 900)
184
+ view.show()
185
+ app.processEvents()
186
+ assert view._model.loaded_count == 3
187
+
188
+ for end in (11, 31): # the job keeps going: every tick, more rows
189
+ add(range(view._model.rowCount() + 1, end))
190
+ assert view.refresh_counts()
191
+ for _ in range(5):
192
+ app.processEvents()
193
+ assert view._model.rowCount() == end - 1
194
+ assert [view._model.get_raw_object(row).id for row in range(end - 1)] == list(range(1, end))
195
+ view.close()
196
+
197
+
198
+
199
+ class Gap(SQLModel, table=True):
200
+ __tablename__ = "gap"
201
+ id: Optional[int] = Field(default=None, primary_key=True)
202
+ score: Optional[float] = None
203
+
204
+
205
+ @pytest.mark.parametrize("descending", [False, True])
206
+ def test_keyset_walks_a_null_block_longer_than_a_page(descending):
207
+ """A ΔΔG column is NULL for every ligand without that off-target: the walk must keep
208
+ the whole null block (ascending: first; descending: last), not drop it after page 1."""
209
+ engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=None)
210
+ SQLModel.metadata.create_all(engine, tables=[Gap.__table__])
211
+ with Session(engine) as session:
212
+ session.add_all([Gap(id=i, score=None if i % 3 else float(i)) for i in range(1, 351)])
213
+ session.commit()
214
+ config = TableConfig(model_class=Gap, columns=[ColumnDef("score")], page_size=PAGE,
215
+ load_mode=TableLoadMode.INFINITE,
216
+ default_sort=[SortSpec("score", descending=descending)])
217
+ builder = QueryBuilder(_Db(engine), config)
218
+ seen, cursor = [], None
219
+ while True:
220
+ rows, has_more = builder.fetch_window_after(cursor)
221
+ seen += [row["__raw__"].id for row in rows]
222
+ if not has_more:
223
+ break
224
+ cursor = builder.cursor_of(rows[-1])
225
+ assert sorted(seen) == list(range(1, 351))
@@ -1,36 +0,0 @@
1
- """A PROGRESS column fills the share of the cell its fraction says, clamped to 0..1."""
2
- from __future__ import annotations
3
-
4
- import sqlmodel # noqa: F401 (pydantic before PySide6, or its lazy import breaks)
5
- from PySide6.QtGui import QColor, QImage, QPainter, QPalette, QStandardItemModel
6
- from PySide6.QtWidgets import QApplication, QStyleOptionViewItem, QTableView
7
-
8
- from ms_components.ms_table.cell_delegate import CellWidgetDelegate
9
- from ms_components.ms_table.table_config import ColumnDef, ColumnKind
10
-
11
-
12
- class _Model(QStandardItemModel):
13
- rows = [{"done": 0.5}, {"done": None}, {"done": 3.0}]
14
-
15
- def get_row_data(self, row):
16
- return self.rows[row]
17
-
18
-
19
- def test_a_progress_column_fills_its_fraction_of_the_cell():
20
- app = QApplication.instance() or QApplication([])
21
- view, model = QTableView(), _Model(3, 1)
22
- view.setModel(model)
23
- delegate = CellWidgetDelegate(0, ColumnDef("done", kind=ColumnKind.PROGRESS), view)
24
- filled = []
25
- for row in range(3):
26
- image = QImage(200, 30, QImage.Format_ARGB32)
27
- image.fill(QColor("black"))
28
- option = QStyleOptionViewItem()
29
- option.widget, option.rect = view, image.rect()
30
- option.palette.setColor(QPalette.Highlight, QColor("red"))
31
- painter = QPainter(image)
32
- delegate.paint(painter, option, model.index(row, 0))
33
- painter.end()
34
- filled.append([image.pixelColor(x, 8) == QColor("red") for x in (20, 180)])
35
- assert filled == [[True, False], [False, False], [True, True]]
36
- assert app
File without changes
File without changes
File without changes
File without changes