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,93 @@
1
+ """The Test page's part of a running test's view (D151): once a second —
2
+ the shell's `poll` — it says which variables its DRAWN plots show, and
3
+ re-sends the plots the last read brought samples for. It reads no file:
4
+ `Session.live_runs` polls once for every page, and the figure a card sends
5
+ is the Session's, the Actual trace the samples read so far.
6
+
7
+ A plot that is not drawn asks for nothing — a card draws when it scrolls
8
+ into view — and one Focus hides asks for nothing either. A re-send is the
9
+ card's own `redraw` (`Plotly.react`): a zoom, the legend's choices and the
10
+ page's scroll position are the browser's, and stay.
11
+
12
+ It also marks the page while its test has nothing scored because of a run
13
+ — in progress, or ended without finishing: no criterion has a verdict
14
+ then, and no pill is drawn (the rows say `waiting for result` or `not
15
+ scored` where a readout goes)."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from typing import TYPE_CHECKING
21
+
22
+ from .tree_card import _set_prop
23
+
24
+ if TYPE_CHECKING:
25
+ from .test_page import TestPage
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ LIVE_CSS = """
30
+ /* A test with nothing scored because of a run (D151): its criteria have no
31
+ verdict, so their pills are not drawn — the tree's, the plots' and the
32
+ Statistics card's. The header's badge is the test's own word. */
33
+ .dstf-test[data-unscored] .dstf-pill { display: none; }
34
+ /* One line under the strip: why a running test's page shows no growing
35
+ trace. */
36
+ .dstf-strip-note { padding: 2px 24px; font-size: 12px; color: var(--dstf-text-muted);
37
+ border-top: 1px solid var(--dstf-border-hairline); }
38
+ """
39
+
40
+
41
+ class LiveView:
42
+ def __init__(self, page: TestPage) -> None:
43
+ self.page = page
44
+
45
+ def mark(self, view: dict | None) -> None:
46
+ """The page's body says the word its criteria say in place of a
47
+ verdict (`live_rows.unscored_view`), or nothing. An attribute, the
48
+ server's own: a class the browser set would not survive a repaint."""
49
+ body = self.page.body
50
+ if body is None:
51
+ return
52
+ word = None if view is None else view.get("unscored")
53
+ if _set_prop(body, "data-unscored", word):
54
+ body.update()
55
+
56
+ def names(self) -> set[str]:
57
+ """The variables of the plots that are drawn and shown."""
58
+ return {
59
+ name
60
+ for card in self._drawn()
61
+ for name in card.resolved.series
62
+ if name not in card.resolved.missing
63
+ }
64
+
65
+ def _drawn(self) -> list:
66
+ return [
67
+ card
68
+ for card in self.page.cards
69
+ if card.plot is not None and card.card.visible
70
+ ]
71
+
72
+ def tick(self) -> None:
73
+ """Ask for the drawn plots' variables, and re-send each plot the
74
+ last poll brought a sample for. Nothing on a test that is not
75
+ running, or runs on a backend without a live view."""
76
+ page = self.page
77
+ runs, model_id = page.shell.session.live_runs, page.model_id
78
+ if not runs.polled(model_id):
79
+ return
80
+ runs.ask(model_id, self, self.names())
81
+ moved = [
82
+ card
83
+ for card in self._drawn()
84
+ if any(runs.moved(model_id, name) for name in card.resolved.series)
85
+ ]
86
+ if not moved:
87
+ return
88
+ view = page.shell.session.tree_view(model_id) # one read for them all
89
+ for card in moved:
90
+ try:
91
+ card.redraw(view)
92
+ except Exception: # one card's failure is not another's
93
+ logger.exception("live plot %s of %s", card.index, model_id)
@@ -0,0 +1,267 @@
1
+ """The add form's variable picker (the plan's Task 3, Q8, K2): the drawers'
2
+ filter box over a scrolling list of rows in sections, filtered on the
3
+ SERVER (`picker_rows`, `needle.matcher`) — never a `ui.select` over every
4
+ name: TRANSFORM has a test of 76,992. One row is chosen (``value``, the
5
+ result-file name); ArrowUp / ArrowDown move the cursor from the filter
6
+ box, Enter chooses the cursor's row, a click chooses a row. Under the list
7
+ a `Chosen:` line names the choice (K7), so a filter that hides its row
8
+ still shows what Add will stage.
9
+
10
+ A `ui.column` with a ``value``, so a form reads it as it read the select
11
+ it replaced."""
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from collections.abc import Callable
17
+ from typing import Any
18
+
19
+ import regex
20
+ from nicegui import ui
21
+
22
+ from ..needle import matcher
23
+ from ..picker_rows import Picked, PickRow
24
+ from .drawer_controls import filter_box
25
+ from .widgets import MiddleCut
26
+
27
+ _SPACE = re.compile(r"\s+")
28
+
29
+ #: The filter box's keys the list answers, the page's default kept for
30
+ #: every other key (a caret move, a typed character). Each carries the
31
+ #: box's text AS TYPED: the filter reaches the server 250 ms after the last
32
+ #: keystroke (`debounce`), and ↓ Enter typed inside that moved the cursor
33
+ #: on the list before the filter and chose a row the list then hid. The
34
+ #: `change` first flushes Quasar's debounce, so its model value is that
35
+ #: text too: left at "" while `T_amb` ↓ drew T_amb's rows, the text then
36
+ #: deleted inside the debounce emitted nothing (the value never moved)
37
+ #: and the list stayed T_amb's under an empty box.
38
+ _KEYS = """(e) => {
39
+ if (['ArrowDown', 'ArrowUp', 'Enter'].includes(e.key)) {
40
+ e.preventDefault();
41
+ e.target.dispatchEvent(new Event('change'));
42
+ emit({key: e.key, needle: e.target.value || ''});
43
+ }
44
+ }"""
45
+
46
+
47
+ def _mark(name: str) -> str:
48
+ """A row's marker word: NiceGUI splits a marker on whitespace."""
49
+ return _SPACE.sub("", name)
50
+
51
+
52
+ class VariablePicker(ui.column):
53
+ def __init__(
54
+ self,
55
+ rows_of: Callable[[Callable[[str], bool]], Picked],
56
+ *,
57
+ value: str,
58
+ text_of: Callable[[str], str],
59
+ on_change: Callable[[str], None] | None = None,
60
+ marker: str,
61
+ ) -> None:
62
+ """``rows_of(keep)`` is the rows the filter ``keep`` leaves
63
+ (`picker_rows`); ``text_of(name)`` what a name reads as, for the
64
+ Chosen line."""
65
+ super().__init__()
66
+ self.classes("dstf-picker w-full gap-0")
67
+ self.mark(marker)
68
+ self._rows_of, self._on_change, self._marker = rows_of, on_change, marker
69
+ self._text_of = text_of
70
+ self.value = value
71
+ self.cursor = -1
72
+ self.shown: list[PickRow] = []
73
+ self._drawn: dict[str, ui.element] = {}
74
+ with self:
75
+ self.box = filter_box(
76
+ "",
77
+ False,
78
+ False,
79
+ self._filter,
80
+ "search every variable",
81
+ f"{marker}:pick-",
82
+ )
83
+ # The field's name, as the select it replaced was named.
84
+ self.box.needle.props["label"] = "Variable"
85
+ self.box.needle.on("keydown", self._key, js_handler=_KEYS)
86
+ self.list = (
87
+ ui.column()
88
+ .classes("dstf-pick-list w-full gap-0")
89
+ .mark(f"{marker}:pick-list")
90
+ )
91
+ # A combobox over a listbox (WAI-ARIA): the box keeps the focus
92
+ # and names the cursor's row as its active descendant.
93
+ self.list.props["role"] = "listbox"
94
+ # Not "Variables", which reads as the box's "Variable". A lookup
95
+ # of the box goes by its role: a label lookup of "Variable" also
96
+ # finds the "Other variables" group.
97
+ self.list.props["aria-label"] = "Matching names"
98
+ needle = self.box.needle.props
99
+ needle["role"] = "combobox"
100
+ needle["aria-controls"] = self.list.html_id
101
+ needle["aria-expanded"] = "true"
102
+ needle["aria-autocomplete"] = "list"
103
+ # Outside the list: a listbox holds options and groups of them.
104
+ self.none = ui.label("no variable matches").classes("dstf-why")
105
+ self.none.props["role"] = "status"
106
+ self.none.set_visibility(False)
107
+ self.cap = ui.label().classes("dstf-why").mark(f"{marker}:pick-cap")
108
+ self.chosen = (
109
+ ui.label()
110
+ .classes("dstf-pick-chosen text-caption")
111
+ .mark(f"{marker}:pick-chosen")
112
+ )
113
+ self._state: dict[str, Any] = {"needle": "", "regex": False, "case": False}
114
+ self._say_chosen()
115
+ self._draw()
116
+
117
+ # -- the list ------------------------------------------------------------
118
+
119
+ def _filter(self, needle: str, regex_on: bool, case: bool) -> None:
120
+ """The box's filter. The same one again — the debounced change after
121
+ a key already drew its needle (`_key`) — draws nothing: a redraw
122
+ would take the cursor off the row just moved to."""
123
+ state = {"needle": needle, "regex": regex_on, "case": case}
124
+ if state == self._state:
125
+ return
126
+ self._state = state
127
+ self._draw()
128
+
129
+ def _draw(self) -> None:
130
+ st = self._state
131
+ self.list.clear()
132
+ self._drawn, self.shown, self.cursor = {}, [], -1
133
+ self._active(None)
134
+ try:
135
+ keep = matcher(st["needle"], regex=st["regex"], case=st["case"])
136
+ picked = self._rows_of(keep)
137
+ except regex.error as e:
138
+ self.box.say(f"not a regular expression: {e}")
139
+ self.cap.set_visibility(False)
140
+ self.none.set_visibility(False)
141
+ return
142
+ except TimeoutError:
143
+ self.box.say("the pattern took too long; simplify it")
144
+ self.cap.set_visibility(False)
145
+ self.none.set_visibility(False)
146
+ return
147
+ self.box.say(None)
148
+ with self.list:
149
+ for title, rows in picked.sections:
150
+ # A named group of options (WAI-ARIA): its title is the
151
+ # group's name, so it is not read a second time.
152
+ with ui.column().classes("w-full gap-0") as group:
153
+ head = ui.label(title).classes("dstf-group")
154
+ head.mark(f"{self._marker}:pick-section:{_mark(title)}")
155
+ head.props["aria-hidden"] = "true"
156
+ for row in rows:
157
+ self._row(row)
158
+ group.props["role"] = "group"
159
+ group.props["aria-label"] = title
160
+ self.none.set_visibility(not picked.sections)
161
+ self.cap.set_text(picked.capped or "")
162
+ self.cap.set_visibility(picked.capped is not None)
163
+ if self.value in self._drawn:
164
+ self.cursor = [r.name for r in self.shown].index(self.value)
165
+
166
+ def _row(self, row: PickRow) -> None:
167
+ self.shown.append(row)
168
+ with (
169
+ ui.row()
170
+ .classes("dstf-pick-row items-baseline no-wrap gap-2 w-full")
171
+ .mark(f"{self._marker}:pick:{_mark(row.name)}")
172
+ .on("click", lambda _e, n=row.name: self.set_value(n))
173
+ ) as element:
174
+ # The plain label: the muted name beside it names the row, and
175
+ # the scope's text would say the name twice, `a.Q (y) y`.
176
+ text = MiddleCut(row.label).classes("dstf-var-text dstf-mono text-xs")
177
+ text.props["title"] = row.name
178
+ if row.label != row.name:
179
+ ui.label(row.name).classes("dstf-varname dstf-var-text")
180
+ element.props["role"] = "option"
181
+ # Its accessible name is said, not read off the row: the middle
182
+ # cut's two parts read as "…T_very_long_co mponent_name". The name
183
+ # once — from the scope's text it read `a.Q (y) (y)`.
184
+ element.props["aria-label"] = (
185
+ row.label if row.label == row.name else f"{row.label} ({row.name})"
186
+ )
187
+ element.props["aria-selected"] = "true" if row.name == self.value else "false"
188
+ if row.name == self.value:
189
+ element.classes(add="dstf-pick-on")
190
+ self._drawn[row.name] = element
191
+
192
+ @property
193
+ def options(self) -> dict[str, str]:
194
+ """``{name: text}`` of the rows drawn now, in their order."""
195
+ return {r.name: r.text for r in self.shown}
196
+
197
+ # -- choosing ------------------------------------------------------------
198
+
199
+ def _say_chosen(self) -> None:
200
+ self.chosen.set_text(
201
+ f"Chosen: {self._text_of(self.value)}" if self.value else "Chosen: none"
202
+ )
203
+
204
+ def set_value(self, name: str) -> None:
205
+ """Choose ``name``: its row is highlighted and carries the cursor
206
+ (a click's row is the one ↓ moves from), the Chosen line names it,
207
+ ``on_change`` hears it."""
208
+ old = self._drawn.get(self.value)
209
+ if old is not None:
210
+ old.classes(remove="dstf-pick-on")
211
+ old.props["aria-selected"] = "false"
212
+ old.update()
213
+ self.value = name
214
+ new = self._drawn.get(name)
215
+ if new is not None:
216
+ new.classes(add="dstf-pick-on")
217
+ new.props["aria-selected"] = "true"
218
+ new.update()
219
+ self._move([r.name for r in self.shown].index(name))
220
+ self._say_chosen()
221
+ if self._on_change is not None:
222
+ self._on_change(name)
223
+
224
+ def _move(self, to: int) -> None:
225
+ if not self.shown:
226
+ return
227
+ old = self._cursor_element()
228
+ if old is not None:
229
+ old.classes(remove="dstf-pick-cursor")
230
+ self.cursor = to % len(self.shown)
231
+ new = self._cursor_element()
232
+ self._active(new)
233
+ if new is not None:
234
+ new.classes(add="dstf-pick-cursor")
235
+ self.client.run_javascript(
236
+ f'document.getElementById("{new.html_id}")'
237
+ '?.scrollIntoView({block: "nearest"})'
238
+ )
239
+
240
+ def _active(self, row: ui.element | None) -> None:
241
+ """The box's ``aria-activedescendant``: the cursor's row, if any."""
242
+ props = self.box.needle.props
243
+ if row is None:
244
+ if props.pop("aria-activedescendant", None) is not None:
245
+ self.box.needle.update()
246
+ return
247
+ props["aria-activedescendant"] = row.html_id
248
+ self.box.needle.update()
249
+
250
+ def _cursor_element(self) -> ui.element | None:
251
+ if 0 <= self.cursor < len(self.shown):
252
+ return self._drawn.get(self.shown[self.cursor].name)
253
+ return None
254
+
255
+ def _key(self, e: Any) -> None:
256
+ """A key on the list AS THE USER SEES IT: a needle typed since the
257
+ last filter is drawn first (`_KEYS`)."""
258
+ key, needle = e.args["key"], e.args["needle"]
259
+ if needle != self._state["needle"]:
260
+ self._state = {**self._state, "needle": needle}
261
+ self._draw()
262
+ if key == "ArrowDown":
263
+ self._move(self.cursor + 1)
264
+ elif key == "ArrowUp":
265
+ self._move(self.cursor - 1 if self.cursor >= 0 else -1)
266
+ elif key == "Enter" and 0 <= self.cursor < len(self.shown):
267
+ self.set_value(self.shown[self.cursor].name)
@@ -0,0 +1,122 @@
1
+ """The open criterion editor's ⌖ buttons and its one armed field (D149:
2
+ arm a field, then click the plot).
3
+
4
+ A field that names a place on the plot carries a ⌖ (`Panel.place_button`).
5
+ Pressed, it ARMS that field: the next plain click on the criterion's plot
6
+ is handed to the panel that drew it (`Panel.on_place`), which sets the
7
+ field from the click; a click that goes through disarms, a refused one
8
+ says why under the plot and leaves the field armed. One field is armed at
9
+ a time — one editor is open on a page, and this is its — and pressing ⌖
10
+ again, Esc, closing the editor or opening another criterion disarms.
11
+
12
+ The armed field is the SERVER's to know (it reads the click), so its
13
+ button's highlight is an attribute the server renders — ``aria-pressed``,
14
+ which the stylesheet reads — never a class the page sets.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Callable, Hashable
20
+ from dataclasses import dataclass
21
+
22
+ from nicegui import ui
23
+
24
+ from .figure_hook import FigureHook
25
+
26
+ #: The hook kinds a panel's place handler and preview handler are
27
+ #: subscribed under (`ShellPanel.on_place` / `.on_preview`): so a rebuilt
28
+ #: panel's go with its other handlers (`FigureHook.clear(owner)`).
29
+ PLACE = "dstf_place"
30
+ PREVIEW = "dstf_preview"
31
+
32
+ #: A click with any of these held is not a plain click.
33
+ MODIFIERS = ("ctrl", "shift", "alt", "meta")
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Armed:
38
+ """The armed field: the capability whose panel drew it, the name that
39
+ panel gave it (`Panel.place_button`'s ``pointer``) and its label."""
40
+
41
+ owner: str
42
+ pointer: Hashable
43
+ label: str
44
+
45
+
46
+ class Placing:
47
+ """One plot card's: the ⌖ buttons of the editor open under it, which
48
+ of their fields is armed, and the hook its clicks arrive on."""
49
+
50
+ def __init__(self, hook: FigureHook, on_change: Callable[[], None]) -> None:
51
+ self.hook = hook
52
+ #: Called when the armed field changes: the card says it in the
53
+ #: editor's hint line.
54
+ self._on_change = on_change
55
+ self.armed: Armed | None = None
56
+ self._buttons: dict[tuple[str, Hashable], ui.button] = {}
57
+
58
+ def button(self, owner: str, pointer: Hashable, label: str) -> None:
59
+ """Draw the ⌖ of the field ``owner``'s panel calls ``pointer``,
60
+ into the current slot; its click never reaches the field it sits
61
+ in. Out of the Tab order: it is the first half of a gesture whose
62
+ second is a click on the plot, and in it Tab from a field met a ⌖
63
+ before the next field — twice in every tolerance."""
64
+ armed = Armed(owner, pointer, label)
65
+ button = (
66
+ ui.button(icon="my_location", color=None)
67
+ .props('flat dense round size=xs tabindex="-1"')
68
+ .classes("dstf-arm")
69
+ .on(
70
+ "click",
71
+ lambda: self.toggle(armed),
72
+ js_handler="(e) => { e.stopPropagation(); emit(); }",
73
+ )
74
+ )
75
+ button.props["aria-label"] = f"Set {label} by a click on the plot"
76
+ button.props["aria-pressed"] = "true" if self.armed == armed else "false"
77
+ # The browser's own tooltip, not an element: a table of thirty
78
+ # checkpoints draws 180 of these on every rebuild of the editor.
79
+ button.props["title"] = "Set by a click on the plot"
80
+ self._buttons[owner, pointer] = button
81
+
82
+ def toggle(self, armed: Armed) -> None:
83
+ self._set(None if self.armed == armed else armed)
84
+
85
+ def disarm(self) -> None:
86
+ if self.armed is not None:
87
+ self._set(None)
88
+
89
+ def settle(self) -> None:
90
+ """After the editor's panels were rebuilt: the buttons that went
91
+ with them are forgotten, and a field that is no longer drawn — its
92
+ row removed, its mode switched away — is no longer armed."""
93
+ self._buttons = {k: b for k, b in self._buttons.items() if not b.is_deleted}
94
+ armed = self.armed
95
+ if armed is not None and (armed.owner, armed.pointer) not in self._buttons:
96
+ self._set(None)
97
+
98
+ def forget(self) -> None:
99
+ """The editor closed: nothing is armed and no button is its."""
100
+ self._buttons = {}
101
+ self.disarm()
102
+
103
+ def _set(self, armed: Armed | None) -> None:
104
+ self.armed = armed
105
+ for (owner, pointer), button in self._buttons.items():
106
+ if button.is_deleted:
107
+ continue
108
+ on = armed is not None and (armed.owner, armed.pointer) == (owner, pointer)
109
+ pressed = "true" if on else "false"
110
+ # Only when it changes: every write of a prop resends the button.
111
+ if button.props.get("aria-pressed") != pressed:
112
+ button.props["aria-pressed"] = pressed
113
+ button.update()
114
+ self._on_change()
115
+
116
+ def clicked(self, click: dict) -> None:
117
+ """A click on the plot, as the page reports it: every subscriber
118
+ of the raw click hears it (Ctrl+click adds a row), and a PLAIN one
119
+ is the armed field's."""
120
+ self.hook.emit("dstf_click", click)
121
+ if self.armed is not None and not any(click.get(key) for key in MODIFIERS):
122
+ self.hook.emit(PLACE, click)