ms-components 0.3.1__tar.gz → 0.3.3__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 (131) hide show
  1. {ms_components-0.3.1/src/ms_components.egg-info → ms_components-0.3.3}/PKG-INFO +1 -1
  2. ms_components-0.3.3/src/ms_components/backdrop.py +34 -0
  3. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/base.qss +37 -4
  4. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_dockwidget/widget.py +4 -1
  5. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_settings/panel.py +3 -2
  6. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/cell_delegate.py +36 -24
  7. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/query_builder.py +6 -4
  8. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/smart_table_view.py +25 -10
  9. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/table_config.py +10 -1
  10. ms_components-0.3.3/src/ms_components/palettes.py +39 -0
  11. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/theme.py +98 -134
  12. {ms_components-0.3.1 → ms_components-0.3.3/src/ms_components.egg-info}/PKG-INFO +1 -1
  13. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components.egg-info/SOURCES.txt +1 -0
  14. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components.egg-info/scm_file_list.json +1 -0
  15. ms_components-0.3.3/src/ms_components.egg-info/scm_version.json +8 -0
  16. ms_components-0.3.3/tests/test_progress_column.py +48 -0
  17. {ms_components-0.3.1 → ms_components-0.3.3}/tests/test_query_builder_keyset.py +54 -0
  18. ms_components-0.3.3/tests/test_theme_contrast.py +68 -0
  19. ms_components-0.3.1/src/ms_components/palettes.py +0 -29
  20. ms_components-0.3.1/src/ms_components.egg-info/scm_version.json +0 -8
  21. ms_components-0.3.1/tests/test_progress_column.py +0 -36
  22. ms_components-0.3.1/tests/test_theme_contrast.py +0 -31
  23. {ms_components-0.3.1 → ms_components-0.3.3}/.github/workflows/docs.yml +0 -0
  24. {ms_components-0.3.1 → ms_components-0.3.3}/.github/workflows/publish.yml +0 -0
  25. {ms_components-0.3.1 → ms_components-0.3.3}/.gitignore +0 -0
  26. {ms_components-0.3.1 → ms_components-0.3.3}/LICENSE +0 -0
  27. {ms_components-0.3.1 → ms_components-0.3.3}/README.md +0 -0
  28. {ms_components-0.3.1 → ms_components-0.3.3}/docs/api_reference.md +0 -0
  29. {ms_components-0.3.1 → ms_components-0.3.3}/docs/examples.md +0 -0
  30. {ms_components-0.3.1 → ms_components-0.3.3}/docs/index.md +0 -0
  31. {ms_components-0.3.1 → ms_components-0.3.3}/docs/ms_tutorial.md +0 -0
  32. {ms_components-0.3.1 → ms_components-0.3.3}/docs/usage.md +0 -0
  33. {ms_components-0.3.1 → ms_components-0.3.3}/examples/tutorial_demo.py +0 -0
  34. {ms_components-0.3.1 → ms_components-0.3.3}/examples/tutorial_demo.toml +0 -0
  35. {ms_components-0.3.1 → ms_components-0.3.3}/pyproject.toml +0 -0
  36. {ms_components-0.3.1 → ms_components-0.3.3}/setup.cfg +0 -0
  37. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/__init__.py +0 -0
  38. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/chips.py +0 -0
  39. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/listview.py +0 -0
  40. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_dockwidget/__init__.py +0 -0
  41. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_dockwidget/resources/__init__.py +0 -0
  42. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_dockwidget/resources/icons/__init__.py +0 -0
  43. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_dockwidget/resources/icons/folder.svg +0 -0
  44. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_executors.py +0 -0
  45. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_monitor/__init__.py +0 -0
  46. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_monitor/bridge.py +0 -0
  47. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_monitor/cards.py +0 -0
  48. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_monitor/config.py +0 -0
  49. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_monitor/detail_panel.py +0 -0
  50. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_monitor/formatting.py +0 -0
  51. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_monitor/job_cards.py +0 -0
  52. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_monitor/models.py +0 -0
  53. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_monitor/palette.py +0 -0
  54. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_monitor/polling.py +0 -0
  55. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_monitor/widget.py +0 -0
  56. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_projects/__init__.py +0 -0
  57. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_projects/app.py +0 -0
  58. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_projects/browser.py +0 -0
  59. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_projects/scratch.py +0 -0
  60. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_projects/widget.py +0 -0
  61. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_pymol/__init__.py +0 -0
  62. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_pymol/icons/actions.svg +0 -0
  63. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_pymol/icons/box.svg +0 -0
  64. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_pymol/icons/center.svg +0 -0
  65. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_pymol/icons/display.svg +0 -0
  66. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_pymol/icons/hide.svg +0 -0
  67. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_pymol/icons/menu.svg +0 -0
  68. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_pymol/icons/orient.svg +0 -0
  69. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_pymol/icons/presets.svg +0 -0
  70. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_pymol/icons/rock.svg +0 -0
  71. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_pymol/icons/selection.svg +0 -0
  72. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_pymol/icons/settings.svg +0 -0
  73. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_pymol/icons/zoom.svg +0 -0
  74. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_pymol/plugins.py +0 -0
  75. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_pymol/widget.py +0 -0
  76. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_settings/__init__.py +0 -0
  77. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_stepper.py +0 -0
  78. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/__init__.py +0 -0
  79. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/action_header.py +0 -0
  80. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/column_panel.py +0 -0
  81. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/example_usage.py +0 -0
  82. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/filter_panel.py +0 -0
  83. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/icons/columns.svg +0 -0
  84. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/icons/export.svg +0 -0
  85. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/icons/filter.svg +0 -0
  86. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/icons/menu.svg +0 -0
  87. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/icons/reload.svg +0 -0
  88. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/icons/search.svg +0 -0
  89. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/icons/settings.svg +0 -0
  90. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/icons/sort-asc.svg +0 -0
  91. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/icons/sort-desc.svg +0 -0
  92. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/icons/sort.svg +0 -0
  93. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/metric_filter_bar.py +0 -0
  94. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/pagination_control.py +0 -0
  95. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/session_provider.py +0 -0
  96. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/sort_panel.py +0 -0
  97. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/table_model.py +0 -0
  98. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/test_external_filters.py +0 -0
  99. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_table/test_toolbar.py +0 -0
  100. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_tutorial/__init__.py +0 -0
  101. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_tutorial/bindings.py +0 -0
  102. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_tutorial/controller.py +0 -0
  103. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_tutorial/models.py +0 -0
  104. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/ms_tutorial/overlay.py +0 -0
  105. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/opengl.py +0 -0
  106. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/pagination.py +0 -0
  107. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/step_dialog.py +0 -0
  108. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/tool_panel.py +0 -0
  109. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components/wheel.py +0 -0
  110. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components.egg-info/dependency_links.txt +0 -0
  111. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components.egg-info/requires.txt +0 -0
  112. {ms_components-0.3.1 → ms_components-0.3.3}/src/ms_components.egg-info/top_level.txt +0 -0
  113. {ms_components-0.3.1 → ms_components-0.3.3}/tests/test_app_settings.py +0 -0
  114. {ms_components-0.3.1 → ms_components-0.3.3}/tests/test_chips.py +0 -0
  115. {ms_components-0.3.1 → ms_components-0.3.3}/tests/test_dock_window.py +0 -0
  116. {ms_components-0.3.1 → ms_components-0.3.3}/tests/test_executors_ui.py +0 -0
  117. {ms_components-0.3.1 → ms_components-0.3.3}/tests/test_molsuite_monitor_bridge.py +0 -0
  118. {ms_components-0.3.1 → ms_components-0.3.3}/tests/test_molsuite_monitor_widgets.py +0 -0
  119. {ms_components-0.3.1 → ms_components-0.3.3}/tests/test_monitor_config.py +0 -0
  120. {ms_components-0.3.1 → ms_components-0.3.3}/tests/test_ms_projects_widget.py +0 -0
  121. {ms_components-0.3.1 → ms_components-0.3.3}/tests/test_opengl.py +0 -0
  122. {ms_components-0.3.1 → ms_components-0.3.3}/tests/test_project_browser.py +0 -0
  123. {ms_components-0.3.1 → ms_components-0.3.3}/tests/test_pymol_grid.py +0 -0
  124. {ms_components-0.3.1 → ms_components-0.3.3}/tests/test_pymol_toolbar.py +0 -0
  125. {ms_components-0.3.1 → ms_components-0.3.3}/tests/test_shift_hscroll.py +0 -0
  126. {ms_components-0.3.1 → ms_components-0.3.3}/tests/test_split_button_style.py +0 -0
  127. {ms_components-0.3.1 → ms_components-0.3.3}/tests/test_step_dialog.py +0 -0
  128. {ms_components-0.3.1 → ms_components-0.3.3}/tests/test_table_toolbar.py +0 -0
  129. {ms_components-0.3.1 → ms_components-0.3.3}/tests/test_table_view_state.py +0 -0
  130. {ms_components-0.3.1 → ms_components-0.3.3}/tests/test_tutorial.py +0 -0
  131. {ms_components-0.3.1 → ms_components-0.3.3}/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.3
