visualdynamics 0.1.0a1__tar.gz → 0.1.0a2__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 (133) hide show
  1. {visualdynamics-0.1.0a1/src/visualdynamics.egg-info → visualdynamics-0.1.0a2}/PKG-INFO +1 -1
  2. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/__init__.py +1 -1
  3. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/__init__.py +31 -0
  4. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/main_window.py +61 -7
  5. visualdynamics-0.1.0a2/src/visualdynamics/gui/preferences.py +135 -0
  6. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/theme.py +73 -2
  7. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2/src/visualdynamics.egg-info}/PKG-INFO +1 -1
  8. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics.egg-info/SOURCES.txt +1 -0
  9. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/LICENSE +0 -0
  10. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/README.md +0 -0
  11. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/pyproject.toml +0 -0
  12. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/setup.cfg +0 -0
  13. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/__main__.py +0 -0
  14. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/compatibility.py +0 -0
  15. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/__init__.py +0 -0
  16. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/author.py +0 -0
  17. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/averaging.py +0 -0
  18. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/channel_table.py +0 -0
  19. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/compliance.py +0 -0
  20. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/correlate.py +0 -0
  21. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/data.py +0 -0
  22. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/detect.py +0 -0
  23. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/entities.py +0 -0
  24. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/fem.py +0 -0
  25. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/filters.py +0 -0
  26. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/geometry.py +0 -0
  27. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/kurtosis.py +0 -0
  28. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/matches.py +0 -0
  29. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/merge.py +0 -0
  30. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/modal_fit.py +0 -0
  31. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/octave.py +0 -0
  32. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/photos.py +0 -0
  33. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/replication.py +0 -0
  34. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/report.py +0 -0
  35. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/rigid.py +0 -0
  36. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/shapes.py +0 -0
  37. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/shocks.py +0 -0
  38. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/sine.py +0 -0
  39. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/srs.py +0 -0
  40. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/tables.py +0 -0
  41. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/transform.py +0 -0
  42. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/truncate.py +0 -0
  43. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/unit_choices.py +0 -0
  44. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/validate.py +0 -0
  45. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/core/wavelet.py +0 -0
  46. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/decimate.py +0 -0
  47. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/deform.py +0 -0
  48. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/demo/__init__.py +0 -0
  49. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/demo/drone.py +0 -0
  50. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/demo/plate.py +0 -0
  51. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/app_icon.py +0 -0
  52. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/author_panel.py +0 -0
  53. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/averaging_panel.py +0 -0
  54. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/console.py +0 -0
  55. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/disclaimer.py +0 -0
  56. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/drawer_tab.py +0 -0
  57. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/editors.py +0 -0
  58. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/filter_panel.py +0 -0
  59. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/glyphs.py +0 -0
  60. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/icons/drawer-tab-shape.svg +0 -0
  61. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/icons/drawer-tab.svg +0 -0
  62. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/icons.py +0 -0
  63. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/object_tables.py +0 -0
  64. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/octave_panel.py +0 -0
  65. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/panes.py +0 -0
  66. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/project_tree.py +0 -0
  67. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/record_grid.py +0 -0
  68. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/report_editor.py +0 -0
  69. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/rigid_panel.py +0 -0
  70. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/settings_panel.py +0 -0
  71. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/shock_panel.py +0 -0
  72. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/stage_drag.py +0 -0
  73. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/tables.py +0 -0
  74. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/toolbars.py +0 -0
  75. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/truncate_panel.py +0 -0
  76. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/wavelet_panel.py +0 -0
  77. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/gui/windows.py +0 -0
  78. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/io/__init__.py +0 -0
  79. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/io/adf.py +0 -0
  80. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/io/excel.py +0 -0
  81. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/io/exodus.py +0 -0
  82. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/io/exporters.py +0 -0
  83. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/io/femap.py +0 -0
  84. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/io/nastran.py +0 -0
  85. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/io/native.py +0 -0
  86. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/io/photo_files.py +0 -0
  87. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/io/punch.py +0 -0
  88. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/io/rattlesnake.py +0 -0
  89. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/io/rattlesnake_specification.py +0 -0
  90. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/io/report_template.py +0 -0
  91. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/io/sdynpy_data.py +0 -0
  92. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/io/sdynpy_npz.py +0 -0
  93. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/io/sdynpy_shapes.py +0 -0
  94. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/io/sep005.py +0 -0
  95. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/io/sniffing.py +0 -0
  96. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/io/step.py +0 -0
  97. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/io/stl.py +0 -0
  98. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/io/threemf.py +0 -0
  99. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/io/unv.py +0 -0
  100. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/names.py +0 -0
  101. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/plot/__init__.py +0 -0
  102. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/plot/averaging.py +0 -0
  103. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/plot/bands.py +0 -0
  104. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/plot/bars.py +0 -0
  105. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/plot/filtering.py +0 -0
  106. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/plot/scalogram.py +0 -0
  107. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/plot/shocks.py +0 -0
  108. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/plot/truncation.py +0 -0
  109. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/project.py +0 -0
  110. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/py.typed +0 -0
  111. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/report/__init__.py +0 -0
  112. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/report/markdown.py +0 -0
  113. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/report/page.py +0 -0
  114. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/rotate.py +0 -0
  115. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/units.py +0 -0
  116. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/update.py +0 -0
  117. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/viz/__init__.py +0 -0
  118. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/viz/_quiet.py +0 -0
  119. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/viz/animate.py +0 -0
  120. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/viz/banded.py +0 -0
  121. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/viz/geometry.py +0 -0
  122. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/viz/mac_bars.py +0 -0
  123. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/viz/marks.py +0 -0
  124. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/viz/paired.py +0 -0
  125. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/viz/pick.py +0 -0
  126. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/viz/rigid.py +0 -0
  127. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/viz/scalogram.py +0 -0
  128. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/viz/sinespec.py +0 -0
  129. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics/viz/waterfall.py +0 -0
  130. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics.egg-info/dependency_links.txt +0 -0
  131. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics.egg-info/entry_points.txt +0 -0
  132. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics.egg-info/requires.txt +0 -0
  133. {visualdynamics-0.1.0a1 → visualdynamics-0.1.0a2}/src/visualdynamics.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: visualdynamics
