dynamic-systems-testing-framework 1.0.1__py3-none-any.whl

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 (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,589 @@
1
+ """The Variables drawer (the roadmap's 3c; DESIGN.md "The Variables drawer";
2
+ the owner's rulings Q5-Q7, 2026-09-25, and Q12-Q14, 2026-09-28): every
3
+ variable of the last result in `variable_rows`' groups, folded and
4
+ filtered, each row checked against the selected plot. Its filter and its
5
+ sort, group and names menus are the tab's (`TabState`), drawn by
6
+ `drawer_controls` as the Tests drawer's are. It edits the plot extras: a
7
+ check adds a row's members to the selected plot's CONTEXT or takes them
8
+ off (the plot's subject is on and grey — a derived plot's subject is the
9
+ tree's), "+ new plot" makes a WATCH plot of one variable after the others.
10
+ The rules are not here: the rows are data (`authoring.variable_rows`), the
11
+ edits pure functions (`authoring.layout`), and the page stages what they
12
+ return (`TestPage.stage_layout`).
13
+
14
+ Rebuilt whole when it opens, and when what it draws moved under the page
15
+ it sits on (`Rail.refresh`: a select, a sync), never while closed. So its
16
+ check is a button with `role=checkbox`, not a `ui.checkbox`: a checkbox's
17
+ `set_value` fires its own `on_change`, and a repaint of one would stage an
18
+ edit."""
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from collections import Counter
24
+ from collections.abc import Callable
25
+ from dataclasses import dataclass, replace
26
+ from functools import partial
27
+ from typing import TYPE_CHECKING
28
+
29
+ import regex
30
+ from nicegui import ui
31
+
32
+ from ...backends.types import VariableText
33
+ from ...discovery.plots import PlotExtra, ResolvedPlot
34
+ from ...discovery.unit_tests import is_unit_test_variable
35
+ from ...figures.families import HUES
36
+ from ..layout import add_context, new_watch, remove_context
37
+ from ..needle import matcher
38
+ from ..variable_rows import VarGroup, VarRow, cap, variable_groups
39
+ from .drawer_controls import filter_box, menu_button
40
+ from .widgets import MiddleCut
41
+
42
+ if TYPE_CHECKING:
43
+ from .test_page import TestPage
44
+
45
+ #: At most this many top-level rows (planner's choice 4): a result of
46
+ #: thousands of names drew thousands of rows. An opened family shows at most
47
+ #: ``MEMBERS`` of its own.
48
+ ROWS, MEMBERS = 200, 50
49
+
50
+ #: A family's swatch marks the row as an array: four blocks, one per stop
51
+ #: of the accent ramp (the first hue's, `figures.families`), light to dark,
52
+ #: as the owner-approved board drew it — discrete, never a gradient
53
+ #: (DESIGN.md "Don't use gradients"). It is not the ramp its members are
54
+ #: drawn in: a row is tied to no plot, and a plot gives a family its hue by
55
+ #: the family's order on that plot.
56
+ _RAMP = HUES[0]
57
+
58
+
59
+ _SPACE = re.compile(r"\s+")
60
+
61
+ #: The target line and the grey "+ new plot"'s reason on a run that failed
62
+ #: (`TestPage.run_state`). A test with no result reads as one never run.
63
+ RUN_FAILED = "The run failed: no plots"
64
+
65
+
66
+ def marker(kind: str, row_id: str) -> str:
67
+ """A row's ``var-*`` marker: ``kind:row_id`` with ALL whitespace taken
68
+ out. NiceGUI splits a marker on whitespace, so ``port[2, 1].C[]``'s was
69
+ two words that ``port[2, 3, 1].C[]``'s marker also carried, and a find
70
+ for the one found both. Every row marker is built here; a test looks one
71
+ up through it."""
72
+ return f"{kind}:{_SPACE.sub('', row_id)}"
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class _Reads:
77
+ """The Session's reads one build draws from, taken once."""
78
+
79
+ names: list[str]
80
+ tracked: list[str]
81
+ scope: dict[str, VariableText]
82
+ plots: tuple[ResolvedPlot, ...]
83
+ #: the names that are the test's UnitTests elements: rows of their own,
84
+ #: as they are plots of their own (O3)
85
+ unit_tests: frozenset[str]
86
+
87
+
88
+ def _slug(title: str) -> str:
89
+ """A group title as one marker word: NiceGUI splits markers on
90
+ whitespace, and "(top level)" was two (the executing review)."""
91
+ return re.sub(r"[^\w.$]+", "-", title).strip("-")
92
+
93
+
94
+ def _state(row: VarRow) -> tuple[str, str]:
95
+ """``(aria-checked, icon)``: all, some or none of the row's members in
96
+ the selected plot's context — or the plot's subject, which it always
97
+ shows."""
98
+ if row.is_subject or row.on_selected == len(row.members):
99
+ return "true", "check_box"
100
+ if row.on_selected:
101
+ return "mixed", "indeterminate_check_box"
102
+ return "false", "check_box_outline_blank"
103
+
104
+
105
+ class VariablesDrawer:
106
+ def __init__(self, page: TestPage, panel: ui.column) -> None:
107
+ self.page = page
108
+ #: The families opened to their members, by `_family`: kept across
109
+ #: rebuilds, so a check inside an opened family leaves it open. Never
110
+ #: by row id, which carries the group — a family opened By component
111
+ #: closed under Flat — nor by its members, which a filter narrows.
112
+ self.opened: set[tuple[str, bool]] = set()
113
+ #: The test's tracked names, as the last rebuild read them.
114
+ self.tracked: frozenset[str] = frozenset()
115
+ #: The texts two or more of the drawn rows read as.
116
+ self.alike: set[str] = set()
117
+ #: Why "+ new plot" is grey on every row — O2's gate, worded by the
118
+ #: run state — as the last build read it, or None.
119
+ self.withheld: str | None = None
120
+ #: The filter and its toggles are the tab's (`TabState`): a switch
121
+ #: of test builds a new drawer, and it starts from them.
122
+ self.state = page.state
123
+ #: What the last rebuild read, for the clicks on the rows it drew.
124
+ self.texts: dict[str, str] = {}
125
+ #: What the drawn rows were built from (`_signature`): `refresh`
126
+ #: builds again only when it moved.
127
+ self.drawn: object = None
128
+ with panel:
129
+ ui.label("Variables").classes("text-subtitle2")
130
+ state = self.state
131
+ self.box = filter_box(
132
+ state.vars_needle,
133
+ state.vars_regex,
134
+ state.vars_case,
135
+ self._on_filter,
136
+ "filter: substring, or a glob with * ?",
137
+ "var-",
138
+ )
139
+ with ui.row().classes("items-center no-wrap gap-0 w-full"):
140
+ menu_button(
141
+ "sort",
142
+ "Sort",
143
+ (("file", "Result-file order"), ("name", "A→Z")),
144
+ state.vars_sort,
145
+ partial(self._set, "vars_sort"),
146
+ "var-sort",
147
+ )
148
+ menu_button(
149
+ "account_tree",
150
+ "Group",
151
+ (
152
+ ("component", "By component"),
153
+ ("flat", "Flat"),
154
+ ("tracked", "Tracked first"),
155
+ ),
156
+ state.vars_group,
157
+ partial(self._set, "vars_group"),
158
+ "var-group-by",
159
+ )
160
+ menu_button(
161
+ "label",
162
+ "Names",
163
+ (
164
+ ("expression", "Expression"),
165
+ ("file", "Result-file name"),
166
+ ("both", "Both"),
167
+ ),
168
+ state.vars_names,
169
+ partial(self._set, "vars_names"),
170
+ "var-names",
171
+ )
172
+ # The plot the checks act on, with the ring its card wears; a
173
+ # click brings that card into view.
174
+ with (
175
+ ui.row()
176
+ .classes("dstf-var-target items-center no-wrap gap-2")
177
+ .on("click", self._reveal)
178
+ ):
179
+ ui.element("span").classes("dstf-var-ring")
180
+ self.target = ui.label().mark("var-target")
181
+ self.body = ui.column().classes("w-full gap-0")
182
+ self.rebuild()
183
+
184
+ def _on_filter(self, needle: str, regex: bool, case: bool) -> None:
185
+ state = self.state
186
+ state.vars_needle, state.vars_regex, state.vars_case = needle, regex, case
187
+ self.rebuild()
188
+
189
+ def _set(self, key: str, value: str) -> None:
190
+ """A menu's choice, the tab's (`TabState`)."""
191
+ setattr(self.state, key, value)
192
+ self.rebuild()
193
+
194
+ def _reveal(self) -> None:
195
+ page = self.page
196
+ if 0 <= page.selected < len(page.cards):
197
+ card = page.cards[page.selected].card
198
+ ui.run_javascript(
199
+ f'document.getElementById("{card.html_id}")'
200
+ '?.scrollIntoView({block: "nearest"})'
201
+ )
202
+
203
+ # -- the rows ----------------------------------------------------------------
204
+
205
+ def rebuild(self) -> None:
206
+ """Every row from the Session as it is now, against the selected
207
+ plot, whether it moved or not: the drawer opening, its filter, a
208
+ family opened."""
209
+ self._draw(self._read())
210
+
211
+ def refresh(self) -> None:
212
+ """The page moved (`Rail.refresh`: a select, a sync): build again
213
+ only when what the rows are drawn from did. `TestPage.sync` ends
214
+ here, and a run's events rebuilt every row of an open drawer each
215
+ time over a list that had not changed."""
216
+ reads = self._read()
217
+ if self._signature(reads) != self.drawn:
218
+ self._draw(reads)
219
+
220
+ def _read(self) -> _Reads | None:
221
+ """ONE read of each — the names, the tracked names, the texts, the
222
+ plots — or None on a page whose test is gone: every read raises for
223
+ a model the open project does not have."""
224
+ page = self.page
225
+ if page.gone:
226
+ return None
227
+ session, model_id = page.shell.session, page.model_id
228
+ names = session.variable_names(model_id)
229
+ project = session.project
230
+ test = project.test(model_id) if project is not None else None
231
+ return _Reads(
232
+ names=names,
233
+ tracked=session.tracked(model_id),
234
+ scope=session.variable_texts(model_id),
235
+ plots=session.plots(model_id),
236
+ unit_tests=frozenset(
237
+ n for n in names if test is not None and is_unit_test_variable(test, n)
238
+ ),
239
+ )
240
+
241
+ def _signature(self, reads: _Reads | None) -> object:
242
+ """Everything a build draws from: the reads, the selected plot and
243
+ the number of plots, the run state and O2's gate, the filter and
244
+ the menus, the families opened, and the reason an empty result
245
+ gives."""
246
+ page = self.page
247
+ head = (
248
+ page.selected,
249
+ len(page.cards),
250
+ page.run_state(),
251
+ page.plots_withheld(),
252
+ page.result() is None,
253
+ self.state.vars_needle,
254
+ self.state.vars_regex,
255
+ self.state.vars_case,
256
+ self.state.vars_group,
257
+ self.state.vars_names,
258
+ self.state.vars_sort,
259
+ frozenset(self.opened),
260
+ )
261
+ if reads is None:
262
+ return head, None
263
+ return head, (
264
+ tuple(reads.names),
265
+ tuple(reads.tracked),
266
+ tuple((n, t.label, t.text) for n, t in reads.scope.items()),
267
+ reads.plots,
268
+ reads.unit_tests,
269
+ "" if reads.names else page.unscored_reason(),
270
+ )
271
+
272
+ def _draw(self, reads: _Reads | None) -> None:
273
+ """Build the rows, and record what they were built from only once
274
+ they are: a draw that raised had recorded it first, and every later
275
+ refresh then saw nothing moved and left the cleared body empty. A
276
+ bad regex, or one that ran out of time, is a draw that completed —
277
+ it is drawn as its reason."""
278
+ signature = self._signature(reads)
279
+ self.drawn = None
280
+ self._build(reads)
281
+ self.drawn = signature
282
+
283
+ def _build(self, reads: _Reads | None) -> None:
284
+ page = self.page
285
+ self.body.clear()
286
+ if reads is None:
287
+ self.target.set_text("")
288
+ return
289
+ n = len(page.cards)
290
+ # O2's gate: a result that is not a success has no plots — nothing
291
+ # to add context to, and a watch plot made on it would draw nothing.
292
+ # Its run failed (`run_state`), as every line of the page says.
293
+ run = page.run_state()
294
+ self.withheld = RUN_FAILED if page.plots_withheld() else None
295
+ self.target.set_text(
296
+ f"Checkboxes add context to plot {page.selected + 1} of {n}"
297
+ if n
298
+ else RUN_FAILED
299
+ if run == "failed"
300
+ else "No plot yet: + new plot adds one"
301
+ )
302
+ try:
303
+ state = self.state
304
+ keep = matcher(
305
+ state.vars_needle, regex=state.vars_regex, case=state.vars_case
306
+ )
307
+ except regex.error as e:
308
+ self.box.say(f"not a regular expression: {e}")
309
+ return
310
+ self.box.say(None)
311
+ names = reads.names
312
+ project = page.shell.session.project
313
+ with self.body:
314
+ if not names:
315
+ ui.label(
316
+ page.unscored_reason()
317
+ if run != "ok"
318
+ else "the result has no variables"
319
+ ).classes("dstf-why").mark("var-empty")
320
+ return
321
+ scope = reads.scope
322
+ self.texts = {name: t.text for name, t in scope.items()}
323
+ self.tracked = frozenset(reads.tracked)
324
+ # Everything but the names and the filter, for the members too.
325
+ groups_of = partial(
326
+ variable_groups,
327
+ tracked=reads.tracked,
328
+ diagnostics=(
329
+ project.config.diagnostic_variables if project is not None else ()
330
+ ),
331
+ labels={name: t.label for name, t in scope.items()},
332
+ plots=reads.plots,
333
+ unit_tests=reads.unit_tests,
334
+ selected=page.selected if n else None,
335
+ group=self.state.vars_group,
336
+ order=self.state.vars_sort,
337
+ read=self.state.vars_names,
338
+ )
339
+ # `keep` is only ever asked here: a typed regex that ran out of
340
+ # its budget has drawn nothing yet, and draws nothing
341
+ try:
342
+ found = groups_of(names=names, keep=keep)
343
+ except TimeoutError:
344
+ self.box.say("the pattern took too long; simplify it")
345
+ return
346
+ groups, shown, total = cap(found, ROWS)
347
+ drawn = [
348
+ (group, [(row, self._members(row, groups_of)) for row in group.rows])
349
+ for group in groups
350
+ ]
351
+ # The one label rule (`display_label`) over what the drawer
352
+ # draws: a tracked expression `a.Q` beside an untracked variable
353
+ # NAMED `a.Q` is no collision in the test's scope when no plot or
354
+ # criterion holds the second, and two rows read "a.Q".
355
+ reads_as = Counter(
356
+ r.text
357
+ for _, rows in drawn
358
+ for row, members in rows
359
+ for r in (row, *members)
360
+ )
361
+ self.alike = {text for text, n in reads_as.items() if n > 1}
362
+ for group, rows in drawn:
363
+ ui.label(group.title).classes("dstf-group").mark(
364
+ f"var-group:{group.rank}-{_slug(group.title)}"
365
+ )
366
+ for row, members in rows:
367
+ self._row(row)
368
+ for member in members:
369
+ self._row(member, member=True)
370
+ if members and len(row.members) > MEMBERS:
371
+ ui.label(f"… {len(row.members) - MEMBERS} more").classes(
372
+ "dstf-why dstf-var-member"
373
+ )
374
+ if shown < total:
375
+ ui.label(
376
+ f"Showing {shown} of {total:,} rows — narrow the filter"
377
+ ).classes("dstf-why").mark("var-cap")
378
+
379
+ def _members(
380
+ self, row: VarRow, groups_of: Callable[..., list[VarGroup]]
381
+ ) -> list[VarRow]:
382
+ """An opened family's members, each drawn as a row of its own under
383
+ it (none for a family not opened). Each is `variable_groups`' row for
384
+ that one name — the same rules, the same state — with an id under its
385
+ family's."""
386
+ if len(row.members) < 2 or self._family(row) not in self.opened:
387
+ return []
388
+ out = []
389
+ for name in row.members[:MEMBERS]:
390
+ (group,) = groups_of(names=[name], keep=lambda _: True)
391
+ (member,) = group.rows
392
+ out.append(replace(member, id=f"{row.id}/{name}"))
393
+ return out
394
+
395
+ def _row(self, row: VarRow, *, member: bool = False) -> None:
396
+ page, key = self.page, row.id
397
+ # What is muted beside the row's text: a lone variable's name. A
398
+ # family has none to mute: rows fold by NAME, so its text is its
399
+ # names folded (`file_text`).
400
+ muted = row.name
401
+ said = row.text if muted is None else f"{row.text} ({muted})"
402
+ # The names menu: the expression alone, the result-file name alone,
403
+ # or both — the expression with the name muted beside it. The
404
+ # expression alone still keeps the name beside a label another name
405
+ # of the test carries (`variable_texts`, the one label rule) or
406
+ # another row drawn here reads (`alike`): two rows reading "a.Q"
407
+ # said nothing of which was which. A family has no one name for the
408
+ # scope test: `alike` is what says it reads as another drawn row does.
409
+ names = self.state.vars_names
410
+ show_muted = muted is not None and (
411
+ names == "both"
412
+ or (
413
+ names == "expression"
414
+ and (
415
+ (row.name is not None and self.texts.get(row.name) != row.text)
416
+ or row.text in self.alike
417
+ )
418
+ )
419
+ )
420
+ # Two lines (2026-09-29): the label alone on the first, after its
421
+ # controls, with the row's whole width — a real library's labels
422
+ # outran the one line they shared with the name, "plot N" and
423
+ # "+ new plot" — and those, muted, on the second. A second line that
424
+ # would hold nothing but the hover-only "+ new plot" collapses, the
425
+ # `+` at line 1's right (K6): a list of untracked names was every
426
+ # other line empty.
427
+ two_lines = show_muted or bool(row.elsewhere)
428
+ with (
429
+ ui.column()
430
+ .classes(
431
+ "dstf-var-row gap-0 w-full" + (" dstf-var-member" if member else "")
432
+ )
433
+ .mark(marker("var-row", key))
434
+ ):
435
+ with ui.row().classes("dstf-var-line1 items-center no-wrap gap-1 w-full"):
436
+ # Names go on the props dict, never through a props string: a
437
+ # name can hold a `"`. A check's only text was its icon's
438
+ # ligature, `check_box_outline_blank`.
439
+ if len(row.members) > 1:
440
+ opened = self._family(row) in self.opened
441
+ chevron = (
442
+ ui.button(
443
+ icon="expand_more" if opened else "chevron_right",
444
+ on_click=partial(self._open, self._family(row)),
445
+ )
446
+ .props("flat dense round size=sm")
447
+ .mark(marker("var-open", key))
448
+ )
449
+ chevron.props["aria-label"] = (
450
+ f"{'Close' if opened else 'Open'} {said}"
451
+ )
452
+ chevron.props["aria-expanded"] = "true" if opened else "false"
453
+ checked, icon = _state(row)
454
+ # A disabled Quasar button takes no pointer events: the
455
+ # reason's tooltip is on a span around it.
456
+ with ui.element("span").mark(marker("var-why", key)):
457
+ check = (
458
+ ui.button(icon=icon, on_click=partial(self._check, row))
459
+ .props(
460
+ "flat dense round size=sm "
461
+ f'role="checkbox" aria-checked="{checked}"'
462
+ )
463
+ .mark(marker("var-check", key))
464
+ )
465
+ # A row with a muted name says it too: two rows
466
+ # reading "a.Q" had two checks a screen reader read alike.
467
+ check.props["aria-label"] = said
468
+ if not page.cards:
469
+ check.disable() # no plot to act on: "+ new plot" makes one
470
+ elif row.is_subject:
471
+ # A plot cannot lose its subject: a derived plot's is
472
+ # the tree's, a watch plot's is the plot.
473
+ check.disable()
474
+ ui.tooltip("The plot's subject")
475
+ if len(row.members) > 1:
476
+ with ui.element("span").classes("dstf-var-ramp"):
477
+ for stop in _RAMP:
478
+ ui.element("span").style(f"background: {stop}")
479
+ # A real library's names outrun the panel and are cut in the
480
+ # middle: the text carries all of it, and the name, as its
481
+ # title (on the props dict: a name can hold a `"`).
482
+ shown = row.file_text if names == "file" else row.text
483
+ text = MiddleCut(shown).classes("dstf-var-text dstf-mono text-xs")
484
+ text.props["title"] = said
485
+ if not two_lines:
486
+ ui.space()
487
+ self._new_button(row, key)
488
+ if two_lines:
489
+ with ui.row().classes(
490
+ "dstf-var-line2 items-center no-wrap gap-2 w-full"
491
+ ):
492
+ if show_muted and muted is not None:
493
+ ui.label(muted).classes("dstf-varname")
494
+ ui.space()
495
+ # These two never wrap (the stylesheet's `.dstf-var-where`
496
+ # rule keeps each to one line and unshrunk): squeezed by a
497
+ # long name, "+ new plot" took two lines and "plot 2, 3"
498
+ # broke after "plot".
499
+ if row.elsewhere:
500
+ ui.label(
501
+ "plot " + ", ".join(str(i + 1) for i in row.elsewhere)
502
+ ).classes("dstf-why dstf-var-where")
503
+ self._new_button(row, key)
504
+
505
+ def _new_button(self, row: VarRow, key: str) -> None:
506
+ """ "+ new plot": a watch plot is of ONE variable, and a variable has
507
+ one plot at most — grey, with why, on a family's row (its members,
508
+ opened, each make one) and on any plot's subject."""
509
+ why = (
510
+ self.withheld
511
+ if self.withheld is not None
512
+ else f"Already the subject of plot {row.subject_of + 1}"
513
+ if row.subject_of is not None
514
+ else "A watch plot shows one variable: open the family"
515
+ if len(row.members) > 1
516
+ else None
517
+ )
518
+ with ui.element("span").mark(marker("var-new-why", key)):
519
+ new = (
520
+ ui.button("+ new plot", on_click=partial(self._new, row))
521
+ .props("flat dense no-caps size=sm")
522
+ .classes("dstf-var-new")
523
+ .mark(marker("var-new", key))
524
+ )
525
+ if why is not None:
526
+ new.disable()
527
+ ui.tooltip(why)
528
+
529
+ # -- the edits -----------------------------------------------------------------
530
+
531
+ def _family(self, row: VarRow) -> tuple[str, bool]:
532
+ """What an opened family is known by: its key, and whether it is
533
+ tracked. The key alone is not enough — a tracked family whose
534
+ expressions are ``pipe.T[i]`` keys ``pipe.T[]`` as the result's own
535
+ ``pipe.T[i]`` do — and a family is all tracked or all not
536
+ (`variable_rows` folds the two apart)."""
537
+ return row.key, row.members[0] in self.tracked
538
+
539
+ def _open(self, family: tuple[str, bool]) -> None:
540
+ if family in self.opened:
541
+ self.opened.discard(family)
542
+ else:
543
+ self.opened.add(family)
544
+ self.rebuild()
545
+
546
+ def _check(self, row: VarRow) -> None:
547
+ """The row as CONTEXT on the selected plot: all of its members
548
+ there take them off, else the missing ones are added — never the
549
+ plot's subject.
550
+
551
+ ``row`` was drawn before the last change, and the selection may name
552
+ a plot there no longer is: a real double-click raised IndexError on
553
+ its second click once. So the plots are read NOW, a selection
554
+ outside them does nothing, and on or off is what that plot holds
555
+ now — never ``row.on_selected``."""
556
+ page = self.page
557
+ session = page.shell.session
558
+ plots = session.plots(page.model_id)
559
+ if not 0 <= page.selected < len(plots):
560
+ return
561
+ plot = plots[page.selected]
562
+ members = [m for m in row.members if m not in plot.subject]
563
+ if not members:
564
+ return # the plot's subject: its check is grey
565
+ extras: tuple[PlotExtra, ...] = session.layout(page.model_id)
566
+ if all(m in plot.context for m in members):
567
+ for name in members:
568
+ extras = remove_context(extras, plot, name)
569
+ else:
570
+ extras = add_context(extras, plot, members)
571
+ page.stage_layout(extras)
572
+
573
+ def _new(self, row: VarRow) -> None:
574
+ """A WATCH plot of the row's one variable, after every other plot,
575
+ selected. Read NOW: a variable some plot has as its subject — a
576
+ second click before the page caught up with the first — makes
577
+ none, and neither does a result that is not a success (O2's gate)
578
+ since the row was drawn."""
579
+ page = self.page
580
+ session = page.shell.session
581
+ plots = session.plots(page.model_id)
582
+ if (
583
+ page.plots_withheld()
584
+ or len(row.members) != 1
585
+ or any(row.members[0] in p.subject for p in plots)
586
+ ):
587
+ return
588
+ extras = new_watch(session.layout(page.model_id), row.members[0])
589
+ page.stage_layout(extras, select=len(plots))