4
4
  Summary: PySide6 UI components for MolSuite: smart table, project browser, job monitor, PyMOL dock, steppers.
5
5
  Author: Mario S. Valdés-Tresanco, Mario E. Valdés-Tresanco
6
6
  License-Expression: MIT
@@ -0,0 +1,34 @@
1
+ """A faint, centred icon painted under a widget's own children (welcome pages, empty states)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from PySide6.QtCore import QRect, QSize
6
+ from PySide6.QtGui import QIcon, QPainter
7
+ from PySide6.QtWidgets import QWidget
8
+
9
+
10
+ class Backdrop(QWidget):
11
+ """``icon`` drawn at ``opacity``, scaled to 80 % of the shorter side; ``None`` draws nothing."""
12
+
13
+ # One fixed-size render, scaled: asking the icon for each widget size would cache a
14
+ # multi-MB pixmap per size (~1 GB after a long window resize with a caching icon engine).
15
+ RENDER_PX = 512
16
+
17
+ def __init__(self, icon: QIcon | None = None, parent: QWidget | None = None, *, opacity: float = 0.06):
18
+ super().__init__(parent)
19
+ self._icon = icon
20
+ self._opacity = opacity
21
+
22
+ def paintEvent(self, event) -> None:
23
+ if self._icon is not None:
24
+ side = int(min(self.width(), self.height()) * 0.8)
25
+ painter = QPainter(self)
26
+ painter.setOpacity(self._opacity)
27
+ painter.setRenderHint(QPainter.SmoothPixmapTransform)
28
+ painter.drawPixmap(QRect((self.width() - side) // 2, (self.height() - side) // 2, side, side),
29
+ self._icon.pixmap(QSize(self.RENDER_PX, self.RENDER_PX)))
30
+ painter.end()
31
+ super().paintEvent(event)
32
+
33
+
34
+ __all__ = ["Backdrop"]
@@ -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
@@ -98,7 +111,8 @@ QTextEdit,
98
111
  QSpinBox,
99
112
  QDoubleSpinBox,
100
113
  QComboBox {
101
- border: 1px solid palette(mid);
114
+ /* shadow = the control edge (3:1 on every surface); mid stays for hairlines */
115
+ border: 1px solid palette(shadow);
102
116
  border-radius: 4px;
103
117
  padding: 3px 6px;
104
118
  }
@@ -123,7 +137,7 @@ QComboBox:focus {
123
137
  }
124
138
 
125
139
  QPushButton {
126
- border: 1px solid palette(mid);
140
+ border: 1px solid palette(shadow);
127
141
  border-radius: 4px;
128
142
  padding: 5px 14px;
129
143
  }
@@ -141,19 +155,38 @@ QPushButton:default {
141
155
  border-color: palette(highlight);
142
156
  }
143
157
 
158
+ QLineEdit:disabled,
159
+ QPlainTextEdit:disabled,
160
+ QTextEdit:disabled,
161
+ QSpinBox:disabled,
162
+ QDoubleSpinBox:disabled,
163
+ QComboBox:disabled,
164
+ QPushButton:disabled,
165
+ QToolButton[popupMode="MenuButtonPopup"]:disabled {
166
+ border-color: palette(mid);
167
+ }
168
+
144
169
  /* A split button (QToolButton with a menu arrow) is an action button, not a toolbar icon:
145
170
  framed like QPushButton, the arrow in its own segment. */
146
171
  QToolButton[popupMode="MenuButtonPopup"] {
147
- border: 1px solid palette(mid);
172
+ border: 1px solid palette(shadow);
148
173
  padding: 5px 14px;
149
174
  padding-right: 24px;
150
175
  }
151
176
 
152
177
  QToolButton[popupMode="MenuButtonPopup"]::menu-button {
153
- border-left: 1px solid palette(mid);
178
+ border-left: 1px solid palette(shadow);
154
179
  width: 18px;
155
180
  }
156
181
 
182
+ /* Qt's own tooltip colours are a pale grey that dark themes leave unreadable. */
183
+ QToolTip {
184
+ background: palette(window);
185
+ color: palette(text);
186
+ border: 1px solid palette(highlight);
187
+ padding: 4px;
188
+ }
189
+
157
190
  /* Card = content surface. Nav trees/lists stay flat unless opted in. */
158
191
  QTableView,
159
192
  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(
@@ -311,8 +311,8 @@ class AppSettingsPanel(QWidget):
311
311
  font-weight: 700;
312
312
  }
313
313
  QLabel#sourceDefault { color: palette(text); background: rgba(128, 128, 128, 0.18); }
314
- QLabel#sourceGlobal { color: #4c9be8; background: rgba(76, 155, 232, 0.16); }
315
- QLabel#sourceProject { color: #34c88a; background: rgba(52, 200, 138, 0.16); }
314
+ QLabel#sourceGlobal { background: rgba(76, 155, 232, 0.16); }
315
+ QLabel#sourceProject { background: rgba(52, 200, 138, 0.16); }
316
316
  """
