ms-components 0.3.2__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 (130) hide show
  1. {ms_components-0.3.2/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.2 → ms_components-0.3.3}/src/ms_components/base.qss +16 -4
  4. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_settings/panel.py +3 -2
  5. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/smart_table_view.py +13 -8
  6. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/table_config.py +2 -0
  7. ms_components-0.3.3/src/ms_components/palettes.py +39 -0
  8. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/theme.py +80 -135
  9. {ms_components-0.3.2 → ms_components-0.3.3/src/ms_components.egg-info}/PKG-INFO +1 -1
  10. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components.egg-info/SOURCES.txt +1 -0
  11. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components.egg-info/scm_file_list.json +1 -0
  12. ms_components-0.3.3/src/ms_components.egg-info/scm_version.json +8 -0
  13. ms_components-0.3.3/tests/test_theme_contrast.py +68 -0
  14. ms_components-0.3.2/src/ms_components/palettes.py +0 -29
  15. ms_components-0.3.2/src/ms_components.egg-info/scm_version.json +0 -8
  16. ms_components-0.3.2/tests/test_theme_contrast.py +0 -31
  17. {ms_components-0.3.2 → ms_components-0.3.3}/.github/workflows/docs.yml +0 -0
  18. {ms_components-0.3.2 → ms_components-0.3.3}/.github/workflows/publish.yml +0 -0
  19. {ms_components-0.3.2 → ms_components-0.3.3}/.gitignore +0 -0
  20. {ms_components-0.3.2 → ms_components-0.3.3}/LICENSE +0 -0
  21. {ms_components-0.3.2 → ms_components-0.3.3}/README.md +0 -0
  22. {ms_components-0.3.2 → ms_components-0.3.3}/docs/api_reference.md +0 -0
  23. {ms_components-0.3.2 → ms_components-0.3.3}/docs/examples.md +0 -0
  24. {ms_components-0.3.2 → ms_components-0.3.3}/docs/index.md +0 -0
  25. {ms_components-0.3.2 → ms_components-0.3.3}/docs/ms_tutorial.md +0 -0
  26. {ms_components-0.3.2 → ms_components-0.3.3}/docs/usage.md +0 -0
  27. {ms_components-0.3.2 → ms_components-0.3.3}/examples/tutorial_demo.py +0 -0
  28. {ms_components-0.3.2 → ms_components-0.3.3}/examples/tutorial_demo.toml +0 -0
  29. {ms_components-0.3.2 → ms_components-0.3.3}/pyproject.toml +0 -0
  30. {ms_components-0.3.2 → ms_components-0.3.3}/setup.cfg +0 -0
  31. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/__init__.py +0 -0
  32. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/chips.py +0 -0
  33. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/listview.py +0 -0
  34. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_dockwidget/__init__.py +0 -0
  35. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_dockwidget/resources/__init__.py +0 -0
  36. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_dockwidget/resources/icons/__init__.py +0 -0
  37. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_dockwidget/resources/icons/folder.svg +0 -0
  38. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_dockwidget/widget.py +0 -0
  39. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_executors.py +0 -0
  40. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_monitor/__init__.py +0 -0
  41. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_monitor/bridge.py +0 -0
  42. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_monitor/cards.py +0 -0
  43. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_monitor/config.py +0 -0
  44. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_monitor/detail_panel.py +0 -0
  45. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_monitor/formatting.py +0 -0
  46. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_monitor/job_cards.py +0 -0
  47. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_monitor/models.py +0 -0
  48. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_monitor/palette.py +0 -0
  49. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_monitor/polling.py +0 -0
  50. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_monitor/widget.py +0 -0
  51. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_projects/__init__.py +0 -0
  52. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_projects/app.py +0 -0
  53. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_projects/browser.py +0 -0
  54. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_projects/scratch.py +0 -0
  55. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_projects/widget.py +0 -0
  56. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_pymol/__init__.py +0 -0
  57. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_pymol/icons/actions.svg +0 -0
  58. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_pymol/icons/box.svg +0 -0
  59. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_pymol/icons/center.svg +0 -0
  60. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_pymol/icons/display.svg +0 -0
  61. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_pymol/icons/hide.svg +0 -0
  62. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_pymol/icons/menu.svg +0 -0
  63. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_pymol/icons/orient.svg +0 -0
  64. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_pymol/icons/presets.svg +0 -0
  65. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_pymol/icons/rock.svg +0 -0
  66. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_pymol/icons/selection.svg +0 -0
  67. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_pymol/icons/settings.svg +0 -0
  68. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_pymol/icons/zoom.svg +0 -0
  69. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_pymol/plugins.py +0 -0
  70. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_pymol/widget.py +0 -0
  71. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_settings/__init__.py +0 -0
  72. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_stepper.py +0 -0
  73. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/__init__.py +0 -0
  74. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/action_header.py +0 -0
  75. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/cell_delegate.py +0 -0
  76. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/column_panel.py +0 -0
  77. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/example_usage.py +0 -0
  78. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/filter_panel.py +0 -0
  79. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/icons/columns.svg +0 -0
  80. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/icons/export.svg +0 -0
  81. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/icons/filter.svg +0 -0
  82. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/icons/menu.svg +0 -0
  83. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/icons/reload.svg +0 -0
  84. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/icons/search.svg +0 -0
  85. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/icons/settings.svg +0 -0
  86. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/icons/sort-asc.svg +0 -0
  87. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/icons/sort-desc.svg +0 -0
  88. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/icons/sort.svg +0 -0
  89. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/metric_filter_bar.py +0 -0
  90. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/pagination_control.py +0 -0
  91. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/query_builder.py +0 -0
  92. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/session_provider.py +0 -0
  93. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/sort_panel.py +0 -0
  94. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/table_model.py +0 -0
  95. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/test_external_filters.py +0 -0
  96. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_table/test_toolbar.py +0 -0
  97. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_tutorial/__init__.py +0 -0
  98. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_tutorial/bindings.py +0 -0
  99. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_tutorial/controller.py +0 -0
  100. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_tutorial/models.py +0 -0
  101. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/ms_tutorial/overlay.py +0 -0
  102. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/opengl.py +0 -0
  103. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/pagination.py +0 -0
  104. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/step_dialog.py +0 -0
  105. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/tool_panel.py +0 -0
  106. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components/wheel.py +0 -0
  107. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components.egg-info/dependency_links.txt +0 -0
  108. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components.egg-info/requires.txt +0 -0
  109. {ms_components-0.3.2 → ms_components-0.3.3}/src/ms_components.egg-info/top_level.txt +0 -0
  110. {ms_components-0.3.2 → ms_components-0.3.3}/tests/test_app_settings.py +0 -0
  111. {ms_components-0.3.2 → ms_components-0.3.3}/tests/test_chips.py +0 -0
  112. {ms_components-0.3.2 → ms_components-0.3.3}/tests/test_dock_window.py +0 -0
  113. {ms_components-0.3.2 → ms_components-0.3.3}/tests/test_executors_ui.py +0 -0
  114. {ms_components-0.3.2 → ms_components-0.3.3}/tests/test_molsuite_monitor_bridge.py +0 -0
  115. {ms_components-0.3.2 → ms_components-0.3.3}/tests/test_molsuite_monitor_widgets.py +0 -0
  116. {ms_components-0.3.2 → ms_components-0.3.3}/tests/test_monitor_config.py +0 -0
  117. {ms_components-0.3.2 → ms_components-0.3.3}/tests/test_ms_projects_widget.py +0 -0
  118. {ms_components-0.3.2 → ms_components-0.3.3}/tests/test_opengl.py +0 -0
  119. {ms_components-0.3.2 → ms_components-0.3.3}/tests/test_progress_column.py +0 -0
  120. {ms_components-0.3.2 → ms_components-0.3.3}/tests/test_project_browser.py +0 -0
  121. {ms_components-0.3.2 → ms_components-0.3.3}/tests/test_pymol_grid.py +0 -0
  122. {ms_components-0.3.2 → ms_components-0.3.3}/tests/test_pymol_toolbar.py +0 -0
  123. {ms_components-0.3.2 → ms_components-0.3.3}/tests/test_query_builder_keyset.py +0 -0
  124. {ms_components-0.3.2 → ms_components-0.3.3}/tests/test_shift_hscroll.py +0 -0
  125. {ms_components-0.3.2 → ms_components-0.3.3}/tests/test_split_button_style.py +0 -0
  126. {ms_components-0.3.2 → ms_components-0.3.3}/tests/test_step_dialog.py +0 -0
  127. {ms_components-0.3.2 → ms_components-0.3.3}/tests/test_table_toolbar.py +0 -0
  128. {ms_components-0.3.2 → ms_components-0.3.3}/tests/test_table_view_state.py +0 -0
  129. {ms_components-0.3.2 → ms_components-0.3.3}/tests/test_tutorial.py +0 -0
  130. {ms_components-0.3.2 → 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.2
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"]
@@ -111,7 +111,8 @@ QTextEdit,
111
111
  QSpinBox,
112
112
  QDoubleSpinBox,
113
113
  QComboBox {
114
- 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);
115
116
  border-radius: 4px;
116
117
  padding: 3px 6px;
117
118
  }
