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.
- dstf/__init__.py +41 -0
- dstf/__main__.py +10 -0
- dstf/authoring/__init__.py +13 -0
- dstf/authoring/builtin/__init__.py +32 -0
- dstf/authoring/builtin/accept.py +205 -0
- dstf/authoring/builtin/edit_criteria.py +479 -0
- dstf/authoring/builtin/edit_family.py +228 -0
- dstf/authoring/builtin/edit_on_plot.py +1354 -0
- dstf/authoring/builtin/edit_tree.py +1399 -0
- dstf/authoring/builtin/enqueue.py +144 -0
- dstf/authoring/capabilities.py +463 -0
- dstf/authoring/errors.py +49 -0
- dstf/authoring/family_rows.py +526 -0
- dstf/authoring/forms.py +631 -0
- dstf/authoring/layout.py +104 -0
- dstf/authoring/live_rows.py +337 -0
- dstf/authoring/live_runs.py +385 -0
- dstf/authoring/needle.py +127 -0
- dstf/authoring/picker_rows.py +92 -0
- dstf/authoring/queue_rows.py +354 -0
- dstf/authoring/session.py +3016 -0
- dstf/authoring/shell/__init__.py +9 -0
- dstf/authoring/shell/actions.py +702 -0
- dstf/authoring/shell/app.py +53 -0
- dstf/authoring/shell/drawer_controls.py +190 -0
- dstf/authoring/shell/family_fold.py +468 -0
- dstf/authoring/shell/figure_hook.py +249 -0
- dstf/authoring/shell/grips.js +1000 -0
- dstf/authoring/shell/header.py +783 -0
- dstf/authoring/shell/live_view.py +93 -0
- dstf/authoring/shell/picker.py +267 -0
- dstf/authoring/shell/placing.py +122 -0
- dstf/authoring/shell/plot_card.py +1171 -0
- dstf/authoring/shell/queue_panel.py +433 -0
- dstf/authoring/shell/rail.py +593 -0
- dstf/authoring/shell/shell.py +528 -0
- dstf/authoring/shell/stats_card.py +501 -0
- dstf/authoring/shell/strip.py +204 -0
- dstf/authoring/shell/style.py +721 -0
- dstf/authoring/shell/suite.py +277 -0
- dstf/authoring/shell/suite_grid.js +414 -0
- dstf/authoring/shell/suite_grid.py +373 -0
- dstf/authoring/shell/tab_theme.py +110 -0
- dstf/authoring/shell/tabstate.py +146 -0
- dstf/authoring/shell/test_list.py +179 -0
- dstf/authoring/shell/test_page.py +1404 -0
- dstf/authoring/shell/tree_card.py +1171 -0
- dstf/authoring/shell/variables_drawer.py +589 -0
- dstf/authoring/shell/widgets.py +188 -0
- dstf/authoring/situation.py +287 -0
- dstf/authoring/spec_document.py +215 -0
- dstf/authoring/spec_preview.py +83 -0
- dstf/authoring/suite_rows.py +99 -0
- dstf/authoring/tree_move.py +516 -0
- dstf/authoring/tree_pointer.py +33 -0
- dstf/authoring/tree_text.py +61 -0
- dstf/authoring/typed_number.js +64 -0
- dstf/authoring/typed_number.py +76 -0
- dstf/authoring/unscorable.py +141 -0
- dstf/authoring/variable_rows.py +216 -0
- dstf/backends/__init__.py +283 -0
- dstf/backends/base.py +1117 -0
- dstf/backends/common/__init__.py +1 -0
- dstf/backends/common/json_driver.py +484 -0
- dstf/backends/common/live_mat.py +369 -0
- dstf/backends/common/mat_reader.py +632 -0
- dstf/backends/common/proc_kill.py +238 -0
- dstf/backends/common/proc_output.py +20 -0
- dstf/backends/common/result_files.py +109 -0
- dstf/backends/common/result_variables.py +329 -0
- dstf/backends/common/statistics.py +39 -0
- dstf/backends/cross_backend.py +193 -0
- dstf/backends/dymola/__init__.py +5 -0
- dstf/backends/dymola/backend.py +776 -0
- dstf/backends/dymola/interface_loader.py +241 -0
- dstf/backends/dymola/log_parser.py +396 -0
- dstf/backends/dymola/persistent_backend.py +949 -0
- dstf/backends/fmpy/__init__.py +11 -0
- dstf/backends/fmpy/backend.py +443 -0
- dstf/backends/julia/__init__.py +27 -0
- dstf/backends/julia/backend.py +156 -0
- dstf/backends/julia/persistent_backend.py +541 -0
- dstf/backends/julia/run_persistent.jl +167 -0
- dstf/backends/julia/run_test.jl +146 -0
- dstf/backends/openmodelica/__init__.py +21 -0
- dstf/backends/openmodelica/backend.py +368 -0
- dstf/backends/openmodelica/init_xml.py +76 -0
- dstf/backends/openmodelica/log_parser.py +119 -0
- dstf/backends/openmodelica/mos_generator.py +228 -0
- dstf/backends/openmodelica/persistent_backend.py +1045 -0
- dstf/backends/openmodelica/session_loader.py +63 -0
- dstf/backends/persistent.py +1811 -0
- dstf/backends/pool.py +602 -0
- dstf/backends/progress.py +395 -0
- dstf/backends/python/__init__.py +11 -0
- dstf/backends/python/backend.py +141 -0
- dstf/backends/python/run_test.py +141 -0
- dstf/backends/run_metadata.py +103 -0
- dstf/backends/types.py +310 -0
- dstf/cli/__init__.py +12 -0
- dstf/cli/author_cmd.py +61 -0
- dstf/cli/baseline_cmds.py +341 -0
- dstf/cli/common.py +231 -0
- dstf/cli/diag_cmds.py +350 -0
- dstf/cli/main.py +561 -0
- dstf/cli/review.py +398 -0
- dstf/cli/run_cmds.py +589 -0
- dstf/cli/spec_cmds.py +123 -0
- dstf/comparison/__init__.py +13 -0
- dstf/comparison/algorithms.py +2127 -0
- dstf/comparison/builtins.py +291 -0
- dstf/comparison/comparator.py +1189 -0
- dstf/comparison/geometry.py +164 -0
- dstf/comparison/handles.py +1194 -0
- dstf/comparison/metric_tree.py +332 -0
- dstf/comparison/mode_schema.py +176 -0
- dstf/comparison/modes.py +788 -0
- dstf/comparison/registry.py +230 -0
- dstf/comparison/seeds.py +281 -0
- dstf/comparison/sides.py +158 -0
- dstf/comparison/snap.py +62 -0
- dstf/comparison/strict_keys.py +246 -0
- dstf/comparison/summaries.py +354 -0
- dstf/comparison/tree_eval.py +868 -0
- dstf/comparison/tree_spec.py +678 -0
- dstf/comparison/types.py +194 -0
- dstf/comparison/validator.py +232 -0
- dstf/config.py +815 -0
- dstf/discovery/__init__.py +0 -0
- dstf/discovery/json_recognizer.py +702 -0
- dstf/discovery/library_state.py +248 -0
- dstf/discovery/mo_parser.py +794 -0
- dstf/discovery/patch_apply.py +308 -0
- dstf/discovery/patch_envelope.py +63 -0
- dstf/discovery/plots.py +318 -0
- dstf/discovery/recognizer.py +103 -0
- dstf/discovery/scan_cache.py +154 -0
- dstf/discovery/spec_parser.py +538 -0
- dstf/discovery/spec_shape.py +122 -0
- dstf/discovery/spec_update.py +493 -0
- dstf/discovery/test_registry.py +488 -0
- dstf/discovery/unit_tests.py +95 -0
- dstf/figures/__init__.py +14 -0
- dstf/figures/assemble.py +48 -0
- dstf/figures/decimate.py +150 -0
- dstf/figures/families.py +289 -0
- dstf/figures/palette.py +170 -0
- dstf/figures/plotly.py +1413 -0
- dstf/figures/view.py +233 -0
- dstf/pipeline.py +435 -0
- dstf/plugins.py +104 -0
- dstf/reporting/__init__.py +0 -0
- dstf/reporting/console_report.py +187 -0
- dstf/reporting/context_builder.py +979 -0
- dstf/reporting/dashboard_render.py +534 -0
- dstf/reporting/junit_report.py +192 -0
- dstf/reporting/overlay_loader.py +708 -0
- dstf/reporting/report_suite.py +1059 -0
- dstf/reporting/rows.py +458 -0
- dstf/reporting/schema_export.py +413 -0
- dstf/reporting/stats_rows.py +321 -0
- dstf/reporting/status.py +388 -0
- dstf/reporting/templates/axes.js +61 -0
- dstf/reporting/templates/dashboard.html +284 -0
- dstf/reporting/templates/dashboard.js +604 -0
- dstf/reporting/templates/filters.js +100 -0
- dstf/reporting/templates/interactive.html +420 -0
- dstf/reporting/templates/report.js +116 -0
- dstf/reporting/templates/vendor/plotly.min.js +8 -0
- dstf/reporting/theme.py +324 -0
- dstf/reporting/tree_rows.py +73 -0
- dstf/reporting/verdicts.py +124 -0
- dstf/storage/__init__.py +0 -0
- dstf/storage/accept.py +216 -0
- dstf/storage/baseline.py +43 -0
- dstf/storage/batch_manifest.py +204 -0
- dstf/storage/ref_index.py +295 -0
- dstf/storage/reference_store.py +661 -0
- dstf/storage/roles.py +576 -0
- dstf/storage/run_stats.py +85 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
- 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)
|