317
317
  )
318
318
 
@@ -743,6 +743,7 @@ class AppSettingsPanel(QWidget):
743
743
  normalized = source if source in _SOURCE_LABELS else "default"
744
744
  label.setText(_SOURCE_LABELS[normalized])
745
745
  label.setObjectName(f"source{normalized.title()}")
746
+ label.setProperty("tone", {"global": "blue", "project": "green"}.get(normalized, "")) # theme text hue
746
747
  label.style().unpolish(label)
747
748
  label.style().polish(label)
748
749
 
@@ -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)."""
@@ -57,7 +57,7 @@ from PySide6.QtWidgets import (
57
57
  QHeaderView, QLabel, QMenu, QPushButton, QSizePolicy, QFrame,
58
58
  QTableView, QToolBar, QToolButton, QVBoxLayout, QWidget
59
59
  )
60
- from ..theme import color
60
+ from ..backdrop import Backdrop
61
61
  from .action_header import ActionHeader, FilterPopup, SearchPopup
62
62
  from .cell_delegate import CellWidgetDelegate
63
63
  from .column_panel import ColumnConfigDialog
@@ -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
 
@@ -326,21 +335,26 @@ class SmartTableView(QWidget):
326
335
  self._empty_label.setObjectName("EmptyLabel")
327
336
  self._empty_label.setAlignment(Qt.AlignCenter)
328
337
  action = self._config.empty_action
329
- if action is None:
338
+ if action is None and self._config.empty_backdrop is None:
330
339
  self._empty_widget = self._empty_label
331
340
  else:
332
- self._empty_widget = QWidget()
341
+ self._empty_widget = Backdrop(self._config.empty_backdrop, opacity=0.08)
333
342
  empty_layout = QVBoxLayout(self._empty_widget)
334
343
  empty_layout.setAlignment(Qt.AlignCenter)
335
344
  empty_layout.setSpacing(12)
345
+ self._empty_label.setStyleSheet("font-size: 16px;")
346
+ empty_layout.addWidget(self._empty_label)
347
+ if action is not None:
336
348
  button = QPushButton(action.label)
337
349
  button.setObjectName("EmptyActionButton")
338
350
  if action.icon is not None:
339
351
  button.setIcon(action.icon)
340
352
  button.setToolTip(action.tooltip or action.label)
353
+ button.setCursor(Qt.CursorShape.PointingHandCursor)
354
+ button.setStyleSheet("font-size: 15px; padding: 8px 24px;")
341
355
  if action.on_click is not None:
342
356
  button.clicked.connect(lambda _=False, cb=action.on_click: cb([]))
343
- empty_layout.addWidget(self._empty_label)
357
+ empty_layout.addSpacing(16)
344
358
  empty_layout.addWidget(button, alignment=Qt.AlignCenter)
345
359
  self._empty_widget.setVisible(False)
346
360
  root.addWidget(self._empty_widget, stretch=1)
@@ -749,8 +763,9 @@ class SmartTableView(QWidget):
749
763
  self.load_next_page()
750
764
 
751
765
  def _on_refresh_clicked(self):
752
- # Reload this table, then let the host (e.g. DockingStudio) refresh surrounding state.
753
- self.refresh()
766
+ # Reload this table where the user is looking (scroll and selection kept), then let
767
+ # the host (e.g. DockingStudio) refresh surrounding state.
768
+ self.refresh_preserving_view()
754
769
  self.refresh_clicked.emit()
755
770
 
756
771
  # ──────────────────────────────────────────
@@ -839,14 +854,14 @@ class SmartTableView(QWidget):
839
854
  def _apply_toolbar_chrome(self) -> None:
840
855
  """Paint the toolbar as a band so the actions read as chrome, not as content.