3
- Version: 0.1.0a1
3
+ Version: 0.1.0a2
4
4
  Summary: Structural Dynamics Visualization: a units-aware structural dynamics analysis toolset
5
5
  Author-email: Brandon Zwink <bzwink@gmail.com>
6
6
  License-Expression: GPL-3.0-or-later
@@ -76,7 +76,7 @@ from .units import (
76
76
  #: alpha, said in the version itself so the dialog, the About text, the
77
77
  #: installers and latest.json all say the same thing, and the first
78
78
  #: non-alpha release is offered as an upgrade (Brandon, 2026-09-11)
79
- __version__ = '0.1.0a1'
79
+ __version__ = '0.1.0a2'
80
80
 
81
81
 
82
82
  def launch_gui(*paths: str, in_process: bool | None = None) -> Any:
@@ -92,6 +92,30 @@ def check_qt_binding() -> None:
92
92
  f'which then breaks sdynpy\'s own plotting in that process.')
93
93
 
94
94
 
95
+ def theme_flag(argv: list[str]) -> tuple[list[str], str | None]:
96
+ """Take `--theme dark`, `--theme light` or `--theme=dark` out of
97
+ the arguments: the rest, and the theme named, or None."""
98
+ rest: list[str] = []
99
+ theme = None
100
+ skip = False
101
+ for i, arg in enumerate(argv):
102
+ if skip:
103
+ skip = False
104
+ continue
105
+ if arg.startswith('--theme='):
106
+ theme = arg.split('=', 1)[1]
107
+ elif arg == '--theme' and i + 1 < len(argv):
108
+ theme = argv[i + 1]
109
+ skip = True
110
+ else:
111
+ rest.append(arg)
112
+ if theme is not None:
113
+ theme = theme.strip().lower()
114
+ if theme not in ('dark', 'light'):
115
+ raise SystemExit(f"--theme takes dark or light, not {theme!r}")
116
+ return rest, theme
117
+
118
+
95
119
  def main(argv: Sequence[str] | None = None) -> int:
96
120
  import os
97
121
 
@@ -108,6 +132,7 @@ def main(argv: Sequence[str] | None = None) -> int:
108
132
 
109
133
  check_qt_binding() # before a window is built out of the mismatch
110
134
 
135
+ from ..theme import OVERRIDE as THEME_OVERRIDE
111
136
  from .main_window import MainWindow
112
137
 
113
138
  # let Ctrl-C in the launching terminal kill the GUI immediately instead
@@ -121,6 +146,12 @@ def main(argv: Sequence[str] | None = None) -> int:
121
146
  # its own and is not a file to import
122
147
  with_disclaimer = '--no-disclaimer' not in argv