@@ -136,7 +137,7 @@ QComboBox:focus {
136
137
  }
137
138
 
138
139
  QPushButton {
139
- border: 1px solid palette(mid);
140
+ border: 1px solid palette(shadow);
140
141
  border-radius: 4px;
141
142
  padding: 5px 14px;
142
143
  }
@@ -154,16 +155,27 @@ QPushButton:default {
154
155
  border-color: palette(highlight);
155
156
  }
156
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
+
157
169
  /* A split button (QToolButton with a menu arrow) is an action button, not a toolbar icon:
158
170
  framed like QPushButton, the arrow in its own segment. */
159
171
  QToolButton[popupMode="MenuButtonPopup"] {
160
- border: 1px solid palette(mid);
172
+ border: 1px solid palette(shadow);
161
173
  padding: 5px 14px;
162
174
  padding-right: 24px;
163
175
  }
164
176
 
165
177
  QToolButton[popupMode="MenuButtonPopup"]::menu-button {
166
- border-left: 1px solid palette(mid);
178
+ border-left: 1px solid palette(shadow);
167
179
  width: 18px;
168
180
  }
169
181
 
@@ -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
 
@@ -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
@@ -335,21 +335,26 @@ class SmartTableView(QWidget):
335
335
  self._empty_label.setObjectName("EmptyLabel")
336
336
  self._empty_label.setAlignment(Qt.AlignCenter)
337
337
  action = self._config.empty_action
338
- if action is None:
338
+ if action is None and self._config.empty_backdrop is None:
339
339
  self._empty_widget = self._empty_label
340
340
  else:
341
- self._empty_widget = QWidget()
341
+ self._empty_widget = Backdrop(self._config.empty_backdrop, opacity=0.08)
342
342
  empty_layout = QVBoxLayout(self._empty_widget)
343
343
  empty_layout.setAlignment(Qt.AlignCenter)
344
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:
345
348
  button = QPushButton(action.label)
346
349
  button.setObjectName("EmptyActionButton")
347
350
  if action.icon is not None:
348
351
  button.setIcon(action.icon)