841
856
 
842
- `surface0` and not a palette role: window/base/alternate-base are within a couple
843
- of steps of each other in the flat light themes, so a role-based fill separates
844
- nothing. Re-applied on PaletteChange, so it still follows a live theme switch.
857
+ The header's tone (`dark`): window/base/alternate-base are within a couple of steps
858
+ of each other in the flat light themes, so they separate nothing. Re-applied on
859
+ PaletteChange, so it still follows a live theme switch.
845
860
  """
846
861
  if self._toolbar_row is None:
847
862
  return
848
863
  self._toolbar_row.setStyleSheet(
849
- f"#SmartTableToolbar {{ background: {color('surface0').name()};"
864
+ "#SmartTableToolbar { background: palette(dark);"
850
865
  " border-bottom: 1px solid palette(mid); }"
851
866
  " QToolBar { background: transparent; border: 0; }"
852
867
  )
@@ -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
  #
@@ -370,6 +377,8 @@ class TableConfig:
370
377
  # action (import, create), a button that triggers it is more useful than
371
378
  # `empty_message`. When set it replaces the text. `on_click` receives [].
372
379
  empty_action: Optional["ToolbarAction"] = None
380
+ # Faint illustration under the empty state (a QIcon; see ms_components.backdrop).
381
+ empty_backdrop: Optional[Any] = None
373
382
  # (row_data: dict) -> QColor | None. Tints a whole row from host state that is not a
374
383
  # column (a marked/selected set, a validation flag). None = leave the default painting.
375
384
  row_background: Optional[Callable] = None
@@ -0,0 +1,39 @@
1
+ """The color values of every theme, as data: no Qt, so a settings model can list their names.
2
+
3
+ Palette data from **qt-themes** by Beat Reichenbach (MIT, see `ms_components.theme`), stored
4
+ here per QPalette role and nudged to the contrast floors that `tests/test_theme_contrast.py`
5
+ enforces. How the keys become a QPalette: `ms_components.theme.build_palette`.
6
+
7
+ Keys are the QPalette roles in snake_case, so a value is exactly what `palette(role)` returns
8
+ in a stylesheet. What MolSuite asks of the roles Qt leaves loosely defined:
9
+
10
+ * ``mid``: hairlines (separators, frames, table and group borders). Decorative.
11
+ * ``shadow``: the edge of a control (field, button, checkbox, radio); >= 3:1 on every surface.
12
+ * ``dark``: table header surface. ``midlight``: thin progress tracks.
13
+ * ``placeholder_text``: muted text (hints, meta lines); >= 4.5:1, below the body text.
14
+ * ``disabled_text``: the Disabled group's text; dimmer than a hint, so "off" reads as off.
15
+
16
+ The hues (red, orange, yellow, green, cyan, blue, magenta) have no QPalette role: read them
17
+ with `ms_components.theme.color`. Each is >= 4.5:1 on the theme's surfaces, since they are
18
+ used as text (errors, warnings) as well as fills.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ # Auto mode picks these based on the OS color scheme.
23
+ DEFAULT_LIGHT = "github_light"
24
+ DEFAULT_DARK = "catppuccin_mocha"
25
+
26
+ THEMES: dict[str, dict[str, str]] = {
27
+ "catppuccin_frappe": {"window": "#303446", "window_text": "#c6d0f5", "base": "#292c3c", "alternate_base": "#303446", "tooltip_base": "#292c3c", "tooltip_text": "#949cbb", "placeholder_text": "#959dbb", "text": "#c6d0f5", "button": "#303446", "button_text": "#c6d0f5", "bright_text": "#08080a", "light": "#232634", "midlight": "#292c3c", "mid": "#626880", "dark": "#51576d", "shadow": "#777d97", "highlight": "#ca9ee6", "highlighted_text": "#232634", "link": "#8caaee", "link_visited": "#8caaee", "accent": "#8caaee", "disabled_text": "#6c728c", "red": "#e78284", "orange": "#ef9f76", "yellow": "#e5c890", "green": "#a6d189", "cyan": "#99d1db", "blue": "#8caaee", "magenta": "#ca9ee6"},
28
+ "catppuccin_latte": {"window": "#ccd0da", "window_text": "#4c4f69", "base": "#ffffff", "alternate_base": "#e6e9ef", "tooltip_base": "#e6e9ef", "tooltip_text": "#666879", "placeholder_text": "#555871", "text": "#4c4f69", "button": "#ccd0da", "button_text": "#4c4f69", "bright_text": "#6d7196", "light": "#ffffff", "midlight": "#e6e9ef", "mid": "#9ca0b0", "dark": "#bfc3ce", "shadow": "#70737f", "highlight": "#8839ef", "highlighted_text": "#ffffff", "link": "#094cd2", "link_visited": "#094cd2", "accent": "#094cd2", "disabled_text": "#7a7d92", "red": "#b30d31", "orange": "#9b3901", "yellow": "#7c4f10", "green": "#29661b", "cyan": "#025f83", "blue": "#094cd2", "magenta": "#7113ec"},
29
+ "catppuccin_macchiato": {"window": "#24273a", "window_text": "#cad3f5", "base": "#1e2030", "alternate_base": "#24273a", "tooltip_base": "#1e2030", "tooltip_text": "#939ab7", "placeholder_text": "#888eaa", "text": "#cad3f5", "button": "#24273a", "button_text": "#cad3f5", "bright_text": "#08090a", "light": "#181926", "midlight": "#1e2030", "mid": "#5b6078", "dark": "#494d64", "shadow": "#6e738d", "highlight": "#c6a0f6", "highlighted_text": "#181926", "link": "#8aadf4", "link_visited": "#8aadf4", "accent": "#8aadf4", "disabled_text": "#61677f", "red": "#ed8796", "orange": "#f5a97f", "yellow": "#eed49f", "green": "#a6da95", "cyan": "#91d7e3", "blue": "#8aadf4", "magenta": "#c6a0f6"},
30
+ "catppuccin_mocha": {"window": "#1e1e2e", "window_text": "#cdd6f4", "base": "#181825", "alternate_base": "#1e1e2e", "tooltip_base": "#181825", "tooltip_text": "#9399b2", "placeholder_text": "#82879f", "text": "#cdd6f4", "button": "#1e1e2e", "button_text": "#cdd6f4", "bright_text": "#090a0b", "light": "#11111b", "midlight": "#181825", "mid": "#585b70", "dark": "#45475a", "shadow": "#6c7086", "highlight": "#cba6f7", "highlighted_text": "#11111b", "link": "#89b4fa", "link_visited": "#89b4fa", "accent": "#89b4fa", "disabled_text": "#5d6075", "red": "#f38ba8", "orange": "#fab387", "yellow": "#f9e2af", "green": "#a6e3a1", "cyan": "#89dceb", "blue": "#89b4fa", "magenta": "#cba6f7"},
31
+ "dracula": {"window": "#2d2f3c", "window_text": "#f8f8f2", "base": "#282a36", "alternate_base": "#2d2f3c", "tooltip_base": "#282a36", "tooltip_text": "#9ea0a6", "placeholder_text": "#97989b", "text": "#f8f8f2", "button": "#2d2f3c", "button_text": "#f8f8f2", "bright_text": "#070707", "light": "#1d1b22", "midlight": "#282a36", "mid": "#44475a", "dark": "#474959", "shadow": "#767985", "highlight": "#bd93f9", "highlighted_text": "#1d1b22", "link": "#ff79c6", "link_visited": "#ff79c6", "accent": "#ff79c6", "disabled_text": "#6e6f76", "red": "#ff6262", "orange": "#ffb86c", "yellow": "#f1fa8c", "green": "#50fa7b", "cyan": "#8be9fd", "blue": "#8a96bb", "magenta": "#bd93f9"},
32
+ "github_dark": {"window": "#262c36", "window_text": "#d1d7e0", "base": "#212830", "alternate_base": "#262c36", "tooltip_base": "#212830", "tooltip_text": "#989ea7", "placeholder_text": "#8d939c", "text": "#d1d7e0", "button": "#262c36", "button_text": "#d1d7e0", "bright_text": "#1d1e1f", "light": "#151b23", "midlight": "#212830", "mid": "#5f656f", "dark": "#4c525c", "shadow": "#727882", "highlight": "#79c0ff", "highlighted_text": "#151b23", "link": "#79c0ff", "link_visited": "#79c0ff", "accent": "#79c0ff", "disabled_text": "#656b75", "red": "#ffa198", "orange": "#ffa657", "yellow": "#e3b341", "green": "#56d364", "cyan": "#68cab2", "blue": "#79c0ff", "magenta": "#d2a8ff"},
33
+ "github_light": {"window": "#f6f8fa", "window_text": "#1f2328", "base": "#ffffff", "alternate_base": "#fbfcfd", "tooltip_base": "#fbfcfd", "tooltip_text": "#444952", "placeholder_text": "#61666e", "text": "#1f2328", "button": "#f6f8fa", "button_text": "#1f2328", "bright_text": "#a7bcd7", "light": "#ffffff", "midlight": "#fbfcfd", "mid": "#9da1a6", "dark": "#bbbec2", "shadow": "#7f838a", "highlight": "#5a94c8", "highlighted_text": "#1f2328", "link": "#0071d5", "link_visited": "#0071d5", "accent": "#0071d5", "disabled_text": "#979a9e", "red": "#e21400", "orange": "#b85600", "yellow": "#8f6b15", "green": "#22822d", "cyan": "#2c7e6a", "blue": "#0071d5", "magenta": "#9838ff"},
34
+ "modern_dark": {"window": "#2a2a2a", "window_text": "#c8c8c8", "base": "#1f1f1f", "alternate_base": "#2a2a2a", "tooltip_base": "#1f1f1f", "tooltip_text": "#868686", "placeholder_text": "#919191", "text": "#c8c8c8", "button": "#2a2a2a", "button_text": "#c8c8c8", "bright_text": "#373737", "light": "#141414", "midlight": "#1f1f1f", "mid": "#424242", "dark": "#444444", "shadow": "#737373", "highlight": "#2aa5da", "highlighted_text": "#141414", "link": "#2ad7da", "link_visited": "#2ad7da", "accent": "#2ad7da", "disabled_text": "#696969", "red": "#e16f53", "orange": "#da8a2a", "yellow": "#d7da2a", "green": "#2ada6f", "cyan": "#2ad7da", "blue": "#2aa5da", "magenta": "#c06fe4"},
35
+ "modern_light": {"window": "#e6ebf0", "window_text": "#000000", "base": "#ffffff", "alternate_base": "#eef2f6", "tooltip_base": "#eef2f6", "tooltip_text": "#484b4d", "placeholder_text": "#626568", "text": "#000000", "button": "#e6ebf0", "button_text": "#000000", "bright_text": "#ffffff", "light": "#ffffff", "midlight": "#eef2f6", "mid": "#979b9f", "dark": "#b1b6ba", "shadow": "#7d8084", "highlight": "#2590be", "highlighted_text": "#000000", "link": "#157677", "link_visited": "#157677", "accent": "#157677", "disabled_text": "#8f9295", "red": "#bb3f20", "orange": "#945c1a", "yellow": "#6b6d13", "green": "#157a3d", "cyan": "#157677", "blue": "#1a7196", "magenta": "#a22ad7"},
36
+ "monokai": {"window": "#2d2a2e", "window_text": "#fcfcfa", "base": "#221f22", "alternate_base": "#2d2a2e", "tooltip_base": "#221f22", "tooltip_text": "#939293", "placeholder_text": "#939293", "text": "#fcfcfa", "button": "#2d2a2e", "button_text": "#fcfcfa", "bright_text": "#030303", "light": "#19181a", "midlight": "#221f22", "mid": "#5b595c", "dark": "#4e4c4f", "shadow": "#757375", "highlight": "#ffd866", "highlighted_text": "#19181a", "link": "#ff6188", "link_visited": "#ff6188", "accent": "#ff6188", "disabled_text": "#6b696b", "red": "#ff6188", "orange": "#fc9867", "yellow": "#ffd866", "green": "#a9dc76", "cyan": "#91dcaf", "blue": "#78dce8", "magenta": "#ab9df2"},
37
+ "nord": {"window": "#3b4252", "window_text": "#eceff4", "base": "#2e3440", "alternate_base": "#3b4252", "tooltip_base": "#2e3440", "tooltip_text": "#d8dee9", "placeholder_text": "#abafb8", "text": "#eceff4", "button": "#3b4252", "button_text": "#eceff4", "bright_text": "#0b0b0b", "light": "#2e3440", "midlight": "#2e3440", "mid": "#4c566a", "dark": "#525a6b", "shadow": "#878e9b", "highlight": "#81a1c1", "highlighted_text": "#2e3440", "link": "#88c0d0", "link_visited": "#88c0d0", "accent": "#88c0d0", "disabled_text": "#7c828e", "red": "#d89fa4", "orange": "#daa18f", "yellow": "#ebcb8b", "green": "#a3be8c", "cyan": "#88c0d0", "blue": "#97b2cc", "magenta": "#c3a5be"},
38
+ "one_dark_two": {"window": "#282c34", "window_text": "#e6e6e6", "base": "#21252b", "alternate_base": "#282c34", "tooltip_base": "#21252b", "tooltip_text": "#969dab", "placeholder_text": "#919296", "text": "#e6e6e6", "button": "#282c34", "button_text": "#e6e6e6", "bright_text": "#191919", "light": "#1d1f23", "midlight": "#21252b", "mid": "#5b626d", "dark": "#4a505a", "shadow": "#6f7583", "highlight": "#62bac6", "highlighted_text": "#1d1f23", "link": "#eac786", "link_visited": "#eac786", "accent": "#eac786", "disabled_text": "#696b71", "red": "#e27881", "orange": "#e79c7e", "yellow": "#eac786", "green": "#98c379", "cyan": "#62bac6", "blue": "#71b9f4", "magenta": "#c88bda"},
39
+ }