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,1171 @@
|
|
|
1
|
+
"""One plot of the Test page (brief §3.2; DESIGN.md "Plots are modular",
|
|
2
|
+
"Criteria rows"): its title and status pills, its context series, the slot
|
|
3
|
+
the plot is drawn into when it scrolls into view, a criteria header holding
|
|
4
|
+
what attaches at the VARIABLE placement (the plot's `+`), the criteria rows
|
|
5
|
+
of its SUBJECT in tree order with each row's node controls, and the one
|
|
6
|
+
open criterion's editor below its row. A card owns its `ui.plotly`, its
|
|
7
|
+
`FigureHook` and its snap-back; the card knows no capability by name.
|
|
8
|
+
|
|
9
|
+
A plot is a view of the criteria tree (`derive_plots`): a DERIVED card
|
|
10
|
+
exists because criteria name its subject and goes when the last of them
|
|
11
|
+
goes — it has no Remove; a WATCH card is a person's unscored plot of one
|
|
12
|
+
variable, and has.
|
|
13
|
+
|
|
14
|
+
A card is a 320 px slot until `show()` is called — by the browser's
|
|
15
|
+
`dstf_plot_visible`, by an opening editor, or by a test. Nothing on the
|
|
16
|
+
page draws a figure before that: a test with a dozen variables would
|
|
17
|
+
otherwise assemble a dozen figures nobody has scrolled to."""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
from collections.abc import Mapping, Sequence
|
|
24
|
+
from functools import partial
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import TYPE_CHECKING, Any
|
|
27
|
+
|
|
28
|
+
from nicegui import ui
|
|
29
|
+
|
|
30
|
+
from ...backends.types import VariableText
|
|
31
|
+
from ...comparison import registry
|
|
32
|
+
from ...discovery.plots import ResolvedPlot
|
|
33
|
+
from ...figures.assemble import leaves_by_variable
|
|
34
|
+
from ...figures.families import family_label, group_families
|
|
35
|
+
from ...figures.plotly import _LABELS, Drags, Visibility, drags_of
|
|
36
|
+
from ...reporting.tree_rows import criteria_rows, leaves_of, names_a_series
|
|
37
|
+
from ..capabilities import Placement
|
|
38
|
+
from ..family_rows import is_fold, leaf_of
|
|
39
|
+
from ..session import Selection
|
|
40
|
+
from ..tree_text import leaf_name, parent_kinds
|
|
41
|
+
from .actions import InlineHost, RowTarget
|
|
42
|
+
from .family_fold import Folding
|
|
43
|
+
from .figure_hook import FigureHook
|
|
44
|
+
from .placing import Placing
|
|
45
|
+
from .tree_card import leaf_readout, leaf_status
|
|
46
|
+
from .widgets import MiddleCut, Pill
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING:
|
|
49
|
+
from .test_page import TestPage
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
52
|
+
|
|
53
|
+
#: Worst first: a card's pill is the worst of its variable's criteria, never
|
|
54
|
+
#: the first one's (a passing leaf above a failing one must not read PASS).
|
|
55
|
+
_WORST = ("fail", "warn", "pass", "queued")
|
|
56
|
+
|
|
57
|
+
#: What a leaf contributes to a plot's signature: everything a staged edit
|
|
58
|
+
#: can move in what the plot DRAWS — its geometry (the metric and its
|
|
59
|
+
#: parameters, the window it is scored over, the baseline it is scored
|
|
60
|
+
#: against) and its verdict, which the row beside the plot reads out. Not
|
|
61
|
+
#: the trajectories: no leaf sees a rerun, which forces its own redraw.
|
|
62
|
+
_SIGNED = (
|
|
63
|
+
"metric",
|
|
64
|
+
"params",
|
|
65
|
+
"window",
|
|
66
|
+
"against",
|
|
67
|
+
"passed",
|
|
68
|
+
"score_display",
|
|
69
|
+
"criterion",
|
|
70
|
+
"error",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
#: What the OPEN editor's fields were built from: the leaf at the open path,
|
|
74
|
+
#: as the page's tree view showed it. Its verdict is not in it — a rerun
|
|
75
|
+
#: changes no field — and nothing else in the document is either: comparing
|
|
76
|
+
#: the whole document's pending ops meant an edit to ANOTHER model, followed
|
|
77
|
+
#: by any event that synced the page, rebuilt this editor and threw away a
|
|
78
|
+
#: half-typed table row; and a combinator edit that moved no leaf rebuilt it for nothing (the
|
|
79
|
+
#: 3b review). ``advisory`` is the one key no test pins: nothing the editor
|
|
80
|
+
#: offers writes it, so a change to it can only arrive from a hand-written
|
|
81
|
+
#: patch — it is here because the field IS the leaf's, not because a path
|
|
82
|
+
#: was found that moves it.
|
|
83
|
+
_EDITED = ("metric", "variable", "params", "window", "against", "advisory")
|
|
84
|
+
|
|
85
|
+
#: The open editor's hint line (`PlotCard._say_hint`), by what the plot
|
|
86
|
+
#: draws for the open criterion to be dragged by (`figures.plotly.drags_of`)
|
|
87
|
+
#: — rings and arrows, arrows with no ring to hover, rings alone, bound
|
|
88
|
+
#: lines alone, nothing — and what follows each. A constant tube and an
|
|
89
|
+
#: event's window were told to drag a ring and to hover a point: they have
|
|
90
|
+
#: arrows, and neither.
|
|
91
|
+
EDITOR_HINTS: dict[Drags, str] = {
|
|
92
|
+
"rings+arrows": (
|
|
93
|
+
"Drag a ring or a tolerance arrow on the plot (hover a point to show "
|
|
94
|
+
"its arrows), or edit the fields."
|
|
95
|
+
),
|
|
96
|
+
"arrows": (
|
|
97
|
+
"Drag a tolerance arrow on the plot (hover near it to show the "
|
|
98
|
+
"arrows), or edit the fields."
|
|
99
|
+
),
|
|
100
|
+
"rings": "Drag a ring on the plot, or edit the fields.",
|
|
101
|
+
"lines": "Drag a line on the plot, or edit the fields.",
|
|
102
|
+
"": "Edit the fields.",
|
|
103
|
+
}
|
|
104
|
+
STAGED = " Edits are staged as you make them; Save writes them."
|
|
105
|
+
#: What the hint adds for a criterion whose metric has rows a Ctrl+click
|
|
106
|
+
#: adds to (D149), ``{}`` being what the row is called — the metric's own
|
|
107
|
+
#: word (`MetricDescriptor.row_noun`) — and what it says INSTEAD while a
|
|
108
|
+
#: field is armed, ``{}`` being that field's label.
|
|
109
|
+
ADDS = " Ctrl+click adds {}."
|
|
110
|
+
ARMED = "Click the plot to set {} — Esc cancels."
|
|
111
|
+
#: What the line under a family's plot says for a Ctrl+click made while a
|
|
112
|
+
#: FOLDED row's editor is open (D152: fields only), ``{}`` as in `ADDS`: a
|
|
113
|
+
#: row is added to one criterion's table, and a fold is several.
|
|
114
|
+
FOLD_ADDS = (
|
|
115
|
+
"Ctrl+click adds {} to one criterion: unfold this row and open the one to add to."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
#: The hover module of the tolerance grips (`grips.js`): its file, and where
|
|
119
|
+
#: the shell serves it — beside the Suite table's scripts.
|
|
120
|
+
GRIPS_JS = Path(__file__).parent / "grips.js"
|
|
121
|
+
GRIPS_URL = "/_dstf/grips.js"
|
|
122
|
+
|
|
123
|
+
#: "Not read yet" — `None` cannot mean it: a test with nothing scored has a
|
|
124
|
+
#: `tree_view` of None, and a card that read it for itself cost one read per
|
|
125
|
+
#: shown card on every row click (the 3b review).
|
|
126
|
+
UNREAD: Any = object()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def worst(statuses: list[str]) -> str:
|
|
130
|
+
return next((status for status in _WORST if status in statuses), "queued")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def drawn(leaf: dict, choice: bool | None, is_open: bool) -> bool:
|
|
134
|
+
"""Whether one criterion draws on its plot (legend b). The open one
|
|
135
|
+
always does — the plot is its editing surface. Else the swatch's choice,
|
|
136
|
+
when there is one; else a failing criterion does, so a FAIL never starts
|
|
137
|
+
hidden; else the metric's own default."""
|
|
138
|
+
if is_open:
|
|
139
|
+
return True
|
|
140
|
+
if choice is not None:
|
|
141
|
+
return choice
|
|
142
|
+
if leaf.get("passed") is False:
|
|
143
|
+
return True
|
|
144
|
+
desc = registry.metric(str(leaf.get("metric")))
|
|
145
|
+
return desc is None or desc.drawn_by_default
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def folded(
|
|
149
|
+
names: Sequence[str],
|
|
150
|
+
labels: Mapping[str, str] | None = None,
|
|
151
|
+
by: Mapping[str, str] | None = None,
|
|
152
|
+
) -> str:
|
|
153
|
+
"""``names`` as a plot names them, the way the report's plot titles do
|
|
154
|
+
(`report_suite`): each array family one entry, READ through ``labels``
|
|
155
|
+
(``pipe.T[1:3]``), the fold taken over ``by`` — ``labels`` when not
|
|
156
|
+
given — so two series whose texts collide are no family. The card's
|
|
157
|
+
title, its muted names line and the focus bar all name a plot through
|
|
158
|
+
this, so they fold alike: the focus bar read the
|
|
159
|
+
unfolded texts while the title above it folded them."""
|
|
160
|
+
fold_over = labels if by is None else by
|
|
161
|
+
return ", ".join(
|
|
162
|
+
family_label(key, members, labels)
|
|
163
|
+
for key, members in group_families(names, fold_over)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
#: The swatch's tooltip: what a click on it would do, or why it does nothing.
|
|
168
|
+
_SWATCH_TIPS = {
|
|
169
|
+
"open": "the open criterion always draws",
|
|
170
|
+
True: "Hide this criterion's drawing",
|
|
171
|
+
False: "Show this criterion's drawing",
|
|
172
|
+
}
|
|
173
|
+
_SWATCH_STATES = " ".join(
|
|
174
|
+
[
|
|
175
|
+
*(
|
|
176
|
+
f"dstf-swatch-{s}"
|
|
177
|
+
for s in ("on", "off", "locked", "pass", "fail", "warn", "queued")
|
|
178
|
+
),
|
|
179
|
+
"invisible",
|
|
180
|
+
]
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
#: The node the criteria header's forms act on: the tree's root, which a
|
|
185
|
+
#: plot's `+` adds under.
|
|
186
|
+
TREE_ROOT = "/metrics"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _root_kind(view: dict | None) -> str | None:
|
|
190
|
+
"""The root's combinator (None for a root leaf or no tree): what the
|
|
191
|
+
header's `+` reads — it is grey under a warn or a weighted root."""
|
|
192
|
+
return None if view is None else view.get("combinator")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class CriterionRow:
|
|
196
|
+
"""One criterion under its plot (DESIGN.md "Criteria rows"): the swatch
|
|
197
|
+
that shows or hides its drawing, the pill, the metric and its variable,
|
|
198
|
+
the CLI's readout, and that node's controls — whatever attaches at NODE.
|
|
199
|
+
`paint` writes a new verdict into the row that is already there."""
|
|
200
|
+
|
|
201
|
+
def __init__(
|
|
202
|
+
self,
|
|
203
|
+
card: PlotCard,
|
|
204
|
+
leaf: dict,
|
|
205
|
+
taker: str | None,
|
|
206
|
+
crumb: str = "",
|
|
207
|
+
crumb_ch: int = 0,
|
|
208
|
+
) -> None:
|
|
209
|
+
"""``crumb_ch`` is the card's longest crumb, in characters: every
|
|
210
|
+
crumb on the card is that wide, so the names start in one column."""
|
|
211
|
+
index, path = card.index, str(leaf["path"])
|
|
212
|
+
self.card, self.leaf = card, leaf
|
|
213
|
+
#: The parent's combinator the controls were built under: it takes
|
|
214
|
+
#: the child `+` adds, so a kind that moves rebuilds them (`paint`).
|
|
215
|
+
self.taker = taker
|
|
216
|
+
self.path, self.variable = path, str(leaf.get("variable"))
|
|
217
|
+
model, variable = card.page.model_id, self.variable
|
|
218
|
+
# The row WRAPS, as a Quasar row does unless told not to: a long
|
|
219
|
+
# name keeps one line, and the readout and the node's controls move
|
|
220
|
+
# under it.
|
|
221
|
+
self.row = ui.row().classes("dstf-crit items-center gap-3 w-full")
|
|
222
|
+
# The row IS the way in: its editor opens below it, and a second
|
|
223
|
+
# click closes it — or closes a form row open under it and opens
|
|
224
|
+
# nothing (board 1A). A click on the node's controls inside the row
|
|
225
|
+
# is not a click on the row — `InlineHost`'s column stops it.
|
|
226
|
+
self.row.on("click", partial(card.page.row_click, index, path))
|
|
227
|
+
with self.row.mark(f"criterion:{index}:{path}"):
|
|
228
|
+
# The criterion's drawing on the plot above, shown or hidden
|
|
229
|
+
# (legend b). Its click is stopped in the BROWSER before it
|
|
230
|
+
# reaches the row, which would open the editor too; the user
|
|
231
|
+
# simulation's click never bubbles, so only Chromium can say so.
|
|
232
|
+
# A button to the keyboard as well: Tab reaches it, and Enter or
|
|
233
|
+
# Space toggles it the way a click does (Space's default would
|
|
234
|
+
# scroll the page).
|
|
235
|
+
self.swatch = (
|
|
236
|
+
ui.element("span")
|
|
237
|
+
.classes("dstf-swatch")
|
|
238
|
+
.props('role=button tabindex="0"')
|
|
239
|
+
.mark(f"swatch:{index}:{path}")
|
|
240
|
+
.on(
|
|
241
|
+
"click",
|
|
242
|
+
self._toggle,
|
|
243
|
+
js_handler="(e) => { e.stopPropagation(); emit(); }",
|
|
244
|
+
)
|
|
245
|
+
.on(
|
|
246
|
+
"keydown",
|
|
247
|
+
self._toggle,
|
|
248
|
+
js_handler=(
|
|
249
|
+
"(e) => { if (e.key === 'Enter' || e.key === ' ') "
|
|
250
|
+
"{ e.preventDefault(); e.stopPropagation(); emit(); } }"
|
|
251
|
+
),
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
with self.swatch:
|
|
255
|
+
self.swatch_tip = ui.tooltip("")
|
|
256
|
+
# Hidden until the plot is drawn: which leaves draw anything is
|
|
257
|
+
# known only from the figure (`PlotCard.drawable`). INVISIBLE,
|
|
258
|
+
# never display:none — a hidden swatch keeps its box, so every
|
|
259
|
+
# row's name starts in one column (D140).
|
|
260
|
+
self.swatch.classes("invisible")
|
|
261
|
+
# Its ancestors' kinds, muted: where in the tree it sits. The
|
|
262
|
+
# card's rows come from anywhere in it (a plot is a view of the
|
|
263
|
+
# tree), and their order alone does not say which is under what.
|
|
264
|
+
if crumb:
|
|
265
|
+
ui.label(crumb).classes("dstf-crumb dstf-why").style(
|
|
266
|
+
f"min-width: {crumb_ch}ch"
|
|
267
|
+
).mark(f"crumb:{index}:{path}")
|
|
268
|
+
self.pill = Pill(leaf_status(leaf), f"criterion-pill:{index}:{path}")
|
|
269
|
+
self.name = MiddleCut().classes(
|
|
270
|
+
"dstf-crit-name text-body2 text-weight-medium"
|
|
271
|
+
)
|
|
272
|
+
self.readout = ui.label().classes("dstf-mono text-xs")
|
|
273
|
+
self.readout.mark(f"criterion-readout:{index}:{path}")
|
|
274
|
+
self.target = RowTarget(
|
|
275
|
+
card.page, f"crit:{index}:{path}", self.row, path, index
|
|
276
|
+
)
|
|
277
|
+
self.host = InlineHost(
|
|
278
|
+
card.page.shell,
|
|
279
|
+
Placement.NODE,
|
|
280
|
+
partial(
|
|
281
|
+
Selection,
|
|
282
|
+
tests=frozenset({model}),
|
|
283
|
+
variable=variable,
|
|
284
|
+
node_path=path,
|
|
285
|
+
),
|
|
286
|
+
figure=None,
|
|
287
|
+
marker=f"node:{index}:{path}",
|
|
288
|
+
row=self.target,
|
|
289
|
+
apply=card.page.apply,
|
|
290
|
+
)
|
|
291
|
+
# A host drawn inside a row keeps to its own width, or the
|
|
292
|
+
# cluster takes the rest of the row and sits left of the end
|
|
293
|
+
# (the 3b review, in Chromium). The stylesheet's margin puts it
|
|
294
|
+
# at the row's right end, on the name's line or, the row
|
|
295
|
+
# wrapped, on the next — a spacer before it did that only when
|
|
296
|
+
# it wrapped together with the readout.
|
|
297
|
+
self.host.column.classes(remove="w-full", add="dstf-node-host")
|
|
298
|
+
# The row's slot: its editor, or a form row — one at a time. The
|
|
299
|
+
# host is built once its panels have somewhere to open a row.
|
|
300
|
+
self.slot = ui.column().classes("w-full gap-0")
|
|
301
|
+
self.target.slot = self.slot
|
|
302
|
+
self.host.build()
|
|
303
|
+
self.paint(leaf)
|
|
304
|
+
|
|
305
|
+
def paint(self, leaf: dict) -> None:
|
|
306
|
+
self.pill.set(leaf_status(leaf))
|
|
307
|
+
self.name.set_text(leaf_name(leaf))
|
|
308
|
+
self.readout.set_text(leaf_readout(leaf))
|
|
309
|
+
self.paint_swatch(leaf)
|
|
310
|
+
|
|
311
|
+
def paint_swatch(self, leaf: dict) -> None:
|
|
312
|
+
"""Filled in the pill's status colour while the drawing shows, an
|
|
313
|
+
outline of it while hidden; locked while this criterion is open. A
|
|
314
|
+
verdict that flips moves a default, so every paint repaints it."""
|
|
315
|
+
self.leaf = leaf
|
|
316
|
+
card = self.card
|
|
317
|
+
is_open = card.is_open(self.path)
|
|
318
|
+
on = drawn(leaf, card.choices.get(self.path), is_open)
|
|
319
|
+
# ONE call, its visibility too: a remove and then an add are two
|
|
320
|
+
# changes to the element even when they cancel, and a no-op sync
|
|
321
|
+
# re-sent every swatch.
|
|
322
|
+
self.swatch.classes(
|
|
323
|
+
remove=_SWATCH_STATES,
|
|
324
|
+
add=f"dstf-swatch-{'on' if on else 'off'} dstf-swatch-{leaf_status(leaf)}"
|
|
325
|
+
+ (" dstf-swatch-locked" if is_open else "")
|
|
326
|
+
+ ("" if self.path in card.drawable else " invisible"),
|
|
327
|
+
)
|
|
328
|
+
if is_open:
|
|
329
|
+
self.swatch.props("aria-disabled=true")
|
|
330
|
+
else:
|
|
331
|
+
self.swatch.props(remove="aria-disabled")
|
|
332
|
+
self.swatch_tip.set_text(_SWATCH_TIPS["open" if is_open else on])
|
|
333
|
+
|
|
334
|
+
def _toggle(self) -> None:
|
|
335
|
+
card = self.card
|
|
336
|
+
if card.is_open(self.path):
|
|
337
|
+
return # the open criterion always draws: it is the editing surface
|
|
338
|
+
card.choices[self.path] = not drawn(
|
|
339
|
+
self.leaf, card.choices.get(self.path), False
|
|
340
|
+
)
|
|
341
|
+
# By the signature, which carries the hidden set: one send, and only
|
|
342
|
+
# of this card.
|
|
343
|
+
card.redraw_if_moved()
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class PlotCard:
|
|
347
|
+
def __init__(
|
|
348
|
+
self,
|
|
349
|
+
page: TestPage,
|
|
350
|
+
index: int,
|
|
351
|
+
resolved: ResolvedPlot,
|
|
352
|
+
view: dict | None,
|
|
353
|
+
scope: Mapping[str, VariableText],
|
|
354
|
+
) -> None:
|
|
355
|
+
"""``view`` and ``scope`` are the page's ONE read of
|
|
356
|
+
each, handed to every card: a card that read them for itself cost
|
|
357
|
+
one read per plot on every rebuild (the Task 5-6 review)."""
|
|
358
|
+
self.page, self.index, self.resolved = page, index, resolved
|
|
359
|
+
self.plot: ui.plotly | None = None
|
|
360
|
+
# Created here, attached in `show()`: a panel hosted under this card
|
|
361
|
+
# subscribes before there is a plot to attach to.
|
|
362
|
+
self.hook = FigureHook(on_shape_moved=self.redraw)
|
|
363
|
+
#: The open editor's ⌖ buttons and its armed field (D149).
|
|
364
|
+
self.placing = Placing(self.hook, self._armed_moved)
|
|
365
|
+
self.rows: dict[str, CriterionRow] = {}
|
|
366
|
+
self.pills: dict[str, Pill] = {}
|
|
367
|
+
#: The criteria header's host: what attaches at VARIABLE, inline
|
|
368
|
+
#: (Q3 A: the plot's `+`), and the root's kind it was built under.
|
|
369
|
+
#: The card's shape does not hold the root's kind, so `paint`
|
|
370
|
+
#: rebuilds the host when it moves.
|
|
371
|
+
self.variable_host: InlineHost | None = None
|
|
372
|
+
self.root_kind: str | None = None
|
|
373
|
+
self.editor: InlineHost | None = None # the open criterion's, if it is here
|
|
374
|
+
#: The open editor's own column in its row's slot: the slot also
|
|
375
|
+
#: holds the row host's error labels, so closing takes only this.
|
|
376
|
+
self.editor_box: ui.column | None = None
|
|
377
|
+
self.hint: ui.label | None = None # the open editor's hint line
|
|
378
|
+
#: A refused drag's reason, directly under the plot (`_said`); None
|
|
379
|
+
#: on a card with no plot.
|
|
380
|
+
self.refusal: ui.label | None = None
|
|
381
|
+
self.armed_line: ui.label | None = None
|
|
382
|
+
self.open_path: str | None = None
|
|
383
|
+
self._drawn: str | None = None # the signature of what the plot shows
|
|
384
|
+
#: Whether this card has been asked to draw. A plot that raised is
|
|
385
|
+
#: retried on the next sync, and only on a card somebody wanted.
|
|
386
|
+
self.wanted = False
|
|
387
|
+
#: The text of the error the last try raised, or None. The retry is
|
|
388
|
+
#: free; the traceback is not — a card that cannot draw is tried
|
|
389
|
+
#: again by every sync, a rescan's progress ticks included, and 50
|
|
390
|
+
#: of those over two failing cards logged 100 tracebacks for one
|
|
391
|
+
#: broken result (the final review). A new text is a new line.
|
|
392
|
+
self._error: str | None = None
|
|
393
|
+
#: Stamped on every figure this card sends; see `_figure`.
|
|
394
|
+
self._revision = 0
|
|
395
|
+
#: The leaf the open editor was built from (`_EDITED`).
|
|
396
|
+
self._edited: tuple | None = None
|
|
397
|
+
#: Legend b: the swatches' choices by leaf path (a leaf with none
|
|
398
|
+
#: follows `drawn`'s defaults), the one error series shown, and the
|
|
399
|
+
#: leaves the last figure drew anything for — only those get a
|
|
400
|
+
#: swatch. Set before the rows, which paint their swatch from them.
|
|
401
|
+
self.choices: dict[str, bool] = {}
|
|
402
|
+
self.error: str | None = None
|
|
403
|
+
self.drawable: frozenset[str] = frozenset()
|
|
404
|
+
self.error_select: ui.select | None = None
|
|
405
|
+
model = page.model_id
|
|
406
|
+
#: The plot's SUBJECT — one variable, or the members of one family —
|
|
407
|
+
#: whose criteria are its rows and whose first member the header's
|
|
408
|
+
#: `+` adds for. Its context is drawn and nothing more.
|
|
409
|
+
self.subject = list(resolved.subject)
|
|
410
|
+
#: A card of ONE variable has it in its title, so its pill is the
|
|
411
|
+
#: bare status; several pills side by side read "FAIL FAIL" and look
|
|
412
|
+
#: like the plot's verdict said twice (the board).
|
|
413
|
+
self.named_pills = len(resolved.subject) > 1
|
|
414
|
+
with (
|
|
415
|
+
ui.card()
|
|
416
|
+
.classes("dstf-card dstf-plot w-full")
|
|
417
|
+
.mark(f"plot:{index}") as card
|
|
418
|
+
):
|
|
419
|
+
self.card = card
|
|
420
|
+
# A click anywhere on the card selects its plot — the toolbar,
|
|
421
|
+
# the title, a criteria row — except on the plot itself, which
|
|
422
|
+
# takes zooms and handle drags (3c, planner's choice 8). The guard is
|
|
423
|
+
# the browser's: the user simulation calls every click listener
|
|
424
|
+
# and ignores `js_handler`.
|
|
425
|
+
card.on(
|
|
426
|
+
"click",
|
|
427
|
+
partial(page.select, index),
|
|
428
|
+
js_handler=(
|
|
429
|
+
"(e) => { if (!e.target.closest('.dstf-plot-slot')) emit(); }"
|
|
430
|
+
),
|
|
431
|
+
)
|
|
432
|
+
# The card's header row WRAPS: the title with its pills, and the
|
|
433
|
+
# toolbar as one unit, right-aligned on the title's line when it
|
|
434
|
+
# fits and on a line of its own when not. A row that neither
|
|
435
|
+
# wrapped nor shrank put Remove plot at 1140-1202 px of a 900 px
|
|
436
|
+
# window beside a drawer, on a real library's long names.
|
|
437
|
+
with ui.row().classes("dstf-card-head items-center gap-3 w-full"):
|
|
438
|
+
# The title READS the plain labels (the expressions the
|
|
439
|
+
# model wrote); the names it is keyed by ride beside it,
|
|
440
|
+
# muted, and only when one of them differs — a card for
|
|
441
|
+
# `x` would otherwise say `x x`. The muted name is what
|
|
442
|
+
# tells two series sharing one expression apart HERE, so
|
|
443
|
+
# the title does not also carry it in parentheses.
|
|
444
|
+
labels = {n: scope[n].label if n in scope else n for n in self.subject}
|
|
445
|
+
#: What each of this card's series READS as everywhere the
|
|
446
|
+
#: name is NOT beside it — its pill, the focus bar, its
|
|
447
|
+
#: "Add a criterion for x" `+`, and the context row, which
|
|
448
|
+
#: is why the context is in it too. Disambiguated over the
|
|
449
|
+
#: whole TEST, so all of those agree.
|
|
450
|
+
names = (*self.subject, *resolved.context)
|
|
451
|
+
self.texts = {n: scope[n].text if n in scope else n for n in names}
|
|
452
|
+
# Array families fold, as the report's plot titles do
|
|
453
|
+
# (`report_suite`): the fold over the texts, so two series
|
|
454
|
+
# whose labels collide are no family; each family READS its
|
|
455
|
+
# labels' slice (`pipe.T[1:3]`), and the muted line folds
|
|
456
|
+
# the names the same way. The title is the SUBJECT's: the
|
|
457
|
+
# context is listed under it.
|
|
458
|
+
series = self.subject
|
|
459
|
+
with ui.row().classes("dstf-card-title items-center gap-3"):
|
|
460
|
+
MiddleCut(folded(series, labels, self.texts)).classes(
|
|
461
|
+
"text-subtitle2"
|
|
462
|
+
).mark(f"plot-title:{index}")
|
|
463
|
+
if any(labels[n] != n for n in series):
|
|
464
|
+
ui.label(folded(series)).classes("dstf-varname").mark(
|
|
465
|
+
f"plot-name:{index}"
|
|
466
|
+
)
|
|
467
|
+
for variable in self.subject:
|
|
468
|
+
if self._leaves(view, variable):
|
|
469
|
+
pill = Pill("queued", f"plot-pill:{index}:{variable}")
|
|
470
|
+
if self.named_pills:
|
|
471
|
+
# The name keeps its own case: the pill's
|
|
472
|
+
# text is uppercased by the stylesheet, and
|
|
473
|
+
# `X FAIL` names no variable of this test.
|
|
474
|
+
pill.label.classes(add="dstf-pill-named")
|
|
475
|
+
self.pills[variable] = pill
|
|
476
|
+
# The plot's whole toolbar (decision 9): the legend toggles
|
|
477
|
+
# the signals and the overlays; the criteria are toggled
|
|
478
|
+
# from their rows (legend b) and the errors from this select,
|
|
479
|
+
# built once the plot is drawn.
|
|
480
|
+
with ui.row().classes("dstf-card-tools items-center no-wrap gap-3"):
|
|
481
|
+
self.error_slot = ui.row().classes("items-center gap-0")
|
|
482
|
+
self.focus = ui.button("Focus", on_click=partial(page.focus, index))
|
|
483
|
+
self.focus.props("flat dense no-caps").mark(f"focus:{index}")
|
|
484
|
+
self._remove_button(index)
|
|
485
|
+
if resolved.missing:
|
|
486
|
+
ui.label(f"Not in this result: {', '.join(resolved.missing)}").classes(
|
|
487
|
+
"dstf-why"
|
|
488
|
+
).mark(f"plot-missing:{index}")
|
|
489
|
+
if resolved.context:
|
|
490
|
+
self._context_row(index)
|
|
491
|
+
# A subject the result lacks entirely has no plot: an empty
|
|
492
|
+
# chart said nothing the note does not, and no slot is nothing
|
|
493
|
+
# for the browser's observer to ask to draw.
|
|
494
|
+
self.slot: ui.element | None = None
|
|
495
|
+
if not set(resolved.subject) <= set(resolved.missing):
|
|
496
|
+
self.slot = ui.element("div").classes("dstf-plot-slot w-full")
|
|
497
|
+
self.slot.props(f'data-plot="{index}"').mark(f"plot-slot:{index}")
|
|
498
|
+
# Between the plot and its criteria rows: the editor that
|
|
499
|
+
# says a refused drag's reason can be a screen below the
|
|
500
|
+
# plot the hand is on (measured at y = 1020 of a 900 px
|
|
501
|
+
# window, the plot at 350-670).
|
|
502
|
+
self.refusal = ui.label("").classes(
|
|
503
|
+
"dstf-plot-refusal text-negative text-xs"
|
|
504
|
+
)
|
|
505
|
+
self.refusal.mark(f"plot-refusal:{index}").set_visibility(False)
|
|
506
|
+
# And what an ARMED field's next click does (D149), where
|
|
507
|
+
# the hand is: the ⌖ itself can be a screen below the plot.
|
|
508
|
+
self.armed_line = ui.label("").classes("dstf-plot-armed text-xs")
|
|
509
|
+
self.armed_line.mark(f"plot-armed:{index}").set_visibility(False)
|
|
510
|
+
self._criteria_header(index, model, scope, view)
|
|
511
|
+
criteria = ui.column().classes("dstf-criteria w-full gap-0")
|
|
512
|
+
takers = parent_kinds(view)
|
|
513
|
+
with criteria.mark(f"criteria:{index}"):
|
|
514
|
+
# TREE order, never the subject's member order: a family's
|
|
515
|
+
# rows are where the tree has them, each with its crumb —
|
|
516
|
+
# the report's walk (`criteria_rows`), so both tiers lead a
|
|
517
|
+
# row the same way.
|
|
518
|
+
rows = criteria_rows(view, self.subject)
|
|
519
|
+
crumb_ch = max((len(row["crumbs"]) for row in rows), default=0)
|
|
520
|
+
for row in rows:
|
|
521
|
+
path = str(row["leaf"]["path"])
|
|
522
|
+
self.rows[path] = CriterionRow(
|
|
523
|
+
self, row["leaf"], takers.get(path), row["crumbs"], crumb_ch
|
|
524
|
+
)
|
|
525
|
+
#: A family's identical criteria, folded into one row each
|
|
526
|
+
#: (D152): painted with the rows, in `paint`.
|
|
527
|
+
self.folding = Folding(self, criteria, crumb_ch)
|
|
528
|
+
if resolved.watch:
|
|
529
|
+
ui.label("No criteria yet.").classes("dstf-why").mark(
|
|
530
|
+
f"plot-empty:{index}"
|
|
531
|
+
)
|
|
532
|
+
self.paint(view)
|
|
533
|
+
|
|
534
|
+
def _criteria_header(
|
|
535
|
+
self,
|
|
536
|
+
index: int,
|
|
537
|
+
model: str,
|
|
538
|
+
scope: Mapping[str, VariableText],
|
|
539
|
+
view: dict | None,
|
|
540
|
+
) -> None:
|
|
541
|
+
"""A muted "Criteria" and, at its right, whatever attaches at
|
|
542
|
+
VARIABLE — inline, for the FIRST member of the plot's subject, its forms
|
|
543
|
+
opening as a row under the header (Q3 A; the dialog and the footer
|
|
544
|
+
per variable are gone: one per line pushed the next plot off the
|
|
545
|
+
screen, and a form's select offers the test's other variables)."""
|
|
546
|
+
head = (
|
|
547
|
+
ui.row()
|
|
548
|
+
.classes("dstf-criteria-head items-center gap-3 w-full")
|
|
549
|
+
.mark(f"criteria-header:{index}")
|
|
550
|
+
)
|
|
551
|
+
with head:
|
|
552
|
+
ui.label("Criteria").classes("text-secondary text-xs")
|
|
553
|
+
target = RowTarget(self.page, f"plot:{index}", head, TREE_ROOT, index)
|
|
554
|
+
self.variable_host = InlineHost(
|
|
555
|
+
self.page.shell,
|
|
556
|
+
Placement.VARIABLE,
|
|
557
|
+
partial(
|
|
558
|
+
Selection,
|
|
559
|
+
tests=frozenset({model}),
|
|
560
|
+
variable=self.subject[0],
|
|
561
|
+
# The page's one read of the scope, so the `+` reads
|
|
562
|
+
# this variable's text without building it per card
|
|
563
|
+
# (`Selection.texts`).
|
|
564
|
+
texts=scope,
|
|
565
|
+
),
|
|
566
|
+
figure=self.hook,
|
|
567
|
+
marker=f"variable:{index}",
|
|
568
|
+
row=target,
|
|
569
|
+
apply=self.page.apply,
|
|
570
|
+
)
|
|
571
|
+
# Its own width, pushed to the header's right end (style.py).
|
|
572
|
+
self.variable_host.column.classes(remove="w-full", add="dstf-node-host")
|
|
573
|
+
# The form row's slot, between the header and the criteria rows; the
|
|
574
|
+
# host is built once its panels have somewhere to open a row.
|
|
575
|
+
target.slot = ui.column().classes("dstf-criteria-slot w-full gap-0")
|
|
576
|
+
self.root_kind = _root_kind(view)
|
|
577
|
+
self.variable_host.build()
|
|
578
|
+
|
|
579
|
+
def _remove_button(self, index: int) -> None:
|
|
580
|
+
"""Remove plot, on a WATCH card only: a derived plot exists because
|
|
581
|
+
of its criteria and goes when the last of them goes (Q16) — in its
|
|
582
|
+
place an icon says so."""
|
|
583
|
+
if not self.resolved.watch:
|
|
584
|
+
with (
|
|
585
|
+
ui.icon("info_outline", size="xs")
|
|
586
|
+
.classes("text-secondary")
|
|
587
|
+
.mark(f"plot-derived:{index}")
|
|
588
|
+
):
|
|
589
|
+
ui.tooltip("This plot exists because of its criteria.")
|
|
590
|
+
return
|
|
591
|
+
# Its click stops in the BROWSER: bubbling to the card selected it,
|
|
592
|
+
# and after the rebuild the ring sat on whichever plot moved into
|
|
593
|
+
# the removed one's place.
|
|
594
|
+
ui.button("Remove plot").props("flat dense no-caps").mark(
|
|
595
|
+
f"remove-plot:{index}"
|
|
596
|
+
).on(
|
|
597
|
+
"click",
|
|
598
|
+
partial(self.page.remove_watch, index, self.subject[0]),
|
|
599
|
+
js_handler="(e) => { e.stopPropagation(); emit(); }",
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
def _context_row(self, index: int) -> None:
|
|
603
|
+
"""The context series, each with a `×` that takes it off this plot
|
|
604
|
+
— one op. Drawn grey on the plot; listed here because Plotly's
|
|
605
|
+
legend cannot hold a button. Read as the test's texts, like the
|
|
606
|
+
title."""
|
|
607
|
+
with (
|
|
608
|
+
ui.row()
|
|
609
|
+
.classes("dstf-context items-center gap-2 w-full")
|
|
610
|
+
.mark(f"context:{index}")
|
|
611
|
+
):
|
|
612
|
+
ui.label("Context").classes("text-secondary text-xs")
|
|
613
|
+
for name in self.resolved.context:
|
|
614
|
+
text = self.texts.get(name, name)
|
|
615
|
+
with ui.row().classes("dstf-context-chip items-center no-wrap gap-0"):
|
|
616
|
+
ui.label(text).classes("dstf-mono text-xs").mark(
|
|
617
|
+
f"context-name:{index}:{name}"
|
|
618
|
+
)
|
|
619
|
+
button = (
|
|
620
|
+
ui.button(icon="close")
|
|
621
|
+
.props("flat dense round size=xs")
|
|
622
|
+
.mark(f"context-remove:{index}:{name}")
|
|
623
|
+
.on(
|
|
624
|
+
"click",
|
|
625
|
+
partial(self.page.remove_context, index, name),
|
|
626
|
+
js_handler="(e) => { e.stopPropagation(); emit(); }",
|
|
627
|
+
)
|
|
628
|
+
)
|
|
629
|
+
# On the props dict: a name can hold a `"`.
|
|
630
|
+
button.props["aria-label"] = f"Remove {text} from this plot"
|
|
631
|
+
|
|
632
|
+
@staticmethod
|
|
633
|
+
def _leaves(view: dict | None, variable: str) -> list[dict]:
|
|
634
|
+
return [
|
|
635
|
+
leaf
|
|
636
|
+
for leaf in leaves_of(view)
|
|
637
|
+
if leaf.get("variable") == variable and names_a_series(leaf)
|
|
638
|
+
]
|
|
639
|
+
|
|
640
|
+
def _view(self, view: Any = UNREAD) -> dict | None:
|
|
641
|
+
"""The page's read of this sync, or one of this card's own when it
|
|
642
|
+
was not given one."""
|
|
643
|
+
if view is UNREAD:
|
|
644
|
+
return self.page.shell.session.tree_view(self.page.model_id)
|
|
645
|
+
return view
|
|
646
|
+
|
|
647
|
+
def _edited_now(self, view: dict | None) -> tuple | None:
|
|
648
|
+
"""The leaf under the open editor, as ``view`` shows it — what the
|
|
649
|
+
editor's fields say. ``None`` when no editor is open, or when the
|
|
650
|
+
leaf it was opened on is no longer in the tree (a shape change,
|
|
651
|
+
which rebuilds the cards anyway)."""
|
|
652
|
+
if self.open_path is None:
|
|
653
|
+
return None
|
|
654
|
+
leaf = next(
|
|
655
|
+
(n for n in leaves_of(view) if str(n["path"]) == self.open_path), None
|
|
656
|
+
)
|
|
657
|
+
if leaf is None:
|
|
658
|
+
return None
|
|
659
|
+
return tuple(
|
|
660
|
+
json.dumps(leaf.get(key), sort_keys=True, default=str) for key in _EDITED
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
def paint(self, view: dict | None) -> None:
|
|
664
|
+
"""Pills, rows and the criteria header in place, from the page's one `tree_view`
|
|
665
|
+
read: a verdict that flips must not cost the card it sits in."""
|
|
666
|
+
for variable, pill in self.pills.items():
|
|
667
|
+
status = worst([leaf_status(leaf) for leaf in self._leaves(view, variable)])
|
|
668
|
+
# The pill READS the label, like the title above it: `x[1] FAIL`
|
|
669
|
+
# on a TRANSFORM card named nothing a person recognises.
|
|
670
|
+
text = (
|
|
671
|
+
f"{self.texts.get(variable, variable)} {status.upper()}"
|
|
672
|
+
if self.named_pills
|
|
673
|
+
else None
|
|
674
|
+
)
|
|
675
|
+
pill.set(status, text)
|
|
676
|
+
self._paint_rows(view)
|
|
677
|
+
self.folding.paint(view)
|
|
678
|
+
takers = parent_kinds(view)
|
|
679
|
+
for path, row in self.rows.items():
|
|
680
|
+
if takers.get(path) != row.taker:
|
|
681
|
+
# The row stands, but its parent changed kind — `+` grey
|
|
682
|
+
# under a warn or a weighted — and the controls read it
|
|
683
|
+
# when they are built (the Task 6 review): an edit from
|
|
684
|
+
# weighted to and left `+` grey with the weighted reason.
|
|
685
|
+
row.taker = takers.get(path)
|
|
686
|
+
row.host.build()
|
|
687
|
+
else:
|
|
688
|
+
# What its node's controls MAY do moves too, without the
|
|
689
|
+
# shape moving: a run of the test starts or ends, and a
|
|
690
|
+
# flat entry's criteria are edited one by one only while
|
|
691
|
+
# they are scored (D150).
|
|
692
|
+
row.host.follow()
|
|
693
|
+
if self.editor is not None:
|
|
694
|
+
edited = self._edited_now(view)
|
|
695
|
+
if edited != self._edited:
|
|
696
|
+
# The leaf under the open editor moved by an edit staged
|
|
697
|
+
# ELSEWHERE — a Discard, a Save, another node's controls.
|
|
698
|
+
# The fields kept showing the value that edit undid, and the
|
|
699
|
+
# next keystroke staged it straight back (the Task 6 review).
|
|
700
|
+
self.editor.build()
|
|
701
|
+
self._said(None) # the rebuild took the editor's own line
|
|
702
|
+
self._say_hint()
|
|
703
|
+
self._edited = edited
|
|
704
|
+
else:
|
|
705
|
+
# The editor is not rebuilt while it is open, so an answer
|
|
706
|
+
# that moves under it has to reach its panels here — and
|
|
707
|
+
# its hint, which says what the plot takes: a run that
|
|
708
|
+
# starts takes the handles and the table with it (D151).
|
|
709
|
+
self.editor.follow()
|
|
710
|
+
self._say_hint()
|
|
711
|
+
if self.variable_host is not None:
|
|
712
|
+
kind = _root_kind(view)
|
|
713
|
+
if kind != self.root_kind:
|
|
714
|
+
# The `+` reads the root's kind when it is built — grey
|
|
715
|
+
# under a warn or a weighted root — and the card's shape
|
|
716
|
+
# does not hold it, so the card stands and the host is
|
|
717
|
+
# rebuilt.
|
|
718
|
+
self.root_kind = kind
|
|
719
|
+
self.variable_host.build()
|
|
720
|
+
else:
|
|
721
|
+
# What it MAY do moves too (a run starts or ends).
|
|
722
|
+
self.variable_host.follow()
|
|
723
|
+
|
|
724
|
+
def _paint_rows(self, view: dict | None) -> None:
|
|
725
|
+
for leaf in leaves_of(view):
|
|
726
|
+
row = self.rows.get(str(leaf["path"]))
|
|
727
|
+
if row is not None:
|
|
728
|
+
row.paint(leaf)
|
|
729
|
+
|
|
730
|
+
# -- the plot ------------------------------------------------------------------
|
|
731
|
+
|
|
732
|
+
def _leaves_all(self, view: dict | None) -> list[dict]:
|
|
733
|
+
"""Every leaf on this plot's SUBJECT, in tree order: its rows, what
|
|
734
|
+
it draws, what its signature signs. Not its context — a context
|
|
735
|
+
variable scored on another plot draws nothing of its criteria here —
|
|
736
|
+
and no stored-scalar leaf, whose ``variable`` names the leaf, not a
|
|
737
|
+
series (`names_a_series`)."""
|
|
738
|
+
return [row["leaf"] for row in criteria_rows(view, self.resolved.subject)]
|
|
739
|
+
|
|
740
|
+
def is_open(self, path: str) -> bool:
|
|
741
|
+
"""Whether ``path`` is the open criterion — the page's word, the ONE
|
|
742
|
+
source the rings read too. Not `open_path`: that is set after
|
|
743
|
+
`show()` draws, so the first open of a hidden criterion on an
|
|
744
|
+
undrawn card sent it hidden and then, at once, drawn."""
|
|
745
|
+
return path in self.page.editing_for(self.index)
|
|
746
|
+
|
|
747
|
+
def visibility(self, view: Any = UNREAD) -> Visibility:
|
|
748
|
+
"""What this card chose to show (legend b): the leaves `drawn` says
|
|
749
|
+
are off, and the one error series."""
|
|
750
|
+
hidden = frozenset(
|
|
751
|
+
str(leaf["path"])
|
|
752
|
+
for leaf in self._leaves_all(self._view(view))
|
|
753
|
+
if not drawn(
|
|
754
|
+
leaf,
|
|
755
|
+
self.choices.get(str(leaf["path"])),
|
|
756
|
+
self.is_open(str(leaf["path"])),
|
|
757
|
+
)
|
|
758
|
+
)
|
|
759
|
+
return Visibility(hidden=hidden, error=self.error)
|
|
760
|
+
|
|
761
|
+
def _drawable(self, figure: dict, view: dict | None) -> frozenset[str]:
|
|
762
|
+
"""The leaves this figure draws ANYTHING for: a trace with the path
|
|
763
|
+
in ``meta.leaf``, or a shape named under it. A windowed nrmse draws
|
|
764
|
+
one shape and no trace, and its swatch must still show. A shape is
|
|
765
|
+
matched on its path plus ``/``, so ``/metrics/children/1`` never
|
|
766
|
+
claims a shape of ``/metrics/children/10``."""
|
|
767
|
+
paths = [str(leaf["path"]) for leaf in self._leaves_all(view)]
|
|
768
|
+
found = {(tr.get("meta") or {}).get("leaf") for tr in figure["data"]}
|
|
769
|
+
for shape in figure["layout"].get("shapes", []):
|
|
770
|
+
name = str(shape.get("name", ""))
|
|
771
|
+
hit = next((p for p in paths if name.startswith(p + "/")), None)
|
|
772
|
+
if hit is not None:
|
|
773
|
+
found.add(hit)
|
|
774
|
+
return frozenset(str(p) for p in found if p is not None)
|
|
775
|
+
|
|
776
|
+
def _figure(self, view: Any = UNREAD) -> dict:
|
|
777
|
+
"""The Session's figure with this card's own `editrevision` on it.
|
|
778
|
+
Plotly's `editrevision` DEFAULTS to `uirevision`, which the tool's
|
|
779
|
+
figure pins to "keep" so a zoom survives a redraw — and with it
|
|
780
|
+
pinned, `Plotly.react` keeps a shape the user dragged whenever the
|
|
781
|
+
value the server sends equals the one it sent before, which is
|
|
782
|
+
exactly the snap-back after a refused drag (measured in Chromium).
|
|
783
|
+
An incrementing revision says the shapes are new. The layout is
|
|
784
|
+
COPIED: a Session figure is deterministic, and nothing here writes
|
|
785
|
+
into what it was handed."""
|
|
786
|
+
view = self._view(view)
|
|
787
|
+
figure = self.page.shell.session.plot_figure(
|
|
788
|
+
self.page.model_id,
|
|
789
|
+
self.index,
|
|
790
|
+
editing=self.page.editing_for(self.index),
|
|
791
|
+
visibility=self.visibility(view),
|
|
792
|
+
theme=self.page.theme.name,
|
|
793
|
+
)
|
|
794
|
+
self.drawable = self._drawable(figure, view)
|
|
795
|
+
self._revision += 1
|
|
796
|
+
return {
|
|
797
|
+
**figure,
|
|
798
|
+
"layout": {**figure["layout"], "editrevision": self._revision},
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
def show(self, view: Any = UNREAD) -> None:
|
|
802
|
+
"""Draw the plot, once: the browser said this slot is near the
|
|
803
|
+
viewport (or a test did). The card remembers it was asked, so a try
|
|
804
|
+
that raised is retried by the next sync."""
|
|
805
|
+
self.wanted = True
|
|
806
|
+
if self.plot is not None or self.slot is None:
|
|
807
|
+
return
|
|
808
|
+
view = self._view(view)
|
|
809
|
+
# Whatever a previous try left: `show()` is called again on every
|
|
810
|
+
# opening row, and a slot that kept each failure's label stacked
|
|
811
|
+
# them — with the recovered plot drawn UNDER them.
|
|
812
|
+
self.slot.clear()
|
|
813
|
+
with self.slot:
|
|
814
|
+
try:
|
|
815
|
+
figure = self._figure(view)
|
|
816
|
+
except Exception as e: # a plot that cannot draw says so in place
|
|
817
|
+
text = f"{type(e).__name__}: {e}"
|
|
818
|
+
if text != self._error: # the same failure is not news
|
|
819
|
+
logger.exception("plot %s of %s", self.index, self.page.model_id)
|
|
820
|
+
self._error = text
|
|
821
|
+
ui.label(text).classes("text-negative")
|
|
822
|
+
return
|
|
823
|
+
self._error = None
|
|
824
|
+
self.plot = ui.plotly(figure).classes("w-full").style("height: 320px")
|
|
825
|
+
# A relayout passes through this plot's instance of the hover
|
|
826
|
+
# module on its way here: a ring, an arrow or a bound line released
|
|
827
|
+
# outside the plot area is held to the visible range, and says no
|
|
828
|
+
# position on an axis the pointer barely moved along (`grips.js`
|
|
829
|
+
# `released`).
|
|
830
|
+
self.hook.attach(
|
|
831
|
+
self.plot,
|
|
832
|
+
relayout_js=(
|
|
833
|
+
f"window.DstfGrips ? DstfGrips.released({self.plot.id}, e) : e"
|
|
834
|
+
),
|
|
835
|
+
)
|
|
836
|
+
# This plot's instance of the hover module: the open criterion's
|
|
837
|
+
# grips show while the pointer is near them. Sent to THIS page's
|
|
838
|
+
# client — `show()` also runs from the Shell's timer.
|
|
839
|
+
self.page.client.run_javascript(f"DstfGrips.install({self.plot.id})")
|
|
840
|
+
self._drawn = self.signature(view)
|
|
841
|
+
self._error_select(figure)
|
|
842
|
+
# `drawable` is first known here: the swatches show with the plot.
|
|
843
|
+
self._paint_rows(view)
|
|
844
|
+
|
|
845
|
+
def _error_select(self, figure: dict) -> None:
|
|
846
|
+
"""One select for the error series (legend b), on a plot whose
|
|
847
|
+
figure has any; a family's plot has none. Asked of every
|
|
848
|
+
figure sent, so a redraw that gains error traces gains the select."""
|
|
849
|
+
ids = [
|
|
850
|
+
tr["meta"]["id"]
|
|
851
|
+
for tr in figure["data"]
|
|
852
|
+
if (tr.get("meta") or {}).get("kind") == "error"
|
|
853
|
+
]
|
|
854
|
+
if not ids or self.error_select is not None:
|
|
855
|
+
return
|
|
856
|
+
with self.error_slot:
|
|
857
|
+
self.error_select = (
|
|
858
|
+
ui.select(
|
|
859
|
+
{"none": "No error", **{i: _LABELS[i] for i in ids}},
|
|
860
|
+
value="none",
|
|
861
|
+
on_change=lambda e: self._choose_error(e.value),
|
|
862
|
+
)
|
|
863
|
+
.props("dense options-dense")
|
|
864
|
+
.mark(f"error-select:{self.index}")
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
def _choose_error(self, value: str) -> None:
|
|
868
|
+
self.error = None if value == "none" else value
|
|
869
|
+
# By the signature, which carries the error: one send.
|
|
870
|
+
self.redraw_if_moved()
|
|
871
|
+
|
|
872
|
+
def _retried(self, view: Any) -> bool:
|
|
873
|
+
"""A card with no plot: retry the draw if somebody asked for one.
|
|
874
|
+
Nothing else asked again — a result file being rewritten under a
|
|
875
|
+
`show()` left the card showing its error until a row opened or
|
|
876
|
+
Focus landed on it (the 3b review)."""
|
|
877
|
+
if self.plot is not None:
|
|
878
|
+
return False
|
|
879
|
+
if self.wanted:
|
|
880
|
+
self.show(view)
|
|
881
|
+
return True
|
|
882
|
+
|
|
883
|
+
def redraw(self, view: Any = UNREAD) -> None:
|
|
884
|
+
"""`Plotly.react` on the same node — the figure's `config` never
|
|
885
|
+
depends on what is being edited, so the zoom survives. Also the
|
|
886
|
+
hook's snap-back after every drag, which is the ONE redraw that is
|
|
887
|
+
not decided by the signature: a refused drag moved nothing, and the
|
|
888
|
+
picture still has to go back. ``view`` is the page's read of this
|
|
889
|
+
sync, so a forced redraw costs no read of its own."""
|
|
890
|
+
if self._retried(view):
|
|
891
|
+
return
|
|
892
|
+
assert self.plot is not None
|
|
893
|
+
view = self._view(view)
|
|
894
|
+
figure = self._figure(view)
|
|
895
|
+
self.plot.update_figure(figure)
|
|
896
|
+
self._drawn = self.signature(view)
|
|
897
|
+
self._error_select(figure)
|
|
898
|
+
# The swatches say what was just sent (an open or a close moves one).
|
|
899
|
+
self._paint_rows(view)
|
|
900
|
+
|
|
901
|
+
def signature(self, view: Any = UNREAD) -> str:
|
|
902
|
+
"""What this plot draws that an edit can move: which criterion is
|
|
903
|
+
open — its rings, and only its — every leaf on this plot's subject,
|
|
904
|
+
which leaves are hidden and which error series shows. Comparing it
|
|
905
|
+
to what was last sent is the ONE thing that decides a redraw."""
|
|
906
|
+
view = self._view(view)
|
|
907
|
+
leaves = [
|
|
908
|
+
[leaf.get("path"), *(leaf.get(key) for key in _SIGNED)]
|
|
909
|
+
for leaf in self._leaves_all(view)
|
|
910
|
+
]
|
|
911
|
+
return json.dumps(
|
|
912
|
+
[
|
|
913
|
+
list(self.page.editing_for(self.index)),
|
|
914
|
+
leaves,
|
|
915
|
+
sorted(self.visibility(view).hidden),
|
|
916
|
+
self.error,
|
|
917
|
+
],
|
|
918
|
+
sort_keys=True,
|
|
919
|
+
default=str,
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
def redraw_if_moved(self, view: Any = UNREAD) -> None:
|
|
923
|
+
"""Re-send the figure when what it draws has moved, and only then: a
|
|
924
|
+
plot nobody's edit touched must not be re-sent (the cost the 3b
|
|
925
|
+
review measured is elements on the wire, not a figure assembled)."""
|
|
926
|
+
if self._retried(view):
|
|
927
|
+
return
|
|
928
|
+
if self.signature(view) != self._drawn:
|
|
929
|
+
self.redraw(view)
|
|
930
|
+
|
|
931
|
+
# -- the one open criterion's editor -------------------------------------------
|
|
932
|
+
|
|
933
|
+
def open_editor(self, path: str, view: Any = UNREAD) -> None:
|
|
934
|
+
"""The editor, below its row and at the plot's width (board 2A):
|
|
935
|
+
one hint line and Done, then every capability placed at CRITERION,
|
|
936
|
+
stacked without headings — the row above already names the
|
|
937
|
+
criterion, so the editor reads as one thing (brief §3.2). A folded
|
|
938
|
+
row's editor is its own (`family_fold.FoldRow.open_editor`)."""
|
|
939
|
+
view = self._view(view)
|
|
940
|
+
if self.folding.opens(path, view):
|
|
941
|
+
return
|
|
942
|
+
row, index = self.rows[path], self.index
|
|
943
|
+
model, variable = self.page.model_id, row.variable
|
|
944
|
+
self.open_path = path
|
|
945
|
+
row.row.classes(add="dstf-crit-open")
|
|
946
|
+
with row.slot:
|
|
947
|
+
self.editor_box = ui.column().classes("dstf-editor w-full gap-2")
|
|
948
|
+
with self.editor_box:
|
|
949
|
+
with ui.row().classes("items-center no-wrap gap-3 w-full"):
|
|
950
|
+
self.hint = (
|
|
951
|
+
ui.label("").classes("dstf-why").mark(f"editor-hint:{index}:{path}")
|
|
952
|
+
)
|
|
953
|
+
ui.space()
|
|
954
|
+
ui.button("Done", on_click=self.page.close_editor).props(
|
|
955
|
+
"flat dense no-caps"
|
|
956
|
+
).mark(f"editor-done:{index}:{path}")
|
|
957
|
+
self.editor = InlineHost(
|
|
958
|
+
self.page.shell,
|
|
959
|
+
Placement.CRITERION,
|
|
960
|
+
partial(
|
|
961
|
+
Selection,
|
|
962
|
+
tests=frozenset({model}),
|
|
963
|
+
variable=variable,
|
|
964
|
+
leaf_path=path,
|
|
965
|
+
),
|
|
966
|
+
figure=self.hook,
|
|
967
|
+
marker=f"editor:{index}:{path}",
|
|
968
|
+
on_applied=self._applied,
|
|
969
|
+
apply=self.page.apply,
|
|
970
|
+
# What the editor's panels show that the spec does not hold
|
|
971
|
+
# outlives them: a drag rebuilds the whole editor.
|
|
972
|
+
kept=partial(self.page.kept, path),
|
|
973
|
+
on_said=self._said,
|
|
974
|
+
placing=self.placing,
|
|
975
|
+
)
|
|
976
|
+
self.editor.build()
|
|
977
|
+
view = self._view(view)
|
|
978
|
+
self._say_hint()
|
|
979
|
+
self._edited = self._edited_now(view)
|
|
980
|
+
|
|
981
|
+
def _scored(self, path: str | None = None) -> Any:
|
|
982
|
+
"""The criterion at ``path`` (the open one, unless said) as it was
|
|
983
|
+
scored — its comparison, whose geometry the plot draws — or None
|
|
984
|
+
where it was not: a test that is running, or whose last run did
|
|
985
|
+
not finish (D151)."""
|
|
986
|
+
path = self.open_path if path is None else path
|
|
987
|
+
comp = self.page.shell.session.comparisons.get(self.page.model_id)
|
|
988
|
+
tree = comp.metric_tree if comp is not None else None
|
|
989
|
+
for pairs in leaves_by_variable(tree).values():
|
|
990
|
+
for at, vc in pairs:
|
|
991
|
+
if at == path:
|
|
992
|
+
return vc
|
|
993
|
+
return None
|
|
994
|
+
|
|
995
|
+
def _drags(self) -> Drags:
|
|
996
|
+
"""What the plot draws for the open criterion to be dragged by —
|
|
997
|
+
a key of `EDITOR_HINTS` — read off the geometry its leaf was scored
|
|
998
|
+
with, as the figure reads it: a range has lines and no ring, a
|
|
999
|
+
constant tube arrows and no ring, nrmse nothing, and none is known
|
|
1000
|
+
here by its name. Nothing where the
|
|
1001
|
+
leaf was not scored, or where its metric has no editor for a drag
|
|
1002
|
+
(one would be refused)."""
|
|
1003
|
+
vc = self._scored()
|
|
1004
|
+
if vc is None:
|
|
1005
|
+
return ""
|
|
1006
|
+
desc = registry.metric(vc.mode)
|
|
1007
|
+
if desc is None or desc.edit_handle is None:
|
|
1008
|
+
return ""
|
|
1009
|
+
return drags_of(vc.geometry)
|
|
1010
|
+
|
|
1011
|
+
def _say_hint(self) -> None:
|
|
1012
|
+
"""The editor's hint line: what drags on the plot for the open
|
|
1013
|
+
criterion. The arrows show only while the pointer is near their
|
|
1014
|
+
point — or, where they stand around no point, near themselves — so
|
|
1015
|
+
the line is what tells a person they exist; a criterion with rings
|
|
1016
|
+
and no arrow is told to drag a ring, one with lines alone to drag a
|
|
1017
|
+
line, and one that draws nothing to drag (nrmse) only to edit its
|
|
1018
|
+
fields — its Edit on plot place says why. Said again after an edit:
|
|
1019
|
+
the mode is a field of the editor."""
|
|
1020
|
+
if self.hint is None or self.hint.is_deleted:
|
|
1021
|
+
return
|
|
1022
|
+
if is_fold(self.open_path or ""):
|
|
1023
|
+
# A folded row's editor is this card's too, and its line is
|
|
1024
|
+
# its own (`family_fold.HINT`): fields only, nothing to drag.
|
|
1025
|
+
return
|
|
1026
|
+
armed = self.placing.armed
|
|
1027
|
+
if armed is not None:
|
|
1028
|
+
# While a field is armed the line says the one thing a click
|
|
1029
|
+
# on the plot now does.
|
|
1030
|
+
self.hint.set_text(ARMED.format(armed.label))
|
|
1031
|
+
return
|
|
1032
|
+
self.hint.set_text(EDITOR_HINTS[self._drags()] + self._adds() + STAGED)
|
|
1033
|
+
|
|
1034
|
+
def _row_noun(self, path: str) -> str | None:
|
|
1035
|
+
"""What a Ctrl+click adds to the criterion at ``path`` — its
|
|
1036
|
+
metric's own word for a row — or None where a click adds none: a
|
|
1037
|
+
metric with no rows, a tube of constant width, a metric no drag
|
|
1038
|
+
edits (its Edit on plot place is not drawn), and a criterion that
|
|
1039
|
+
was not scored: the click is read off its scored geometry, and its
|
|
1040
|
+
place says why it takes none."""
|
|
1041
|
+
if self._scored(path) is None:
|
|
1042
|
+
return None
|
|
1043
|
+
raw = self.page._node(path)
|
|
1044
|
+
desc = registry.metric(str((raw or {}).get("metric", registry.DEFAULT_METRIC)))
|
|
1045
|
+
if raw is None or desc is None or desc.place_row is None:
|
|
1046
|
+
return None
|
|
1047
|
+
return desc.row_noun(raw) if desc.row_noun is not None else None
|
|
1048
|
+
|
|
1049
|
+
def _adds(self) -> str:
|
|
1050
|
+
"""What a Ctrl+click adds to the open criterion, as the hint's
|
|
1051
|
+
sentence (`_row_noun`), or nothing."""
|
|
1052
|
+
noun = self._row_noun(self.open_path or "")
|
|
1053
|
+
return ADDS.format(noun) if noun else ""
|
|
1054
|
+
|
|
1055
|
+
def _armed_moved(self) -> None:
|
|
1056
|
+
"""The armed field changed (`Placing`): the editor's hint says it,
|
|
1057
|
+
and so does the plot's own card — the ⌖ and the hint can be a
|
|
1058
|
+
screen below the plot the click is made on. The card carries an
|
|
1059
|
+
ATTRIBUTE the stylesheet reads (an accent outline about the plot),
|
|
1060
|
+
and the line under the plot says what the next click sets."""
|
|
1061
|
+
self._say_hint()
|
|
1062
|
+
armed = self.placing.armed
|
|
1063
|
+
if self.card.is_deleted:
|
|
1064
|
+
return
|
|
1065
|
+
if armed is not None:
|
|
1066
|
+
self.card.props("data-armed")
|
|
1067
|
+
else:
|
|
1068
|
+
self.card.props(remove="data-armed")
|
|
1069
|
+
if self.armed_line is not None and not self.armed_line.is_deleted:
|
|
1070
|
+
self.armed_line.set_text("" if armed is None else ARMED.format(armed.label))
|
|
1071
|
+
self.armed_line.set_visibility(armed is not None)
|
|
1072
|
+
|
|
1073
|
+
def clicked(self, click: Any) -> None:
|
|
1074
|
+
"""A click on this card's plot, as the page reports it (`grips.js`,
|
|
1075
|
+
D149): Ctrl+click adds a row to the open criterion, and a plain one
|
|
1076
|
+
sets its armed field. Nothing with no editor open here. A FOLDED
|
|
1077
|
+
row's editor is fields only (D152) and takes no click: a Ctrl+click
|
|
1078
|
+
that would add a row to any one of its members says, under the
|
|
1079
|
+
plot, where that gesture is."""
|
|
1080
|
+
if self.plot is None or self.editor is None or not isinstance(click, dict):
|
|
1081
|
+
return
|
|
1082
|
+
if not is_fold(self.open_path or ""):
|
|
1083
|
+
self.placing.clicked(click)
|
|
1084
|
+
return
|
|
1085
|
+
noun = self._row_noun(leaf_of(self.open_path or ""))
|
|
1086
|
+
if noun and (click.get("ctrl") or click.get("meta")) and self.refusal:
|
|
1087
|
+
self.refusal.set_text(FOLD_ADDS.format(noun))
|
|
1088
|
+
self.refusal.set_visibility(True)
|
|
1089
|
+
|
|
1090
|
+
def fold_unfolded(self) -> None:
|
|
1091
|
+
"""The folded row whose editor is open here was unfolded
|
|
1092
|
+
(`Folding.toggle`): the line that said to unfold it (`clicked`)
|
|
1093
|
+
goes — under a fold's editor the line says nothing else, and it
|
|
1094
|
+
stayed for as long as that editor did."""
|
|
1095
|
+
if self.refusal is not None and not self.refusal.is_deleted:
|
|
1096
|
+
self.refusal.set_visibility(False)
|
|
1097
|
+
|
|
1098
|
+
def moved(self, move: Any) -> None:
|
|
1099
|
+
"""A handle of this card's plot is HELD (`grips.js`, D149): its
|
|
1100
|
+
would-be position, for the editor's fields to follow."""
|
|
1101
|
+
if self.plot is not None:
|
|
1102
|
+
self.hook.move(move)
|
|
1103
|
+
|
|
1104
|
+
def _said(self, text: str | None) -> None:
|
|
1105
|
+
"""What the open editor's error line just said (`InlineHost`'s
|
|
1106
|
+
``on_said``): a refused DRAG's reason is shown under the plot too,
|
|
1107
|
+
in the editor's own words; an act that went through — a drag, a
|
|
1108
|
+
field — takes it away. A refused field edit is said where the
|
|
1109
|
+
typing is, and leaves this line as it was."""
|
|
1110
|
+
if self.refusal is None or self.refusal.is_deleted:
|
|
1111
|
+
return
|
|
1112
|
+
if text is None:
|
|
1113
|
+
self.refusal.set_visibility(False)
|
|
1114
|
+
elif self.hook.dispatching:
|
|
1115
|
+
self.refusal.set_text(text)
|
|
1116
|
+
self.refusal.set_visibility(True)
|
|
1117
|
+
|
|
1118
|
+
def close_editor(self) -> None:
|
|
1119
|
+
"""The editor goes, and its subscriptions with it: a panel nobody
|
|
1120
|
+
can see must not go on staging edits from drags. Only the editor's
|
|
1121
|
+
own column: the row's slot also holds its host's error labels, and
|
|
1122
|
+
a close that cleared the slot deleted them — a refused Wrap on that
|
|
1123
|
+
row then staged nothing and showed nothing (the review, in
|
|
1124
|
+
Chromium). A refused drag's line under the plot goes with it."""
|
|
1125
|
+
self._said(None)
|
|
1126
|
+
self.placing.forget()
|
|
1127
|
+
if self.editor is not None:
|
|
1128
|
+
self.editor.clear()
|
|
1129
|
+
if self.editor_box is not None and not self.editor_box.is_deleted:
|
|
1130
|
+
self.editor_box.delete()
|
|
1131
|
+
row = self.rows.get(self.open_path or "")
|
|
1132
|
+
if row is not None:
|
|
1133
|
+
row.row.classes(remove="dstf-crit-open")
|
|
1134
|
+
self.editor, self.editor_box, self.hint = None, None, None
|
|
1135
|
+
self.open_path, self._edited = None, None
|
|
1136
|
+
self.folding.released()
|
|
1137
|
+
|
|
1138
|
+
def _applied(self, name: str) -> None:
|
|
1139
|
+
"""What a successful submit from the editor leaves to do. A drag
|
|
1140
|
+
rebuilds the WHOLE editor — no field is being typed during one, and
|
|
1141
|
+
the table has to follow the ring. A panel's own submit rebuilds
|
|
1142
|
+
every OTHER panel and leaves that one alone, so the control being
|
|
1143
|
+
typed in survives."""
|
|
1144
|
+
if self.editor is None:
|
|
1145
|
+
return
|
|
1146
|
+
if self.hook.dispatching:
|
|
1147
|
+
self.editor.build()
|
|
1148
|
+
else:
|
|
1149
|
+
self.editor.build(skip=name)
|
|
1150
|
+
# Recorded AFTER this apply and before its events reach the page,
|
|
1151
|
+
# from a FRESH tree view, so a panel's own edit is not read as one
|
|
1152
|
+
# staged elsewhere — and a first edit of an unmaterialized tree,
|
|
1153
|
+
# which stages the materialization AND the replace, is one edit
|
|
1154
|
+
# here rather than two.
|
|
1155
|
+
view = self._view()
|
|
1156
|
+
self._say_hint()
|
|
1157
|
+
self._edited = self._edited_now(view)
|
|
1158
|
+
|
|
1159
|
+
def dispose(self) -> None:
|
|
1160
|
+
"""Give up this card's handlers before it is dropped: a rebuilt page
|
|
1161
|
+
must not leave a removed row's panel subscribed to the hook."""
|
|
1162
|
+
if self.editor is not None:
|
|
1163
|
+
self.editor.clear()
|
|
1164
|
+
for row in self.rows.values():
|
|
1165
|
+
row.host.clear()
|
|
1166
|
+
if self.variable_host is not None:
|
|
1167
|
+
self.variable_host.clear()
|
|
1168
|
+
self.hook.clear()
|
|
1169
|
+
if self.plot is not None:
|
|
1170
|
+
# Its hover instance listens on the document and the window.
|
|
1171
|
+
self.page.client.run_javascript(f"DstfGrips.destroy({self.plot.id})")
|