349
352
  button.setToolTip(action.tooltip or action.label)
353
+ button.setCursor(Qt.CursorShape.PointingHandCursor)
354
+ button.setStyleSheet("font-size: 15px; padding: 8px 24px;")
350
355
  if action.on_click is not None:
351
356
  button.clicked.connect(lambda _=False, cb=action.on_click: cb([]))
352
- empty_layout.addWidget(self._empty_label)
357
+ empty_layout.addSpacing(16)
353
358
  empty_layout.addWidget(button, alignment=Qt.AlignCenter)
354
359
  self._empty_widget.setVisible(False)
355
360
  root.addWidget(self._empty_widget, stretch=1)
@@ -849,14 +854,14 @@ class SmartTableView(QWidget):
849
854
  def _apply_toolbar_chrome(self) -> None:
850
855
  """Paint the toolbar as a band so the actions read as chrome, not as content.
851
856
 
852
- `surface0` and not a palette role: window/base/alternate-base are within a couple
853
- of steps of each other in the flat light themes, so a role-based fill separates
854
- 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.
855
860
  """
856
861
  if self._toolbar_row is None:
857
862
  return
858
863
  self._toolbar_row.setStyleSheet(
859
- f"#SmartTableToolbar {{ background: {color('surface0').name()};"
864
+ "#SmartTableToolbar { background: palette(dark);"
860
865
  " border-bottom: 1px solid palette(mid); }"
861
866
  " QToolBar { background: transparent; border: 0; }"
862
867
  )
@@ -377,6 +377,8 @@ class TableConfig:
377
377
  # action (import, create), a button that triggers it is more useful than
378
378
  # `empty_message`. When set it replaces the text. `on_click` receives [].
379
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
380
382
  # (row_data: dict) -> QColor | None. Tints a whole row from host state that is not a
381
383
  # column (a marked/selected set, a validation flag). None = leave the default painting.