123
148
  argv = [a for a in argv if a != '--no-disclaimer']
149
+ # --theme dark|light: said into the environment, which is the one
150
+ # place the theme module looks first, so the window and every
151
+ # later `apply_theme` agree (2026-09-14)
152
+ argv, theme = theme_flag(argv)
153
+ if theme is not None:
154
+ os.environ[THEME_OVERRIDE] = theme
124
155
  # what the machine calls this while it is running — and it must be
125
156
  # said *before* the QApplication exists. Cocoa's application menu
126
157
  # ('Hide …', 'Quit …') is built during construction, titled from
@@ -134,7 +134,7 @@ from ..rotate import (
134
134
  rotate_frame,
135
135
  wrapped,
136
136
  )
137
- from ..theme import OVERLAY_ALPHA, system_scheme
137
+ from ..theme import OVERLAY_ALPHA
138
138
  from ..theme import theme as resolve_theme
139
139
  from ..units import DEFAULT_SYSTEM, SYSTEMS, UnitSystem
140
140
  from ..viz.geometry import (
@@ -177,6 +177,13 @@ from .object_tables import (
177
177
  units_table_model,
178
178
  )
179
179
  from .panes import DataPane, ScenePane
180
+ from .preferences import (
181
+ chosen_scheme,
182
+ refresh_palettes,
183
+ remember_appearance,
184
+ remembered_appearance,
185
+ wear_appearance,
186
+ )
180
187
  from .project_tree import (
181
188
  PROJECT_ROW,
182
189
  ROLE_ACTIVE,
@@ -649,8 +656,12 @@ class MainWindow(QMainWindow):
649
656
  check_qt_binding() # a mismatch is unreadable once it reaches a layout
650
657
  import pyqtgraph as pg
651
658
 
652
- # follow the OS light/dark setting for the parts we draw ourselves
653
- self.theme_name: str = system_scheme()
659
+ # the appearance chosen for this launch, remembered, or the
660
+ # platform's — in that order (gui/preferences.py) — worn by the
661
+ # whole application first, so the chrome and the drawn parts
662
+ # are built to the same scheme
663
+ wear_appearance()
664
+ self.theme_name: str = chosen_scheme()
654
665
  colors = resolve_theme(self.theme_name)
655
666
  pg.setConfigOption('background', colors['plot_background'])
656
667
  pg.setConfigOption('foreground', colors['plot_foreground'])
@@ -1343,6 +1354,25 @@ class MainWindow(QMainWindow):
1343
1354
  report_menu.addAction('&Empty',
1344
1355
  lambda: self.generate_report('empty'))
1345
1356
  file_menu.addSeparator()
1357
+ # light, dark, or the platform's choice, remembered between
1358
+ # launches: a Linux desktop Qt could not read left a friend of
1359
+ # Brandon's with a light window and no way to change it
1360
+ # (2026-09-14). One menu, still — File stays the only one.
1361
+ appearance = file_menu.addMenu('&Appearance')
1362
+ group = QActionGroup(self)
1363
+ group.setExclusive(True)
1364
+ self.appearance_actions: dict[str, QAction] = {}
1365
+ remembered = remembered_appearance()
1366
+ for choice, label in (('system', '&System'), ('light', '&Light'),
1367
+ ('dark', '&Dark')):
1368
+ action = appearance.addAction(label)
1369
+ action.setCheckable(True)
1370
+ action.setChecked(choice == remembered)
1371
+ action.triggered.connect(
1372
+ lambda _checked=False, choice=choice:
1373
+ self.choose_appearance(choice))
1374
+ group.addAction(action)
1375
+ self.appearance_actions[choice] = action
1346
1376
  file_menu.addAction('Check for &Updates...', self.check_for_updates)
1347
1377
  # the About role: macOS moves it into the application menu,
1348
1378
  # where a Mac user looks for a version; Windows and Linux keep
@@ -2096,19 +2126,43 @@ class MainWindow(QMainWindow):
2096
2126
  self.dofs_combo_action.setVisible(bool(quantities))
2097
2127
 
2098
2128
  def _scheme_changed(self, _scheme) -> None:
2099
- """The platform switched light and dark: restate the theme."""
2129
+ """The platform switched light and dark: restate the theme —
2130
+ which follows it only while the appearance is System — and
2131
+ make every widget re-read the palette, which they do not do
2132
+ on their own (gui/preferences.py, `refresh_palettes`)."""
2133
+ refresh_palettes()
2100
2134
  self.apply_theme()
2101
2135
 
2136
+ def choose_appearance(self, choice: str) -> None:
2137
+ """File → Appearance: remember the choice and wear it now.
2138
+ 'system' means follow the platform again."""
2139
+ remember_appearance(choice)
2140
+ for name, action in self.appearance_actions.items():
2141
+ action.setChecked(name == choice)
2142
+ # the application first — chrome, menus, native widgets — then
2143
+ # the parts drawn here; Qt announces the switch through
2144
+ # colorSchemeChanged too, which lands on apply_theme as well
2145
+ wear_appearance(choice=choice)
2146
+ self.apply_theme()
2147
+ self._show_status(
2148
+ 'Following the system appearance' if choice == 'system'
2149
+ else f'{choice.capitalize()} appearance, remembered')
2150
+
2102
2151
  def apply_theme(self, name: str | None = None) -> None:
2103
- """Adopt a light/dark theme (default: whatever the OS is set to)."""
2152
+ """Adopt a light/dark theme (default: the one chosen — this
2153
+ launch's flag, the remembered appearance, or the OS)."""
2104
2154
  import pyqtgraph as pg
2105
2155
 
2106
- self.theme_name = name or system_scheme()
2156
+ self.theme_name = name or chosen_scheme()
2107
2157
  colors = resolve_theme(self.theme_name)
2108
2158
  _keep_selection_vivid(self.table)
2109
2159
  pg.setConfigOption('foreground', colors['plot_foreground'])
2110
2160
  self.data_pane.apply_theme(self.theme_name, colors)
2111
- self.scene.apply_background()
2161
+ # the scene's own theme name, not just its background: told
2162
+ # only to repaint, it repainted in the theme it was built with,
2163
+ # and a window opened light stayed white after Dark (Brandon's
2164
+ # screenshot, 2026-09-14)
2165
+ self.scene.apply_theme(self.theme_name)
2112
2166
  # the tree wears the scene's own ground — one theme for every
2113
2167
  # surface, black or white, never the platform's grey (Brandon,
2114
2168
  # 2026-08-23)
@@ -0,0 +1,135 @@
1
+ """What the application remembers between launches: today, the
2
+ appearance.
3
+
4
+ One `QSettings` under one name, so a preference set on one launch is
5
+ read on the next; the tests point the store at a temporary folder
6
+ through `QSettings.setPath`, so nothing a test chooses reaches the
7
+ user's own file.
8
+
9
+ The appearance a window wears is decided in this order, and the order
10
+ is the point: an explicit statement for this launch
11
+ (`VISUALDYNAMICS_THEME`, which `--theme` sets) beats the remembered
12
+ choice, and the remembered choice beats what the platform reports —
13
+ `'system'` means follow the platform, which is the default and what
14
+ `theme.system_scheme` answers.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ from typing import Any
21
+
22
+ from PySide6.QtCore import QSettings
23
+
24
+ from ..theme import OVERRIDE, THEMES, system_scheme
25
+
26
+ #: the remembered choices, in menu order
27
+ APPEARANCES = ('system', 'light', 'dark')
28
+ KEY = 'appearance'
29
+
30
+
31
+ #: a file to keep the preferences in instead of the platform's store —
32
+ #: what the tests set, so nothing a test chooses reaches the user's own
33
+ STORE = 'VISUALDYNAMICS_SETTINGS'
34
+
35
+
36
+ def settings() -> QSettings:
37
+ """The application's own store: the platform's (a plist on macOS,
38
+ the registry on Windows, a file under ~/.config on Linux), or the
39
+ file `VISUALDYNAMICS_SETTINGS` names."""
40
+ path = os.environ.get(STORE)
41
+ if path:
42
+ return QSettings(path, QSettings.Format.IniFormat)
43
+ return QSettings('visualdynamics', 'Visual Dynamics')
44
+
45
+
46
+ def remembered_appearance() -> str:
47
+ """'system', 'light' or 'dark' — 'system' when nothing was chosen
48
+ or the stored word is not one of ours."""
49
+ said = str(settings().value(KEY, 'system')).strip().lower()
50
+ return said if said in APPEARANCES else 'system'
51
+
52
+
53
+ def remember_appearance(choice: str) -> None:
54
+ """Store the choice; 'system' clears it rather than storing a word
55
+ that means "nothing chosen"."""
56
+ if choice not in APPEARANCES:
57
+ raise ValueError(f'appearance is one of {APPEARANCES}, not {choice!r}')
58
+ store = settings()
59
+ if choice == 'system':
60
+ store.remove(KEY)
61
+ else:
62
+ store.setValue(KEY, choice)
63
+ store.sync()
64
+
65
+
66
+ def chosen_scheme() -> str:
67
+ """The theme to wear now: this launch's statement, else the
68
+ remembered choice, else the platform's."""
69
+ said = os.environ.get(OVERRIDE, '').strip().lower()
70
+ if said in THEMES:
71
+ return said
72
+ remembered = remembered_appearance()
73
+ if remembered in THEMES:
74
+ return remembered
75
+ return system_scheme()
76
+
77
+
78
+ def wear_appearance(app: Any = None, choice: str | None = None) -> None:
79
+ """Make the whole application wear the chosen appearance — the
80
+ window chrome, the menus, every native widget — not only the parts
81
+ this program draws itself.
82
+
83
+ Qt 6.8 gave `QStyleHints.setColorScheme`: Light or Dark forces the
84
+ application's appearance (on macOS, the NSApp appearance), Unknown
85
+ follows the platform again. Without it the first cut of the menu
86
+ changed the plots and the scene and left the chrome as the OS had
87
+ it, and Brandon chose Light and saw nothing change (2026-09-14).
88
+ The platform then reports the forced scheme, which is what
89
+ `theme.system_scheme` and the `colorSchemeChanged` slot read, so
90
+ every later repaint agrees. `choice` defaults to what
91
+ `chosen_scheme` would answer: this launch's statement, else the
92
+ remembered choice, else the platform's.
93
+ """
94
+ from PySide6.QtCore import Qt
95
+ from PySide6.QtWidgets import QApplication
96
+
97
+ app = app or QApplication.instance()
98
+ if app is None:
99
+ return
100
+ hints = app.styleHints()
101
+ if not hasattr(hints, 'setColorScheme'):
102
+ return # an older Qt: the drawn parts only
103
+ if choice is None:
104
+ said = os.environ.get(OVERRIDE, '').strip().lower()
105
+ choice = said if said in THEMES else remembered_appearance()
106
+ hints.setColorScheme({'light': Qt.ColorScheme.Light,
107
+ 'dark': Qt.ColorScheme.Dark}.get(
108
+ choice, Qt.ColorScheme.Unknown))
109
+ refresh_palettes(app)
110
+
111
+
112
+ def refresh_palettes(app: Any = None) -> None:
113
+ """Make every widget re-read the application palette.
114
+
115
+ On macOS with Qt 6.11 the application palette follows a colour
116
+ scheme change at once, but widgets already on screen keep the
117
+ palette they resolved before it — the status bar and the panes
118
+ stayed light after Brandon switched back to Dark (his screenshot,
119
+ 2026-09-14), while a fresh window came up right. Setting an
120
+ *empty* palette on a widget makes it resolve from its parent and
121
+ the application again, and leaves `WA_SetPalette` clear, so the
122
+ widgets that own a palette on purpose (the tree and the tables,
123
+ coloured by the theme) are skipped and keep theirs.
124
+ """
125
+ from PySide6.QtCore import Qt
126
+ from PySide6.QtGui import QPalette
127
+ from PySide6.QtWidgets import QApplication, QWidget
128
+
129
+ app = app or QApplication.instance()
130
+ if app is None or not hasattr(app, 'topLevelWidgets'):
131
+ return
132
+ for top in app.topLevelWidgets():
133
+ for widget in (top, *top.findChildren(QWidget)):
134
+ if not widget.testAttribute(Qt.WidgetAttribute.WA_SetPalette):
135
+ widget.setPalette(QPalette())
@@ -148,12 +148,23 @@ def colormap(values: Any) -> Any:
148
148
  return stops[low] * (1.0 - f) + stops[low + 1] * f
149
149
 
150
150
 
151
+ #: an explicit answer beats every guess: `VISUALDYNAMICS_THEME=dark`
152
+ #: (or light) in the environment, which the launcher's `--theme` sets
153
+ OVERRIDE = 'VISUALDYNAMICS_THEME'
154
+
155
+
151
156
  def system_scheme(app: Any = None) -> str:
152
- """'dark' or 'light' from the OS appearance, via Qt.
157
+ """'dark' or 'light': the environment's say, else the OS appearance
158
+ via Qt, else the desktop asked directly, else the palette.
153
159
 
154
160
  Falls back to the default theme when Qt or an application instance is
155
161
  unavailable (e.g. plain scripting use).
156
162
  """
163
+ import os
164
+
165
+ said = os.environ.get(OVERRIDE, '').strip().lower()
166
+ if said in THEMES:
167
+ return said
157
168
  try:
158
169
  from PySide6.QtCore import Qt
159
170
  from PySide6.QtWidgets import QApplication
@@ -167,6 +178,66 @@ def system_scheme(app: Any = None) -> str:
167
178
  return 'dark'
168
179
  if scheme == Qt.ColorScheme.Light:
169
180
  return 'light'
170
- palette = app.palette() # Qt could not tell: judge by palette lightness
181
+ # Qt could not tell. On Linux that is the usual case: Qt learns the
182
+ # scheme through a platform-theme plugin, and a packaged build
183
+ # shipped none until 2026-09-14 — a friend of Brandon's on a dark
184
+ # desktop got a light window. The desktop is asked directly before
185
+ # the palette is judged, since the palette is Qt's own light one
186
+ # whenever the plugin is missing.
187
+ asked = desktop_scheme()
188
+ if asked is not None:
189
+ return asked
190
+ palette = app.palette() # judge by palette lightness
171
191
  return ('dark' if palette.color(palette.ColorRole.Window).lightness() < 128
172
192
  else 'light')
193
+
194
+
195
+ def desktop_scheme(platform: str | None = None,
196
+ run: Any = None, home: str | None = None) -> str | None:
197
+ """The Linux desktop's own answer, or None where there is none.
198
+
199
+ Three doors, in the order a modern desktop answers them: the XDG
200
+ settings portal's `color-scheme` (GNOME, KDE and the rest, over
201
+ D-Bus through `gdbus`, which ships with GLib), GNOME's
202
+ `gsettings` key of the same name, and KDE's `kdeglobals`. Each is
203
+ a subprocess or a file read with a short timeout, and any failure
204
+ is "no answer" — never an exception in the way of a window.
205
+ `platform`, `run` and `home` are for the tests.
206
+ """
207
+ import os
208
+ import subprocess
209
+ import sys
210
+
211
+ platform = platform or sys.platform
212
+ if not platform.startswith('linux'):
213
+ return None
214
+ run = run or (lambda cmd: subprocess.run(
215
+ cmd, capture_output=True, text=True, timeout=2, check=False).stdout)
216
+ for command, dark, light in (
217
+ (['gdbus', 'call', '--session',
218
+ '--dest', 'org.freedesktop.portal.Desktop',
219
+ '--object-path', '/org/freedesktop/portal/desktop',
220
+ '--method', 'org.freedesktop.portal.Settings.ReadOne',
221
+ 'org.freedesktop.appearance', 'color-scheme'],
222
+ 'uint32 1', 'uint32 2'),
223
+ (['gsettings', 'get', 'org.gnome.desktop.interface',
224
+ 'color-scheme'],
225
+ 'prefer-dark', 'prefer-light')):
226
+ try:
227
+ out = run(command) or ''
228
+ except Exception: # noqa: BLE001, S112 — absent tool, timeout: no answer from this door
229
+ continue
230
+ if dark in out:
231
+ return 'dark'
232
+ if light in out:
233
+ return 'light'
234
+ try:
235
+ kdeglobals = os.path.join(home or os.path.expanduser('~'),
236
+ '.config', 'kdeglobals')
237
+ with open(kdeglobals, encoding='utf-8', errors='replace') as f:
238
+ for line in f:
239
+ if line.strip().lower().startswith('colorscheme='):
240
+ return ('dark' if 'dark' in line.lower() else 'light')
241
+ except OSError:
242
+ pass
243
+ return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: visualdynamics
3
- Version: 0.1.0a1
3
+ Version: 0.1.0a2
4
4
  Summary: Structural Dynamics Visualization: a units-aware structural dynamics analysis toolset
5
5
  Author-email: Brandon Zwink <bzwink@gmail.com>
6
6
  License-Expression: GPL-3.0-or-later
@@ -68,6 +68,7 @@ src/visualdynamics/gui/main_window.py
68
68
  src/visualdynamics/gui/object_tables.py
69
69
  src/visualdynamics/gui/octave_panel.py
70
70
  src/visualdynamics/gui/panes.py
71
+ src/visualdynamics/gui/preferences.py
71
72
  src/visualdynamics/gui/project_tree.py
72
73
  src/visualdynamics/gui/record_grid.py
73
74
  src/visualdynamics/gui/report_editor.py