382
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
+ }
@@ -1,7 +1,6 @@
1
1
  """Color themes for every MolSuite Qt app (QPalette-based).
2
2
 
3
- Palette data and the QPalette role mapping are derived from **qt-themes** by
4
- Beat Reichenbach, MIT-licensed:
3
+ Palette data is derived from **qt-themes** by Beat Reichenbach, MIT-licensed:
5
4
 
6
5
  https://github.com/beatreichenbach/qt-themes
7
6
  Copyright (c) 2024 Beat Reichenbach
@@ -25,13 +24,39 @@ from __future__ import annotations
25
24
 
26
25
  from importlib.resources import files
27
26
 
28
- from PySide6.QtGui import QColor, QFont, QFontDatabase, QPalette
29
- from PySide6.QtWidgets import QApplication
27
+ from PySide6.QtCore import Qt
28
+ from PySide6.QtGui import QColor, QFont, QFontDatabase, QPalette, QPen
29
+ from PySide6.QtWidgets import QApplication, QProxyStyle, QStyle
30
30
 
31
31
  from ms_components.palettes import DEFAULT_DARK, DEFAULT_LIGHT, THEMES
32
32
 
33
33
  _current: dict[str, str] = THEMES[DEFAULT_DARK]
34
34
 
35
+ #: theme key -> QPalette role (the keys are the roles in snake_case).
36
+ _ROLES = {
37
+ "window": QPalette.ColorRole.Window,
38
+ "window_text": QPalette.ColorRole.WindowText,
39
+ "base": QPalette.ColorRole.Base,
40
+ "alternate_base": QPalette.ColorRole.AlternateBase,
41
+ "tooltip_base": QPalette.ColorRole.ToolTipBase,
42
+ "tooltip_text": QPalette.ColorRole.ToolTipText,
43
+ "placeholder_text": QPalette.ColorRole.PlaceholderText,
44
+ "text": QPalette.ColorRole.Text,
45
+ "button": QPalette.ColorRole.Button,
46
+ "button_text": QPalette.ColorRole.ButtonText,
47
+ "bright_text": QPalette.ColorRole.BrightText,
48
+ "light": QPalette.ColorRole.Light,
49
+ "midlight": QPalette.ColorRole.Midlight,
50
+ "mid": QPalette.ColorRole.Mid,
51
+ "dark": QPalette.ColorRole.Dark,
52
+ "shadow": QPalette.ColorRole.Shadow,
53
+ "highlight": QPalette.ColorRole.Highlight,
54
+ "highlighted_text": QPalette.ColorRole.HighlightedText,
55
+ "link": QPalette.ColorRole.Link,
56
+ "link_visited": QPalette.ColorRole.LinkVisited,
57
+ "accent": QPalette.ColorRole.Accent,
58
+ }
59
+
35
60
  _REMOVED_THEME_FALLBACKS = {
36
61
  "atom_one": "one_dark_two",
37
62
  "blender": DEFAULT_DARK,
@@ -39,9 +64,9 @@ _REMOVED_THEME_FALLBACKS = {
39
64
 
40
65
 
41
66
  def is_dark(theme: dict[str, str] | None = None) -> bool:
42
- """A theme is dark when its text is brighter than its base background."""
67
+ """A theme is dark when its text is brighter than its window."""
43
68
  theme = theme or _current
44
- return QColor(theme["text"]).value() > QColor(theme["base"]).value()
69
+ return QColor(theme["window_text"]).value() > QColor(theme["window"]).value()
45
70
 
46
71
 
47
72
  def current_theme() -> dict[str, str]:
@@ -74,48 +99,6 @@ def contrast_ratio(left: QColor, right: QColor) -> float:
74
99
  return (lighter + 0.05) / (darker + 0.05)
75
100
 
76
101
 
77
- def _mix(left: QColor, right: QColor, amount: float) -> QColor:
78
- return QColor.fromRgbF(*(
79
- a + (b - a) * amount
80
- for a, b in zip(left.getRgbF(), right.getRgbF())
81
- ))
82
-
83
-
84
- def _surface_with_contrast(
85
- source: QColor,
86
- *,
87
- foreground: QColor,
88
- adjacent: QColor | tuple[QColor, ...],
89
- text_minimum: float,
90
- separation_minimum: float,
91
- ) -> QColor:
92
- """Closest tint/shade satisfying both readable text and visible separation."""
93
- adjacent_colors = adjacent if isinstance(adjacent, tuple) else (adjacent,)
94
- targets = (QColor("#000000"), QColor("#ffffff"))
95
- for step in range(101):
96
- amount = step / 100
97
- for target in targets:
98
- candidate = _mix(source, target, amount)
99
- if (contrast_ratio(foreground, candidate) >= text_minimum
100
- and all(
101
- contrast_ratio(candidate, background) >= separation_minimum
102
- for background in adjacent_colors
103
- )):
104
- return candidate
105
- return source
106
-
107
-
108
- def _contrasting_text(background: QColor, theme: dict[str, str]) -> QColor:
109
- themed = [QColor(theme[key]) for key in ("text", "base", "mantle", "crust")]
110
- best = max(themed, key=lambda candidate: contrast_ratio(candidate, background))
111
- if contrast_ratio(best, background) >= 4.5:
112
- return best
113
- return max(
114
- (QColor("#000000"), QColor("#ffffff")),
115
- key=lambda candidate: contrast_ratio(candidate, background),
116
- )
117
-
118
-
119
102
  #: role -> (scale over the app font, bold). Relative on purpose: the base size is
120
103
  #: whatever Qt resolved for this desktop, so a user with a 14pt system font gets a
121
104
  #: proportional title instead of the 18px somebody typed into a stylesheet once.
@@ -175,92 +158,19 @@ def set_base_font(points: float = DEFAULT_FONT_PT, *, family: str | None = None,
175
158
 
176
159
 
177
160
  def build_palette(theme: dict[str, str]) -> QPalette:
178
- """Build a QPalette from a theme dict.
179
-
180
- Port of qt-themes' `update_palette` (MIT, Beat Reichenbach), adapted to read
181
- from a flat dict instead of a dataclass.
182
- """
183
- c = lambda role: QColor(theme[role]) # noqa: E731
161
+ """Build a QPalette from a theme dict: one key per role, plus the Disabled text."""
184
162
  Role = QPalette.ColorRole
185
163
  Group = QPalette.ColorGroup
186
164
  pal = QPalette()
187
-
188
- dark_theme = is_dark(theme)
189
- table_base = c("mantle") if dark_theme else c("crust")
190
- table_alternate = c("base") if dark_theme else c("mantle")
191
- highlight = _surface_with_contrast(
192
- c("primary"),
193
- foreground=_contrasting_text(c("primary"), theme),
194
- adjacent=(table_base, table_alternate),
195
- text_minimum=4.5,
196
- separation_minimum=3.0,
197
- )
198
- highlight_text = _contrasting_text(highlight, theme)
199
- header = _surface_with_contrast(
200
- c("surface1"),
201
- foreground=c("text"),
202
- adjacent=(table_base, table_alternate),
203
- text_minimum=4.5,
204
- separation_minimum=1.45,
205
- )
206
-
207
- h, s, v, a = c("text").getHsvF()
208
- bright_text = QColor.fromHsvF(h, s, 1 - v, a)
209
-
210
- # Active/Normal group
211
- if is_dark(theme):
212
- pal.setColor(Role.Base, c("mantle"))
213
- pal.setColor(Role.AlternateBase, c("base"))
214
- else:
215
- pal.setColor(Role.Base, c("crust"))
216
- pal.setColor(Role.AlternateBase, c("mantle"))
217
- pal.setColor(Role.Window, c("base"))
218
- pal.setColor(Role.WindowText, c("text"))
219
- pal.setColor(Role.PlaceholderText, c("overlay1"))
220
- pal.setColor(Role.Text, c("text"))
221
- pal.setColor(Role.Button, c("base"))
222
- pal.setColor(Role.ButtonText, c("text"))
223
- pal.setColor(Role.BrightText, bright_text)
224
- pal.setColor(Role.ToolTipBase, c("mantle"))
225
- pal.setColor(Role.ToolTipText, c("overlay2"))
226
-
227
- pal.setColor(Role.Highlight, highlight)
228
- pal.setColor(Role.HighlightedText, highlight_text)
229
- pal.setColor(Role.Link, c("secondary"))
230
- pal.setColor(Role.LinkVisited, c("secondary"))
231
-
232
- pal.setColor(Role.Light, c("crust"))
233
- pal.setColor(Role.Midlight, c("mantle"))
234
- # Mid is *the* border tone: `palette(mid)` is what every stylesheet uses for
235
- # hairlines, so it must be the conventional Catppuccin-style separator.
236
- pal.setColor(Role.Mid, c("surface2"))
237
- pal.setColor(Role.Dark, header)
238
- pal.setColor(Role.Shadow, c("overlay0"))
239
-
240
- # Inactive group
241
- pal.setColor(Group.Inactive, Role.Highlight, highlight)
242
- pal.setColor(Group.Inactive, Role.HighlightedText, highlight_text)
243
- pal.setColor(Group.Inactive, Role.Link, c("surface1"))
244
- pal.setColor(Group.Inactive, Role.LinkVisited, c("surface1"))
245
-
246
- # Disabled group
247
- pal.setColor(Group.Disabled, Role.WindowText, c("overlay1"))
248
- pal.setColor(Group.Disabled, Role.Base, c("base"))
249
- pal.setColor(Group.Disabled, Role.AlternateBase, c("base"))
250
- pal.setColor(Group.Disabled, Role.Text, c("overlay1"))
251
- pal.setColor(Group.Disabled, Role.PlaceholderText, c("overlay1"))
252
- pal.setColor(Group.Disabled, Role.Button, c("base"))
253
- pal.setColor(Group.Disabled, Role.ButtonText, c("overlay1"))
254
- pal.setColor(Group.Disabled, Role.BrightText, c("mantle"))
255
- pal.setColor(Group.Disabled, Role.Highlight, c("surface2"))
256
- pal.setColor(Group.Disabled, Role.HighlightedText, _contrasting_text(c("surface2"), theme))
257
- pal.setColor(Group.Disabled, Role.Link, c("surface0"))
258
- pal.setColor(Group.Disabled, Role.LinkVisited, c("surface0"))
259
-
260
- pal.setColor(Role.Accent, c("secondary"))
261
- pal.setColor(Group.Inactive, Role.Accent, c("surface1"))
262
- pal.setColor(Group.Disabled, Role.Accent, c("surface2"))
263
-
165
+ for key, role in _ROLES.items():
166
+ pal.setColor(role, QColor(theme[key])) # all groups; Disabled is overridden below
167
+ disabled = QColor(theme["disabled_text"])
168
+ for role in (Role.WindowText, Role.Text, Role.ButtonText, Role.PlaceholderText, Role.BrightText,
169
+ Role.HighlightedText, Role.Link, Role.LinkVisited, Role.Accent):
170
+ pal.setColor(Group.Disabled, role, disabled)
171
+ for role in (Role.Base, Role.AlternateBase, Role.Button):
172
+ pal.setColor(Group.Disabled, role, QColor(theme["window"]))
173
+ pal.setColor(Group.Disabled, Role.Highlight, QColor(theme["mid"]))
264
174
  return pal
265
175
 
266
176
 
@@ -278,13 +188,15 @@ def resolve(name: str) -> str:
278
188
 
279
189
  #: The accents a widget can wear through `setProperty("tone", name)`.
280
190
  TONES = ("green", "red")
191
+ HUES = ("red", "orange", "yellow", "green", "cyan", "blue", "magenta")
281
192
 
282
193
 
283
194
  def tone_qss(theme: dict[str, str] | None = None) -> str:
284
- """`QProgressBar[tone="green"]` and siblings, from the live theme.
195
+ """`QProgressBar[tone="green"]`, `QLabel[tone="blue"]` and siblings, from the live theme.
285
196
 
286
197
  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.
198
+ Bars are translucent, so their text stays readable on dark and light themes alike;
199
+ labels take the hue as text (every hue is >= 4.5:1 on the theme's surfaces).
288
200
  """
289
201
  theme = theme or _current
290
202
  rules = []
@@ -292,6 +204,7 @@ def tone_qss(theme: dict[str, str] | None = None) -> str:
292
204
  accent = QColor(theme[tone])
293
205
  rgba = f"rgba({accent.red()}, {accent.green()}, {accent.blue()}, 150)"
294
206
  rules.append(f'QProgressBar[tone="{tone}"]::chunk {{ background: {rgba}; }}')
207
+ rules += [f'QLabel[tone="{hue}"] {{ color: {theme[hue]}; }}' for hue in HUES]
295
208
  return "\n".join(rules) + "\n"
296
209
 
297
210
 
@@ -300,6 +213,37 @@ def base_qss() -> str:
300
213
  return files(__package__).joinpath("base.qss").read_text(encoding="utf-8")
301
214
 
302
215
 
216
+ class _EdgedFusion(QProxyStyle):
217
+ """Fusion with checkbox/radio outlines in `shadow`.
218
+
219
+ Fusion draws them as `window.darker(140)`, which no palette role reaches and which
220
+ sits near 1.5:1 in most themes. A QSS `::indicator` rule would replace the check mark
221
+ too, so the outline is redrawn on top of Fusion's own indicator instead.
222
+ """
223
+
224
+ def __init__(self):
225
+ super().__init__("Fusion")
226
+
227
+ def drawPrimitive(self, element, option, painter, widget=None):
228
+ super().drawPrimitive(element, option, painter, widget)
229
+ if element not in (QStyle.PrimitiveElement.PE_IndicatorCheckBox,
230
+ QStyle.PrimitiveElement.PE_IndicatorRadioButton):
231
+ return
232
+ if not option.state & QStyle.StateFlag.State_Enabled:
233
+ return # disabled keeps Fusion's faint edge: it should read as "off"
234
+ painter.save()
235
+ painter.setBrush(Qt.BrushStyle.NoBrush)
236
+ edge = option.palette.color(QPalette.ColorRole.Shadow)
237
+ if element == QStyle.PrimitiveElement.PE_IndicatorCheckBox:
238
+ painter.setPen(QPen(edge, 1))
239
+ painter.drawRect(option.rect.adjusted(0, 0, -1, -1))
240
+ else: # 1.5 px antialiased: a 1 px ring blurs to ~2.7:1
241
+ painter.setRenderHint(painter.RenderHint.Antialiasing)
242
+ painter.setPen(QPen(edge, 1.5))
243
+ painter.drawEllipse(option.rect.toRectF().adjusted(1.5, 1.5, -1.5, -1.5))
244
+ painter.restore()
245
+
246
+
303
247
  def apply_theme(name: str, app: QApplication | None = None, *,
304
248
  base_font_pt: float | None = None) -> None:
305
249
  """Apply a theme (or "auto") to the QApplication.
@@ -314,7 +258,8 @@ def apply_theme(name: str, app: QApplication | None = None, *,
314
258
  _current = THEMES[resolve(name)]
315
259
  if base_font_pt is not None: # optional: omit it and the system font stays
316
260
  set_base_font(base_font_pt, app=app)
317
- app.setStyle("Fusion") # Fusion honors QPalette ColorRoles most faithfully
261
+ if not isinstance(app.style(), _EdgedFusion):
262
+ app.setStyle(_EdgedFusion()) # Fusion honors QPalette ColorRoles most faithfully
318
263
  app.setPalette(build_palette(_current))
319
264
  app.setStyleSheet(base_qss() + tone_qss())
320
265
 
@@ -322,7 +267,7 @@ def apply_theme(name: str, app: QApplication | None = None, *,
322
267
  if __name__ == "__main__":
323
268
  # Self-check: every theme builds a palette; light/dark classification holds.
324
269
  for _name, _t in THEMES.items():
325
- assert set(_t) >= {"text", "base", "primary", "mantle", "surface2"}, _name
270
+ assert set(_t) >= set(_ROLES) | {"disabled_text", *HUES}, _name
326
271
  assert build_palette(_t).color(QPalette.ColorRole.Window).isValid(), _name
327
272
  assert is_dark(THEMES["dracula"]) is True
328
273
  assert is_dark(THEMES["catppuccin_latte"]) is False
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ms_components
3
- Version: 0.3.2
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
@@ -13,6 +13,7 @@ docs/usage.md
13
13
  examples/tutorial_demo.py
14
14
  examples/tutorial_demo.toml
15
15
  src/ms_components/__init__.py
16
+ src/ms_components/backdrop.py
16
17
  src/ms_components/base.qss
17
18
  src/ms_components/chips.py
18
19
  src/ms_components/listview.py
@@ -14,6 +14,7 @@
14
14
  "examples/tutorial_demo.toml",
15
15
  "pyproject.toml",
16
16
  "src/ms_components/__init__.py",
17
+ "src/ms_components/backdrop.py",
17
18
  "src/ms_components/base.qss",
18
19
  "src/ms_components/chips.py",
19
20
  "src/ms_components/listview.py",
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.3.3",
3
+ "distance": 0,
4
+ "node": "g73c2686cfb14a6ddf44491e7480f39ce323724b1",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-22"
8
+ }
@@ -0,0 +1,68 @@
1
+ from PySide6.QtGui import QColor, QPalette
2
+
3
+ from ms_components.theme import HUES, TONES, THEMES, base_qss, build_palette, contrast_ratio, resolve
4
+
5
+ def _floor(theme, key, minimum, *against):
6
+ against = against or ("window", "base", "alternate_base")
7
+ for surface in against:
8
+ ratio = contrast_ratio(QColor(theme[key]), QColor(theme[surface]))
9
+ assert ratio >= minimum, f"{key} on {surface}: {ratio:.2f} < {minimum}"
10
+
11
+ def test_removed_themes_fall_back():
12
+ assert "atom_one" not in THEMES
13
+ assert "blender" not in THEMES
14
+ assert resolve("atom_one") == "one_dark_two"
15
+ assert resolve("blender") == "catppuccin_mocha"
16
+ assert "QHeaderView::section" in base_qss()
17
+ assert "background: palette(dark)" in base_qss()
18
+
19
+ def test_every_theme_meets_the_contrast_floors():
20
+ for name, theme in THEMES.items():
21
+ try:
22
+ _floor(theme, "text", 4.5)
23
+ _floor(theme, "window_text", 4.5)
24
+ _floor(theme, "button_text", 4.5, "button")
25
+ _floor(theme, "text", 4.5, "dark") # table header
26
+ _floor(theme, "dark", 1.4, "base", "alternate_base") # header band: decorative, just visible
27
+ _floor(theme, "highlighted_text", 4.5, "highlight")
28
+ _floor(theme, "highlight", 3.0) # selection, focus ring, tab underline
29
+ _floor(theme, "shadow", 3.0) # control edges (WCAG 1.4.11)
30
+ _floor(theme, "placeholder_text", 4.5)
31
+ _floor(theme, "tooltip_text", 4.5, "tooltip_base")
32
+ for key in ("link", "link_visited", "accent", *HUES):
33
+ _floor(theme, key, 4.5)
34
+ except AssertionError as error:
35
+ raise AssertionError(f"{name}: {error}") from None
36
+
37
+ def test_muted_and_disabled_text_read_as_different_levels():
38
+ for name, theme in THEMES.items():
39
+ window = QColor(theme["window"])
40
+ body = contrast_ratio(QColor(theme["text"]), window)
41
+ hint = contrast_ratio(QColor(theme["placeholder_text"]), window)
42
+ off = contrast_ratio(QColor(theme["disabled_text"]), window)
43
+ assert off < hint < body, (name, off, hint, body)
44
+
45
+ def test_the_palette_carries_every_role_and_the_disabled_text():
46
+ role, group = QPalette.ColorRole, QPalette.ColorGroup
47
+ theme = THEMES["dracula"]
48
+ palette = build_palette(theme)
49
+ assert palette.color(role.Shadow).name() == theme["shadow"]
50
+ assert palette.color(group.Inactive, role.Link).name() == theme["link"] # no greyed links
51
+ assert palette.color(group.Disabled, role.Text).name() == theme["disabled_text"]
52
+ assert set(TONES) <= set(theme)
53
+
54
+
55
+ def test_checkbox_and_radio_edges_reach_three_to_one():
56
+ from PySide6.QtWidgets import QApplication, QCheckBox, QRadioButton
57
+
58
+ from ms_components.theme import apply_theme
59
+
60
+ app = QApplication.instance() or QApplication([])
61
+ apply_theme("catppuccin_mocha", app) # Fusion alone draws these near 1.5:1
62
+ window = app.palette().window().color()
63
+ for box in (QCheckBox(), QRadioButton()):
64
+ box.resize(box.sizeHint())
65
+ image = box.grab().toImage()
66
+ edge = max(contrast_ratio(image.pixelColor(x, y), window)
67
+ for x in range(min(image.width(), 24)) for y in range(image.height()))
68
+ assert edge >= 3.0, (type(box).__name__, edge)
@@ -1,29 +0,0 @@
1
- """The color values of every theme, as data: no Qt, so a settings model can list their names.
2
-
3
- Where they come from and how they become a QPalette: `ms_components.theme`.
4
- """
5
- from __future__ import annotations
6
-
7
- # Auto mode picks these based on the OS color scheme. Catppuccin is the only
8
- # family here with a light+dark pair designed together, so it's the coherent auto
9
- # default; dracula and the rest stay selectable.
10
- DEFAULT_LIGHT = "github_light"
11
- DEFAULT_DARK = "catppuccin_mocha"
12
-
13
- # name -> {role: "#hex"}. Roles: primary/secondary, accents (magenta/red/orange/
14
- # yellow/green/cyan/blue), text/subtext1/subtext0, overlay2..0, surface2..0,
15
- # base/mantle/crust.
16
- THEMES: dict[str, dict[str, str]] = {
17
- "catppuccin_frappe": {"primary": "#ca9ee6", "secondary": "#8caaee", "magenta": "#ca9ee6", "red": "#e78284", "orange": "#ef9f76", "yellow": "#e5c890", "green": "#a6d189", "cyan": "#99d1db", "blue": "#8caaee", "text": "#c6d0f5", "subtext1": "#b5bfe2", "subtext0": "#a5adce", "overlay2": "#949cbb", "overlay1": "#838ba7", "overlay0": "#737994", "surface2": "#626880", "surface1": "#51576d", "surface0": "#414559", "base": "#303446", "mantle": "#292c3c", "crust": "#232634"},
18
- "catppuccin_latte": {"primary": "#8839ef", "secondary": "#1e66f5", "magenta": "#8839ef", "red": "#d20f39", "orange": "#fe640b", "yellow": "#df8e1d", "green": "#40a02b", "cyan": "#04a5e5", "blue": "#1e66f5", "text": "#4c4f69", "subtext1": "#5c5f77", "subtext0": "#6c6f85", "overlay2": "#7c7f93", "overlay1": "#8c8fa1", "overlay0": "#9ca0b0", "surface2": "#9ca0b0", "surface1": "#bcc0cc", "surface0": "#ccd0da", "base": "#ccd0da", "mantle": "#e6e9ef", "crust": "#ffffff"},
19
- "catppuccin_macchiato": {"primary": "#c6a0f6", "secondary": "#8aadf4", "magenta": "#c6a0f6", "red": "#ed8796", "orange": "#f5a97f", "yellow": "#eed49f", "green": "#a6da95", "cyan": "#91d7e3", "blue": "#8aadf4", "text": "#cad3f5", "subtext1": "#b8c0e0", "subtext0": "#a5adcb", "overlay2": "#939ab7", "overlay1": "#8087a2", "overlay0": "#6e738d", "surface2": "#5b6078", "surface1": "#494d64", "surface0": "#363a4f", "base": "#24273a", "mantle": "#1e2030", "crust": "#181926"},
20
- "catppuccin_mocha": {"primary": "#cba6f7", "secondary": "#89b4fa", "magenta": "#cba6f7", "red": "#f38ba8", "orange": "#fab387", "yellow": "#f9e2af", "green": "#a6e3a1", "cyan": "#89dceb", "blue": "#89b4fa", "text": "#cdd6f4", "subtext1": "#bac2de", "subtext0": "#a6adc8", "overlay2": "#9399b2", "overlay1": "#7f849c", "overlay0": "#6c7086", "surface2": "#585b70", "surface1": "#45475a", "surface0": "#313244", "base": "#1e1e2e", "mantle": "#181825", "crust": "#11111b"},
21
- "dracula": {"primary": "#bd93f9", "secondary": "#ff79c6", "magenta": "#bd93f9", "red": "#ff5555", "orange": "#ffb86c", "yellow": "#f1fa8c", "green": "#50fa7b", "cyan": "#8be9fd", "blue": "#6272a4", "text": "#f8f8f2", "subtext1": "#DADBD9", "subtext0": "#BCBDBF", "overlay2": "#9EA0A6", "overlay1": "#80828D", "overlay0": "#626573", "surface2": "#44475a", "surface1": "#3B3D4E", "surface0": "#313442", "base": "#2D2F3C", "mantle": "#282a36", "crust": "#1d1b22"},
22
- "github_dark": {"primary": "#79c0ff", "secondary": "#56d364", "magenta": "#d2a8ff", "red": "#ffa198", "orange": "#ffa657", "yellow": "#e3b341", "green": "#56d364", "cyan": "#68CAB2", "blue": "#79c0ff", "text": "#d1d7e0", "subtext1": "#BEC4CD", "subtext0": "#ABB1BA", "overlay2": "#989EA7", "overlay1": "#858B94", "overlay0": "#727882", "surface2": "#5F656F", "surface1": "#4C525C", "surface0": "#3E444E", "base": "#262c36", "mantle": "#212830", "crust": "#151b23"},
23
- "github_light": {"primary": "#79c0ff", "secondary": "#56d364", "magenta": "#d2a8ff", "red": "#ffa198", "orange": "#ffa657", "yellow": "#e3b341", "green": "#56d364", "cyan": "#68CAB2", "blue": "#79c0ff", "text": "#1f2328", "subtext1": "#212830", "subtext0": "#262c36", "overlay2": "#444952", "overlay1": "#61666E", "overlay0": "#7F838A", "surface2": "#9DA1A6", "surface1": "#BBBEC2", "surface0": "#D8DBDE", "base": "#f6f8fa", "mantle": "#FBFCFD", "crust": "#ffffff"},
24
- "modern_dark": {"primary": "#2aa5da", "secondary": "#2ad7da", "magenta": "#b351de", "red": "#da4d2a", "orange": "#da8a2a", "yellow": "#d7da2a", "green": "#2ada6f", "cyan": "#2ad7da", "blue": "#2aa5da", "text": "#c8c8c8", "subtext1": "#b2b2b2", "subtext0": "#9b9b9b", "overlay2": "#858585", "overlay1": "#6f6f6f", "overlay0": "#585858", "surface2": "#424242", "surface1": "#3c3c3c", "surface0": "#353535", "base": "#2a2a2a", "mantle": "#1f1f1f", "crust": "#141414"},
25
- "modern_light": {"primary": "#2aa5da", "secondary": "#2ad7da", "magenta": "#b351de", "red": "#da4d2a", "orange": "#da8a2a", "yellow": "#d7da2a", "green": "#2ada6f", "cyan": "#2ad7da", "blue": "#2aa5da", "text": "#000000", "subtext1": "#131517", "subtext0": "#2d3032", "overlay2": "#484b4d", "overlay1": "#626568", "overlay0": "#7d8084", "surface2": "#979b9f", "surface1": "#b1b6ba", "surface0": "#ccd0d5", "base": "#e6ebf0", "mantle": "#eef2f6", "crust": "#ffffff"},
26
- "monokai": {"primary": "#ffd866", "secondary": "#ff6188", "magenta": "#ab9df2", "red": "#ff6188", "orange": "#fc9867", "yellow": "#ffd866", "green": "#a9dc76", "cyan": "#91dcaf", "blue": "#78dce8", "text": "#fcfcfa", "subtext1": "#c1c0c0", "subtext0": "#c1c0c0", "overlay2": "#939293", "overlay1": "#939293", "overlay0": "#727072", "surface2": "#5b595c", "surface1": "#4e4c4f", "surface0": "#403e41", "base": "#2d2a2e", "mantle": "#221f22", "crust": "#19181a"},
27
- "nord": {"primary": "#81a1c1", "secondary": "#88c0d0", "magenta": "#b48ead", "red": "#bf616a", "orange": "#d08770", "yellow": "#ebcb8b", "green": "#a3be8c", "cyan": "#88c0d0", "blue": "#81a1c1", "text": "#eceff4", "subtext1": "#e5e9f0", "subtext0": "#d8dee9", "overlay2": "#d8dee9", "overlay1": "#d8dee9", "overlay0": "#4c566a", "surface2": "#4c566a", "surface1": "#434c5e", "surface0": "#3b4252", "base": "#3b4252", "mantle": "#2e3440", "crust": "#2e3440"},
28
- "one_dark_two": {"primary": "#62bac6", "secondary": "#eac786", "magenta": "#c88bda", "red": "#e27881", "orange": "#e79c7e", "yellow": "#eac786", "green": "#98c379", "cyan": "#62bac6", "blue": "#71b9f4", "text": "#e6e6e6", "subtext1": "#c9ccd3", "subtext0": "#abb2bf", "overlay2": "#969dab", "overlay1": "#818896", "overlay0": "#6c7280", "surface2": "#5b626d", "surface1": "#4a505a", "surface0": "#393e47", "base": "#282c34", "mantle": "#21252b", "crust": "#1d1f23"},
29
- }
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.3.2",
3
- "distance": 0,
4
- "node": "gca390289c6a1cb6d4a8c445570bbc0eb87ebd291",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-10-21"
8
- }
@@ -1,31 +0,0 @@
1
- from PySide6.QtGui import QPalette
2
-
3
- from ms_components.theme import THEMES, base_qss, build_palette, contrast_ratio, resolve
4
-
5
-
6
- def test_remaining_themes_keep_tables_and_selections_legible():
7
- role = QPalette.ColorRole
8
- group = QPalette.ColorGroup
9
-
10
- assert "atom_one" not in THEMES
11
- assert "blender" not in THEMES
12
- assert resolve("atom_one") == "one_dark_two"
13
- assert resolve("blender") == "catppuccin_mocha"
14
- assert "QHeaderView::section" in base_qss()
15
- assert "background: palette(dark)" in base_qss()
16
-
17
- for name, theme in THEMES.items():
18
- palette = build_palette(theme)
19
- row_backgrounds = (
20
- palette.color(group.Active, role.Base),
21
- palette.color(group.Active, role.AlternateBase),
22
- )
23
- header = palette.color(group.Active, role.Dark)
24
- assert contrast_ratio(palette.color(group.Active, role.Text), header) >= 4.5, name
25
- assert min(contrast_ratio(header, bg) for bg in row_backgrounds) >= 1.45, name
26
-
27
- for state in (group.Active, group.Inactive):
28
- highlight = palette.color(state, role.Highlight)
29
- highlighted_text = palette.color(state, role.HighlightedText)
30
- assert contrast_ratio(highlighted_text, highlight) >= 4.5, (name, state)
31
- assert min(contrast_ratio(highlight, bg) for bg in row_backgrounds) >= 3.0, (name, state)
File without changes
File without changes
File without changes
File without changes