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
|
+
"""The criteria tree card (brief §3.2; the 2026-09-20 board, 1A / 1B):
|
|
2
|
+
closed it is one line — the root's verdict, what the root is, the tally,
|
|
3
|
+
and the first failures by name, each a jump to its criterion; open it is a
|
|
4
|
+
row per node with that node's controls, which are whatever capabilities
|
|
5
|
+
attach at the NODE placement. The card knows no capability by name.
|
|
6
|
+
|
|
7
|
+
A refresh rebuilds the rows only when the tree's SHAPE moved — the paths,
|
|
8
|
+
kinds and variables of its nodes. A verdict that flips, a readout, a
|
|
9
|
+
combinator's own count: those repaint into the row that is already there.
|
|
10
|
+
A cluster is elements enough that re-sending every row on every Session
|
|
11
|
+
event was the cost the 3b review measured (40 leaves: ~1150 elements per
|
|
12
|
+
sync with the tree open)."""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from functools import partial
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
from nicegui import ui
|
|
21
|
+
|
|
22
|
+
from ...comparison.tree_spec import reads_a_statistic
|
|
23
|
+
from ...reporting.tree_rows import leaves_of
|
|
24
|
+
from ..capabilities import Placement
|
|
25
|
+
from ..session import Selection
|
|
26
|
+
from ..tree_move import TREE_CHANGED, MoveRefused
|
|
27
|
+
from ..tree_pointer import node_or_none
|
|
28
|
+
from ..tree_text import count_text, leaf_name, node_text, nodes_of
|
|
29
|
+
from .actions import InlineHost, RowTarget
|
|
30
|
+
from .widgets import Pill
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from .test_page import TestPage
|
|
34
|
+
|
|
35
|
+
NAMED = 2 #: failures the closed line names; the rest are counted
|
|
36
|
+
|
|
37
|
+
#: One level's bar and the gap after it (U5, Q1): 6 px + 2 px.
|
|
38
|
+
BAR_STEP = 8
|
|
39
|
+
#: The row's gap between its parts (`gap-3`), the chevron's place and the
|
|
40
|
+
#: drag handle's (`.dstf-chev-slot`, `.dstf-grip`), px.
|
|
41
|
+
_GAP, _CHEVRON, _GRIP = 12, 28, 20
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def content_left(depth: int) -> int:
|
|
45
|
+
"""Where a row's content starts, right of its bars: its chevron's place
|
|
46
|
+
(U5, E4) — where a drag's "before" line starts (U4 C15)."""
|
|
47
|
+
return BAR_STEP * depth + _GAP
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def pill_left(depth: int) -> int:
|
|
51
|
+
"""Where a row's pill starts: past its chevron's place and its handle."""
|
|
52
|
+
return content_left(depth) + _CHEVRON + _GAP + _GRIP + _GAP
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
#: Appended to a move's announcement when the live region already holds
|
|
56
|
+
#: that text: a live region is read out when its text CHANGES, and a
|
|
57
|
+
#: second "Moved." would not change it (`TreeCard.say`).
|
|
58
|
+
_AGAIN = "\u200b"
|
|
59
|
+
|
|
60
|
+
#: The drag (U4), drawn in the browser — only the drag's start and the drop
|
|
61
|
+
#: reach the server. A row's top half is BEFORE it, its bottom half ONTO
|
|
62
|
+
#: it (the owner's ruling, D1 as amended); the root has only onto. The map
|
|
63
|
+
#: of each row's zones (`Panel.movable`'s ``zones``) arrives once per drag
|
|
64
|
+
#: (`TreeCard._drag_start`) and is emptied at every start, so a slow reply
|
|
65
|
+
#: never draws one drag with the last one's map; until it has arrived a
|
|
66
|
+
#: zone draws as allowed, and a drop there is the act's to refuse. A
|
|
67
|
+
#: refused zone outlines its row and says why in the card's header line,
|
|
68
|
+
#: in place of the line's text — said in the row, it reflowed the row's
|
|
69
|
+
#: icons, and the browser shows no title while it drags.
|
|
70
|
+
#:
|
|
71
|
+
#: The drag carries its own MIME type, never text: a drag that does not
|
|
72
|
+
#: (a file, a selection) draws nothing and drops nothing here, and ours,
|
|
73
|
+
#: released over a text field, types no path into it. It carries the
|
|
74
|
+
#: rows' revision from its start too (`TreeCard.revision`): a drop on rows
|
|
75
|
+
#: rebuilt since is refused (`TreeCard.dropped`).
|
|
76
|
+
_DRAG_COMMON = """const D = window.dstfMove || (window.dstfMove = {src: null, map: {}});
|
|
77
|
+
const MIME = __MIME__;
|
|
78
|
+
const ours = (e) => !!e.dataTransfer && [...e.dataTransfer.types].includes(MIME);
|
|
79
|
+
const zoneOf = (row, e) => {
|
|
80
|
+
if (row.dataset.root === '1') return 'onto';
|
|
81
|
+
const r = row.getBoundingClientRect();
|
|
82
|
+
return (e.clientY - r.top) / r.height < 0.5 ? 'before' : 'onto';
|
|
83
|
+
};
|
|
84
|
+
const say = (rows, why) => {
|
|
85
|
+
const line = rows.closest('.dstf-tree')?.querySelector('.dstf-tree-line');
|
|
86
|
+
if (!line) return;
|
|
87
|
+
if (why) { line.dataset.why = why; line.classList.add('dstf-line-why'); }
|
|
88
|
+
else { delete line.dataset.why; line.classList.remove('dstf-line-why'); }
|
|
89
|
+
};
|
|
90
|
+
//: The drag resting on a folded group (U5, Q10): 700 ms over its row
|
|
91
|
+
//: unfolds it, as its chevron's click would; leaving the row, the drop
|
|
92
|
+
//: and the drag's end cancel it (E6).
|
|
93
|
+
const unhover = () => { if (D.hover) { clearTimeout(D.hover.t); D.hover = null; } };
|
|
94
|
+
const hover = (row) => {
|
|
95
|
+
const at = row && row.dataset.folded === '1' ? row : null;
|
|
96
|
+
if (D.hover && D.hover.row === at) return;
|
|
97
|
+
unhover();
|
|
98
|
+
if (!at) return;
|
|
99
|
+
D.hover = {row: at, t: setTimeout(() => {
|
|
100
|
+
D.hover = null;
|
|
101
|
+
if (at.dataset.folded === '1') at.querySelector(':scope > .dstf-chev-slot > .dstf-chev')?.click();
|
|
102
|
+
}, __REST__)};
|
|
103
|
+
};
|
|
104
|
+
const clear = (rows) => {
|
|
105
|
+
rows.querySelectorAll('.dstf-drop').forEach(el => el.classList.remove(
|
|
106
|
+
'dstf-drop', 'dstf-drop-before', 'dstf-drop-onto', 'dstf-drop-no'));
|
|
107
|
+
say(rows, null);
|
|
108
|
+
};"""
|
|
109
|
+
_DRAG_START = """(e) => {
|
|
110
|
+
__COMMON__
|
|
111
|
+
const grip = e.target.closest && e.target.closest('.dstf-grip');
|
|
112
|
+
const row = grip && grip.closest('[data-tree-path]');
|
|
113
|
+
if (!row) return;
|
|
114
|
+
D.src = row.dataset.treePath;
|
|
115
|
+
D.rev = e.currentTarget.dataset.rev;
|
|
116
|
+
D.map = {};
|
|
117
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
118
|
+
e.dataTransfer.setData(MIME, D.src);
|
|
119
|
+
e.dataTransfer.setDragImage(row, 12, row.offsetHeight / 2);
|
|
120
|
+
emit(D.src);
|
|
121
|
+
}"""
|
|
122
|
+
_DRAG_OVER = """(e) => {
|
|
123
|
+
__COMMON__
|
|
124
|
+
if (!D.src || !ours(e)) return;
|
|
125
|
+
const row = e.target.closest && e.target.closest('[data-tree-path]');
|
|
126
|
+
hover(row);
|
|
127
|
+
// Over no row (a form row, a slot, a move's line): no place, nothing drawn.
|
|
128
|
+
if (!row) { clear(e.currentTarget); return; }
|
|
129
|
+
const zone = zoneOf(row, e);
|
|
130
|
+
const why = (D.map[row.dataset.treePath] || {})[zone];
|
|
131
|
+
clear(e.currentTarget);
|
|
132
|
+
row.classList.add('dstf-drop');
|
|
133
|
+
if (why === '') return; // where it already is: nothing drawn, no drop
|
|
134
|
+
if (why) {
|
|
135
|
+
row.classList.add('dstf-drop-no');
|
|
136
|
+
say(e.currentTarget, why);
|
|
137
|
+
e.dataTransfer.dropEffect = 'none';
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
e.dataTransfer.dropEffect = 'move';
|
|
142
|
+
row.classList.add('dstf-drop-' + zone);
|
|
143
|
+
}"""
|
|
144
|
+
_DRAG_LEAVE = """(e) => {
|
|
145
|
+
__COMMON__
|
|
146
|
+
if (!e.currentTarget.contains(e.relatedTarget)) { unhover(); clear(e.currentTarget); }
|
|
147
|
+
}"""
|
|
148
|
+
_DROP = """(e) => {
|
|
149
|
+
__COMMON__
|
|
150
|
+
if (!ours(e)) return;
|
|
151
|
+
const row = e.target.closest && e.target.closest('[data-tree-path]');
|
|
152
|
+
const src = D.src;
|
|
153
|
+
unhover();
|
|
154
|
+
clear(e.currentTarget);
|
|
155
|
+
D.src = null;
|
|
156
|
+
if (!row || !src) return;
|
|
157
|
+
e.preventDefault();
|
|
158
|
+
const zone = zoneOf(row, e);
|
|
159
|
+
if ((D.map[row.dataset.treePath] || {})[zone] != null) return;
|
|
160
|
+
emit({src: src, target: row.dataset.treePath, zone: zone, rev: D.rev});
|
|
161
|
+
}"""
|
|
162
|
+
_DRAG_END = """(e) => {
|
|
163
|
+
__COMMON__
|
|
164
|
+
unhover();
|
|
165
|
+
clear(e.currentTarget);
|
|
166
|
+
D.src = null;
|
|
167
|
+
}"""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
#: The hovered branch (U5, Y2), drawn in the browser: hovering a group's
|
|
171
|
+
#: row deepens that group's level bar on its row and every row under it; a
|
|
172
|
+
#: criterion's row does the same for its parent group; the root, and a
|
|
173
|
+
#: criterion right under it, deepen nothing. Marked on the BAR, never the
|
|
174
|
+
#: row: a server repaint of a row's classes (`row.classes`) writes the
|
|
175
|
+
#: row's whole class attribute and takes a client-set row class with it —
|
|
176
|
+
#: the U5 spike measured it — and the server never re-classes a bar.
|
|
177
|
+
_BRANCH = """(e) => {
|
|
178
|
+
const rows = e.currentTarget;
|
|
179
|
+
const row = e.target.closest && e.target.closest('[data-tree-path]');
|
|
180
|
+
let group = null;
|
|
181
|
+
if (row && row.dataset.root !== '1') {
|
|
182
|
+
const p = row.dataset.group === '1' ? row.dataset.treePath
|
|
183
|
+
: row.dataset.treePath.slice(0, row.dataset.treePath.lastIndexOf('/children/'));
|
|
184
|
+
group = rows.querySelector(`[data-tree-path="${p}"]`);
|
|
185
|
+
if (group && group.dataset.root === '1') group = null;
|
|
186
|
+
}
|
|
187
|
+
// The rows' revision in the key: rows rebuilt under a resting pointer
|
|
188
|
+
// are new rows, and are marked again.
|
|
189
|
+
const at = group ? group.dataset.treePath : '';
|
|
190
|
+
const key = `${rows.dataset.rev}|${at}`;
|
|
191
|
+
if (rows.dataset.branch === key) return;
|
|
192
|
+
rows.querySelectorAll('.dstf-lvl-on').forEach(b => b.classList.remove('dstf-lvl-on'));
|
|
193
|
+
rows.dataset.branch = key;
|
|
194
|
+
if (!group) return;
|
|
195
|
+
const k = Number(group.dataset.depth);
|
|
196
|
+
const mark = (r) => {
|
|
197
|
+
const b = r.querySelector(':scope > .dstf-bars').children[k - 1];
|
|
198
|
+
if (b) b.classList.add('dstf-lvl-on');
|
|
199
|
+
};
|
|
200
|
+
mark(group);
|
|
201
|
+
rows.querySelectorAll(`[data-tree-path^="${at}/"]`).forEach(mark);
|
|
202
|
+
}"""
|
|
203
|
+
_BRANCH_OFF = """(e) => {
|
|
204
|
+
const rows = e.currentTarget;
|
|
205
|
+
rows.querySelectorAll('.dstf-lvl-on').forEach(b => b.classList.remove('dstf-lvl-on'));
|
|
206
|
+
rows.dataset.branch = '';
|
|
207
|
+
}"""
|
|
208
|
+
|
|
209
|
+
#: A group folded or unfolded in the page (U5, Q3): its row's
|
|
210
|
+
#: ``data-folded``, and every row and form slot under a folded group
|
|
211
|
+
#: ``hidden`` — attributes, never classes (E2): a server repaint of a
|
|
212
|
+
#: row's classes writes its whole class attribute (the U5 spike) and leaves
|
|
213
|
+
#: its other attributes alone. No rows are rebuilt, so a drag's revision
|
|
214
|
+
#: stays (U4 W5). The server is told, records it in the tab and sets the
|
|
215
|
+
#: same attributes, so a later rebuild draws them too.
|
|
216
|
+
_FOLD_CLICK = """(e) => {
|
|
217
|
+
e.stopPropagation(); // a chevron's click is never its row's
|
|
218
|
+
const row = e.target.closest('[data-tree-path]');
|
|
219
|
+
if (!row) return;
|
|
220
|
+
const rows = row.parentElement;
|
|
221
|
+
const on = row.dataset.folded !== '1';
|
|
222
|
+
if (on) row.dataset.folded = '1'; else delete row.dataset.folded;
|
|
223
|
+
const chev = e.currentTarget;
|
|
224
|
+
chev.setAttribute('aria-expanded', on ? 'false' : 'true');
|
|
225
|
+
chev.setAttribute('aria-label', (on ? 'Unfold ' : 'Fold ') + chev.dataset.name);
|
|
226
|
+
const shut = [...rows.querySelectorAll(':scope > [data-folded="1"]')].map(
|
|
227
|
+
r => r.dataset.treePath + '/');
|
|
228
|
+
rows.querySelectorAll(':scope > [data-tree-path], :scope > [data-slot-of]').forEach(el => {
|
|
229
|
+
const p = el.dataset.treePath || el.dataset.slotOf;
|
|
230
|
+
el.hidden = shut.some(s => p.startsWith(s));
|
|
231
|
+
});
|
|
232
|
+
emit(on);
|
|
233
|
+
}"""
|
|
234
|
+
|
|
235
|
+
#: A click on a row's own control is the control's, never the row's (U5):
|
|
236
|
+
#: the row's click opens its editor or its fields.
|
|
237
|
+
_STOP = "(e) => e.stopPropagation()"
|
|
238
|
+
|
|
239
|
+
#: Enter or Space on a row that is a button (U5, F5) is its click — on the
|
|
240
|
+
#: row itself, never bubbled up from a control inside it; Space's default
|
|
241
|
+
#: would scroll the page.
|
|
242
|
+
_ROW_KEYS = (
|
|
243
|
+
"(e) => { if (e.target === e.currentTarget && "
|
|
244
|
+
"(e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); emit(); } }"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
#: The drag's own MIME type (`_DRAG_COMMON`).
|
|
248
|
+
MIME = "application/x-dstf-node"
|
|
249
|
+
|
|
250
|
+
#: How long a drag rests on a folded group before it unfolds (Q10), ms.
|
|
251
|
+
REST_MS = 700
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _drag_js(handler: str) -> str:
|
|
255
|
+
return (
|
|
256
|
+
handler.replace("__COMMON__", _DRAG_COMMON)
|
|
257
|
+
.replace("__MIME__", json.dumps(MIME))
|
|
258
|
+
.replace("__REST__", str(REST_MS))
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def shape_of(view: dict | None) -> tuple[tuple[str, str, str, str], ...]:
|
|
263
|
+
"""What a row is drawn from and cannot be repainted through. The paths
|
|
264
|
+
are what decides the rows — one per node, painted by its path — and the
|
|
265
|
+
kind and the leaf's variable ride along harmlessly: a row is built for
|
|
266
|
+
that node's selection and controls, so a row whose kind or variable
|
|
267
|
+
moved is one worth rebuilding anyway. The combinator is in it because
|
|
268
|
+
the controls read it: `+` is grey under a warn or a weighted, and a
|
|
269
|
+
node edited from weighted to and kept a grey `+` with the weighted
|
|
270
|
+
reason (the Task 6 review). The metric is NOT in it: a mode switch
|
|
271
|
+
repaints the row's name in place."""
|
|
272
|
+
return tuple(
|
|
273
|
+
(
|
|
274
|
+
str(node.get("path")),
|
|
275
|
+
str(node.get("kind")),
|
|
276
|
+
str(node.get("variable")),
|
|
277
|
+
str(node.get("combinator")),
|
|
278
|
+
)
|
|
279
|
+
for node in nodes_of(view)
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def leaf_status(leaf: dict) -> str:
|
|
284
|
+
"""One of "pass", "fail", "warn", "queued": an advisory failure is the
|
|
285
|
+
warn one — a soft check is not a regression."""
|
|
286
|
+
passed = leaf.get("passed")
|
|
287
|
+
if passed is True:
|
|
288
|
+
return "pass"
|
|
289
|
+
if passed is False:
|
|
290
|
+
return "warn" if leaf.get("advisory") else "fail"
|
|
291
|
+
return "queued"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def root_status(node: dict) -> str:
|
|
295
|
+
"""A combinator's own verdict, the evaluator's — never the tally of its
|
|
296
|
+
leaves: an `or` passes on one child, a `warn` on none."""
|
|
297
|
+
passed = node.get("passed")
|
|
298
|
+
return "pass" if passed is True else "fail" if passed is False else "queued"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def node_status(node: dict) -> str:
|
|
302
|
+
return leaf_status(node) if node.get("kind") == "leaf" else root_status(node)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def leaf_readout(leaf: dict) -> str:
|
|
306
|
+
"""The CLI's own words for the score: the criterion the leaf was held
|
|
307
|
+
to — which ALREADY carries the score ("NRMSE 1.531e-01 vs tolerance
|
|
308
|
+
1.000e-02 → FAIL"), so the display beside it said the number twice (the
|
|
309
|
+
first screenshot) — else the display alone. A sentinel's criterion says
|
|
310
|
+
the reason itself ("scalar leaf not scored — …"), so the error is not a
|
|
311
|
+
branch of its own. The trailing verdict is not stripped: `tube` and
|
|
312
|
+
`range` do not end with it ("… → FAIL (requires 0)"), so a strip would
|
|
313
|
+
read the string wrong for half the modes."""
|
|
314
|
+
return str(leaf.get("criterion") or leaf.get("score_display") or "")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def root_text(node: dict) -> str:
|
|
318
|
+
"""What the root IS, in the closed line. A staged root leaf is the
|
|
319
|
+
test's one criterion — its metric and variable are already the line's
|
|
320
|
+
only jump — and a combinator names itself and counts the LEAVES under
|
|
321
|
+
it, however deep: `and[k-of-n[l, l], l]` is 3 criteria, not 2."""
|
|
322
|
+
return "1 criterion" if node.get("kind") == "leaf" else node_text(node)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def summary_text(view: dict) -> tuple[str, list[dict], int]:
|
|
326
|
+
"""The closed line's three parts: the tally ("0 pass · 2 fail", plus an
|
|
327
|
+
advisory and an unscored count when either is non-zero), the failing
|
|
328
|
+
leaves the line NAMES — hard failures before advisory ones, at most
|
|
329
|
+
`NAMED` of them — and how many failures are left over for "+k more".
|
|
330
|
+
Board 1A draws each failure's name once, and it is the thing that
|
|
331
|
+
jumps, so the tally does not repeat the names."""
|
|
332
|
+
if view.get("unscored"):
|
|
333
|
+
# Nothing scored because of a run (D151): the word, and no tally.
|
|
334
|
+
return str(view["unscored"]), [], 0
|
|
335
|
+
leaves = leaves_of(view)
|
|
336
|
+
statuses = [leaf_status(leaf) for leaf in leaves]
|
|
337
|
+
hard = [leaf for leaf, st in zip(leaves, statuses) if st == "fail"]
|
|
338
|
+
advisory = [leaf for leaf, st in zip(leaves, statuses) if st == "warn"]
|
|
339
|
+
parts = [f"{statuses.count('pass')} pass"]
|
|
340
|
+
if hard:
|
|
341
|
+
parts.append(f"{len(hard)} fail")
|
|
342
|
+
if advisory:
|
|
343
|
+
parts.append(f"{len(advisory)} advisory")
|
|
344
|
+
if statuses.count("queued"):
|
|
345
|
+
parts.append(f"{statuses.count('queued')} unscored")
|
|
346
|
+
failed = hard + advisory
|
|
347
|
+
return " · ".join(parts), failed[:NAMED], max(len(failed) - NAMED, 0)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def fold_text(node: dict) -> str:
|
|
351
|
+
"""What a folded group's row adds (Q4, E6): its criteria, however deep,
|
|
352
|
+
counted by verdict — "· 3 hidden · 2 pass · 1 fail", with an advisory
|
|
353
|
+
and an unscored count when either is non-zero, as the closed line's
|
|
354
|
+
tally. A folded group inside it is inside it: each criterion is counted
|
|
355
|
+
once."""
|
|
356
|
+
statuses = [leaf_status(leaf) for leaf in leaves_of(node)]
|
|
357
|
+
parts = [f"{len(statuses)} hidden", f"{statuses.count('pass')} pass"]
|
|
358
|
+
for status, word in (
|
|
359
|
+
("fail", "fail"),
|
|
360
|
+
("warn", "advisory"),
|
|
361
|
+
("queued", "unscored"),
|
|
362
|
+
):
|
|
363
|
+
if statuses.count(status):
|
|
364
|
+
parts.append(f"{statuses.count(status)} {word}")
|
|
365
|
+
return "· " + " · ".join(parts)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def show_tip(leaf: dict) -> str:
|
|
369
|
+
"""The ⤓'s name (U5, Q7): where it shows the criterion — a statistics
|
|
370
|
+
criterion is drawn on the Statistics card, any other on its plot."""
|
|
371
|
+
statistic = reads_a_statistic(str(leaf.get("metric")), leaf.get("params") or {})
|
|
372
|
+
return "Show on the Statistics card" if statistic else "Show on its plot"
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _ancestors(path: str) -> list[str]:
|
|
376
|
+
"""Every proper ancestor's path of ``path``, the root's included."""
|
|
377
|
+
out = []
|
|
378
|
+
while "/children/" in path:
|
|
379
|
+
path = path.rsplit("/children/", 1)[0]
|
|
380
|
+
out.append(path)
|
|
381
|
+
return out
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _set_prop(element: ui.element, key: str, value: str | bool | None) -> bool:
|
|
385
|
+
"""``element``'s prop ``key`` set to ``value`` (None: removed), and
|
|
386
|
+
whether it moved; the caller sends the element once."""
|
|
387
|
+
if element.props.get(key) == value:
|
|
388
|
+
return False
|
|
389
|
+
if value is None:
|
|
390
|
+
del element.props[key]
|
|
391
|
+
else:
|
|
392
|
+
element.props[key] = value
|
|
393
|
+
return True
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class _Row:
|
|
397
|
+
"""One node's row, and what a repaint writes into it."""
|
|
398
|
+
|
|
399
|
+
def __init__(
|
|
400
|
+
self,
|
|
401
|
+
pill: Pill,
|
|
402
|
+
text: ui.label,
|
|
403
|
+
readout: ui.label | None,
|
|
404
|
+
lead: ui.element | None = None,
|
|
405
|
+
summary: ui.label | None = None,
|
|
406
|
+
chevron: ui.element | None = None,
|
|
407
|
+
show: tuple[ui.element, ui.tooltip] | None = None,
|
|
408
|
+
) -> None:
|
|
409
|
+
self.pill, self.text, self.readout, self.lead = pill, text, readout, lead
|
|
410
|
+
#: A group's fold summary (Q4), shown by CSS while its row is
|
|
411
|
+
#: folded: painted every time, so a fold needs no round trip.
|
|
412
|
+
self.summary = summary
|
|
413
|
+
#: A group's chevron (Q3): named by the group's text.
|
|
414
|
+
self.chevron = chevron
|
|
415
|
+
#: A criterion's ⤓ and its tooltip (Q7): named by where it is
|
|
416
|
+
#: drawn, which a mode switch moves without rebuilding the row.
|
|
417
|
+
self.show = show
|
|
418
|
+
|
|
419
|
+
def paint(self, node: dict) -> None:
|
|
420
|
+
if self.summary is not None:
|
|
421
|
+
self.summary.set_text(fold_text(node))
|
|
422
|
+
if self.chevron is not None:
|
|
423
|
+
name = node_text(node)
|
|
424
|
+
if self.chevron.props.get("data-name") != name:
|
|
425
|
+
self.chevron.props["data-name"] = name
|
|
426
|
+
verb = (
|
|
427
|
+
"Fold"
|
|
428
|
+
if self.chevron.props.get("aria-expanded") == "true"
|
|
429
|
+
else "Unfold"
|
|
430
|
+
)
|
|
431
|
+
self.chevron.props["aria-label"] = f"{verb} {name}"
|
|
432
|
+
self.chevron.update()
|
|
433
|
+
self.pill.set(node_status(node))
|
|
434
|
+
# A lead holding the kind (a combinator's dropdown) leaves the text
|
|
435
|
+
# the count; an empty one — a host that drew only its why-line —
|
|
436
|
+
# leaves the kind in the text, so it never disappears.
|
|
437
|
+
led = self.lead is not None and bool(self.lead.default_slot.children)
|
|
438
|
+
self.text.set_text(count_text(node) if led else node_text(node))
|
|
439
|
+
if self.show is not None:
|
|
440
|
+
button, tip = self.show
|
|
441
|
+
said = show_tip(node)
|
|
442
|
+
if _set_prop(button, "aria-label", said):
|
|
443
|
+
button.update()
|
|
444
|
+
tip.set_text(said)
|
|
445
|
+
if self.readout is not None:
|
|
446
|
+
# Cut to one line with an ellipsis: its title holds the whole.
|
|
447
|
+
said = leaf_readout(node)
|
|
448
|
+
self.readout.set_text(said)
|
|
449
|
+
if _set_prop(self.readout, "title", said):
|
|
450
|
+
self.readout.update()
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
class TreeCard:
|
|
454
|
+
def __init__(self, page: TestPage) -> None:
|
|
455
|
+
self.page = page
|
|
456
|
+
self.is_open = False
|
|
457
|
+
self.named: list[dict] = []
|
|
458
|
+
self.root_pill: Pill | None = None
|
|
459
|
+
self.hosts: list[InlineHost] = []
|
|
460
|
+
self.painters: dict[str, _Row] = {}
|
|
461
|
+
#: Each node's row by its path: what a reveal brings into view.
|
|
462
|
+
self.rows_by_path: dict[str, ui.row] = {}
|
|
463
|
+
#: Each node's row target by its path: where a drop lands and a
|
|
464
|
+
#: drag's zones come from (`Panel.movable`).
|
|
465
|
+
self.targets: dict[str, RowTarget] = {}
|
|
466
|
+
#: A move's line, under the moved row (`say`).
|
|
467
|
+
self.said: ui.label | None = None
|
|
468
|
+
#: The rows' revision, one up at each rebuild: what a drag carries
|
|
469
|
+
#: from its start and its drop is checked against (`dropped`).
|
|
470
|
+
self.revision = 0
|
|
471
|
+
self._shape: tuple[tuple[str, str, str, str], ...] = ()
|
|
472
|
+
#: Each group's chevron by its path (U5, Q3): the root has none.
|
|
473
|
+
self.chevrons: dict[str, ui.element] = {}
|
|
474
|
+
#: The criteria's rows by path: a click on each opens its editor.
|
|
475
|
+
self.leaves: set[str] = set()
|
|
476
|
+
#: The one criterion editor opened under a tree row (U5, Y4): its
|
|
477
|
+
#: leaf, its host and box, the leaf as authored when its fields
|
|
478
|
+
#: were built, and its "Edit on its plot" link's place and whether
|
|
479
|
+
#: it is drawn.
|
|
480
|
+
self.editor_path: str | None = None
|
|
481
|
+
self.editor: InlineHost | None = None
|
|
482
|
+
self.editor_box: ui.column | None = None
|
|
483
|
+
self.link_slot: ui.element | None = None
|
|
484
|
+
self._edited: str | None = None
|
|
485
|
+
self._link = False
|
|
486
|
+
with ui.card().classes("dstf-card dstf-tree w-full gap-1").mark("tree"):
|
|
487
|
+
self.line = ui.row().classes("dstf-tree-line items-center gap-3 w-full")
|
|
488
|
+
self.rows = ui.column().classes("w-full gap-0").mark("tree:rows")
|
|
489
|
+
#: What a screen reader is told of a move (`say`): ONE region,
|
|
490
|
+
#: there from the start and visually hidden — a live region is
|
|
491
|
+
#: read out when its text changes, and one that arrives holding
|
|
492
|
+
#: its text already is not. The line under the row is not it:
|
|
493
|
+
#: made for each move, it needed a timer to arrive empty first.
|
|
494
|
+
self.live = (
|
|
495
|
+
ui.label("")
|
|
496
|
+
.classes("dstf-live")
|
|
497
|
+
.props('role="status" aria-live="polite"')
|
|
498
|
+
.mark("moved-live")
|
|
499
|
+
)
|
|
500
|
+
rows = self.rows
|
|
501
|
+
rows.on("dragstart", self._drag_start, js_handler=_drag_js(_DRAG_START))
|
|
502
|
+
rows.on("dragover", js_handler=_drag_js(_DRAG_OVER))
|
|
503
|
+
rows.on("dragleave", js_handler=_drag_js(_DRAG_LEAVE))
|
|
504
|
+
rows.on("drop", self._drop, js_handler=_drag_js(_DROP))
|
|
505
|
+
rows.on("dragend", js_handler=_drag_js(_DRAG_END))
|
|
506
|
+
rows.on("mouseover", js_handler=_BRANCH)
|
|
507
|
+
rows.on("mouseleave", js_handler=_BRANCH_OFF)
|
|
508
|
+
# Not refreshed here: the page builds this card before it knows
|
|
509
|
+
# whether the test is one of the open project's, and `_view` raises
|
|
510
|
+
# for a model the project has not got. The page's own `sync` fills
|
|
511
|
+
# it, or `forget` leaves it saying nothing.
|
|
512
|
+
self.rows.set_visibility(False)
|
|
513
|
+
|
|
514
|
+
def _view(self) -> dict | None:
|
|
515
|
+
return self.page.shell.session.tree_view(self.page.model_id)
|
|
516
|
+
|
|
517
|
+
# -- the drag ------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
def zones_for(self, src: str) -> dict[str, dict[str, str | None]]:
|
|
520
|
+
"""What a drag from ``src``'s row draws over every row: its panel's
|
|
521
|
+
answer, read NOW — the tree may have moved since the rows were
|
|
522
|
+
built — and nothing where no panel made that row draggable."""
|
|
523
|
+
target = self.targets.get(src)
|
|
524
|
+
if target is None or target.zones is None:
|
|
525
|
+
return {}
|
|
526
|
+
return target.zones()
|
|
527
|
+
|
|
528
|
+
def _drag_start(self, e: Any) -> None:
|
|
529
|
+
src = str(e.args)
|
|
530
|
+
zones = self.zones_for(src)
|
|
531
|
+
# Only while that drag is the browser's: a reply arriving after
|
|
532
|
+
# another drag started would draw it with this one's map.
|
|
533
|
+
self.page.client.run_javascript(
|
|
534
|
+
f"if (window.dstfMove && window.dstfMove.src === {json.dumps(src)})"
|
|
535
|
+
f" window.dstfMove.map = {json.dumps(zones)};"
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
def _drop(self, e: Any) -> None:
|
|
539
|
+
self.dropped(e.args)
|
|
540
|
+
|
|
541
|
+
def dropped(self, args: dict) -> None:
|
|
542
|
+
"""A drop the browser allowed: the row it landed on hands it to the
|
|
543
|
+
panel that registered for it there, if one did. A drag that started
|
|
544
|
+
on rows rebuilt since is refused under that row: its paths name the
|
|
545
|
+
nodes of a tree that is no longer there."""
|
|
546
|
+
target = self.targets.get(str(args.get("target")))
|
|
547
|
+
if target is None or target.on_drop is None:
|
|
548
|
+
return
|
|
549
|
+
if str(args.get("rev")) != str(self.revision):
|
|
550
|
+
target.refuse(MoveRefused(TREE_CHANGED))
|
|
551
|
+
return
|
|
552
|
+
target.on_drop(str(args.get("src")), str(args.get("zone")))
|
|
553
|
+
|
|
554
|
+
def say(self, path: str, text: str) -> None:
|
|
555
|
+
"""A move's line under ``path``'s row, the row brought into view and
|
|
556
|
+
flashed (V4), and the same text told to the card's live region. One
|
|
557
|
+
at a time; `unsay` takes the line away."""
|
|
558
|
+
self.unsay()
|
|
559
|
+
target, row = self.targets.get(path), self.rows_by_path.get(path)
|
|
560
|
+
if target is None or target.slot is None or row is None:
|
|
561
|
+
return
|
|
562
|
+
self.open_path(path) # R2: a tree event shows its row
|
|
563
|
+
depth = path.count("/children/")
|
|
564
|
+
with target.slot:
|
|
565
|
+
# Under the row's pill: its bars, the chevron's place and the
|
|
566
|
+
# handle's (E4).
|
|
567
|
+
self.said = (
|
|
568
|
+
ui.label(text)
|
|
569
|
+
.classes("dstf-moved text-xs")
|
|
570
|
+
.style(f"padding-left: {pill_left(depth)}px")
|
|
571
|
+
.mark("moved-note")
|
|
572
|
+
)
|
|
573
|
+
self.live.set_text(text if self.live.text != text else text + _AGAIN)
|
|
574
|
+
# To THIS page's client: `sync` runs from the Shell's timer, whose
|
|
575
|
+
# context is no page's (`TestPage._reveal`). The row, then the line
|
|
576
|
+
# under it: the row alone, scrolled to its nearest edge from above,
|
|
577
|
+
# left the line below the fold.
|
|
578
|
+
self.page.client.run_javascript(
|
|
579
|
+
f'const r = document.getElementById("{row.html_id}");'
|
|
580
|
+
f'const l = document.getElementById("{self.said.html_id}");'
|
|
581
|
+
'if (r) { r.scrollIntoView({block: "nearest"});'
|
|
582
|
+
' r.classList.remove("dstf-flash"); void r.offsetWidth;'
|
|
583
|
+
' r.classList.add("dstf-flash"); }'
|
|
584
|
+
'if (l) l.scrollIntoView({block: "nearest"});'
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
def unsay(self) -> None:
|
|
588
|
+
if self.said is not None and not self.said.is_deleted:
|
|
589
|
+
self.said.delete()
|
|
590
|
+
self.said = None
|
|
591
|
+
|
|
592
|
+
# -- folding (U5) --------------------------------------------------------------
|
|
593
|
+
|
|
594
|
+
@property
|
|
595
|
+
def folded(self) -> set[str]:
|
|
596
|
+
"""This test's folded groups, the tab's (Q8)."""
|
|
597
|
+
return set(self.page.state.tree_folded.get(self.page.model_id, []))
|
|
598
|
+
|
|
599
|
+
def _keep(self, folded: set[str]) -> None:
|
|
600
|
+
store = self.page.state.tree_folded
|
|
601
|
+
if folded:
|
|
602
|
+
store[self.page.model_id] = sorted(folded)
|
|
603
|
+
else:
|
|
604
|
+
store.pop(self.page.model_id, None)
|
|
605
|
+
|
|
606
|
+
def _apply_folds(self) -> None:
|
|
607
|
+
"""The rows as the tab's folded set says — the attributes the page's
|
|
608
|
+
script sets, by a props update of the elements that moved and no
|
|
609
|
+
rebuild (E2), so the two agree and the drag's revision stands. A
|
|
610
|
+
form under a row that is now hidden goes (E6: a form nobody can see
|
|
611
|
+
is no form, as Hide tree)."""
|
|
612
|
+
folded = self.folded
|
|
613
|
+
for path, row in self.rows_by_path.items():
|
|
614
|
+
hide = any(up in folded for up in _ancestors(path))
|
|
615
|
+
target = self.targets.get(path)
|
|
616
|
+
slot = target.slot if target is not None else None
|
|
617
|
+
if slot is not None and _set_prop(slot, "hidden", True if hide else None):
|
|
618
|
+
slot.update()
|
|
619
|
+
moved = _set_prop(row, "hidden", True if hide else None)
|
|
620
|
+
chev = self.chevrons.get(path)
|
|
621
|
+
if chev is not None:
|
|
622
|
+
on = path in folded
|
|
623
|
+
moved |= _set_prop(row, "data-folded", "1" if on else None)
|
|
624
|
+
name = chev.props.get("data-name", "")
|
|
625
|
+
turned = _set_prop(chev, "aria-expanded", "false" if on else "true")
|
|
626
|
+
turned |= _set_prop(
|
|
627
|
+
chev, "aria-label", f"{'Unfold' if on else 'Fold'} {name}"
|
|
628
|
+
)
|
|
629
|
+
if turned:
|
|
630
|
+
chev.update()
|
|
631
|
+
if moved:
|
|
632
|
+
row.update()
|
|
633
|
+
if hide and target is not None:
|
|
634
|
+
self.page.drop_row(target)
|
|
635
|
+
if self.editor_path == path:
|
|
636
|
+
# The editor under it goes the same way (F9).
|
|
637
|
+
self.page.drop_editor()
|
|
638
|
+
|
|
639
|
+
def _fold(self, path: str, e: Any) -> None:
|
|
640
|
+
"""A chevron's click (or a drag resting on its row, Q10): the page
|
|
641
|
+
has folded or unfolded the group already; the tab records it."""
|
|
642
|
+
folded = self.folded
|
|
643
|
+
on = e.args if isinstance(e.args, bool) else path not in folded
|
|
644
|
+
if on:
|
|
645
|
+
folded.add(path)
|
|
646
|
+
else:
|
|
647
|
+
folded.discard(path)
|
|
648
|
+
self._keep(folded)
|
|
649
|
+
self._apply_folds()
|
|
650
|
+
|
|
651
|
+
def fold_all(self, on: bool) -> None:
|
|
652
|
+
"""Collapse all / Expand all (Y7): every group of the test but the
|
|
653
|
+
root, which is the tree (Show / Hide tree folds it)."""
|
|
654
|
+
self._keep(set(self.chevrons) if on else set())
|
|
655
|
+
self._apply_folds()
|
|
656
|
+
|
|
657
|
+
def open_path(self, path: str) -> None:
|
|
658
|
+
"""R2: unfold every folded group ``path`` is inside, and nothing
|
|
659
|
+
else — a group at ``path`` itself stays as it is. Only for what the
|
|
660
|
+
tree itself shows under a row (a move's line, a refusal, a form);
|
|
661
|
+
what is done outside the tree leaves the folds alone."""
|
|
662
|
+
folded = self.folded
|
|
663
|
+
opened = folded - set(_ancestors(path))
|
|
664
|
+
if opened != folded:
|
|
665
|
+
self._keep(opened)
|
|
666
|
+
self._apply_folds()
|
|
667
|
+
|
|
668
|
+
def rekey(self, paths: dict[str, str]) -> None:
|
|
669
|
+
"""An edit went through (R1): every folded group is folded where the
|
|
670
|
+
edit put it (``paths``: each kept node, before -> after); one the
|
|
671
|
+
edit removed is dropped. Applied by the sync that redraws the rows
|
|
672
|
+
— the rows standing now are the old tree's."""
|
|
673
|
+
self._keep({paths[p] for p in self.folded if p in paths})
|
|
674
|
+
|
|
675
|
+
def toggle(self) -> None:
|
|
676
|
+
self.is_open = not self.is_open
|
|
677
|
+
self.refresh(self._view())
|
|
678
|
+
|
|
679
|
+
def forget(self) -> None:
|
|
680
|
+
"""The page's test is gone (another project was opened under it):
|
|
681
|
+
the card says nothing rather than the last project's verdicts, and
|
|
682
|
+
`refresh` is not the way there — its "nothing scored" line asks the
|
|
683
|
+
page WHY, which for a test this project does not have is a sentence
|
|
684
|
+
about a baseline that was never the reason."""
|
|
685
|
+
self.close_editor()
|
|
686
|
+
for host in self.hosts:
|
|
687
|
+
host.clear()
|
|
688
|
+
self.hosts, self.painters, self.named = [], {}, []
|
|
689
|
+
self.rows_by_path, self.targets, self.chevrons = {}, {}, {}
|
|
690
|
+
self.leaves = set()
|
|
691
|
+
self.root_pill = None
|
|
692
|
+
self.is_open = False
|
|
693
|
+
self._shape = ()
|
|
694
|
+
self.rows.clear()
|
|
695
|
+
self.rows.set_visibility(False)
|
|
696
|
+
self.line.clear()
|
|
697
|
+
|
|
698
|
+
def refresh(self, view: dict | None) -> None:
|
|
699
|
+
"""Follow the Session: the line every time (it is five elements and
|
|
700
|
+
the toggle's own text is one of them), the rows in place unless
|
|
701
|
+
their shape moved. Open or closed is the card's and survives this.
|
|
702
|
+
``view`` is the page's ONE `tree_view` read of this sync — the cards
|
|
703
|
+
draw their criteria rows from the same dict."""
|
|
704
|
+
if view is None:
|
|
705
|
+
self.is_open = False
|
|
706
|
+
self._line(view)
|
|
707
|
+
self.rows.set_visibility(self.is_open)
|
|
708
|
+
if not self.is_open:
|
|
709
|
+
# A form nobody can see is no form: hiding the tree takes a
|
|
710
|
+
# form open under one of its rows away (the Task 5 review).
|
|
711
|
+
for host in self.hosts:
|
|
712
|
+
if host.row is not None:
|
|
713
|
+
self.page.drop_row(host.row)
|
|
714
|
+
# And an editor nobody can see is no editor (U5).
|
|
715
|
+
if self.editor is not None:
|
|
716
|
+
self.page.drop_editor()
|
|
717
|
+
if view is None or not self.is_open:
|
|
718
|
+
return
|
|
719
|
+
shape = shape_of(view)
|
|
720
|
+
if shape != self._shape or not self.painters:
|
|
721
|
+
self._shape = shape
|
|
722
|
+
self._build_rows(view)
|
|
723
|
+
return
|
|
724
|
+
for host in self.hosts:
|
|
725
|
+
# The rows stand; what a node's controls MAY do does not (an
|
|
726
|
+
# `available` answer can move without the shape moving).
|
|
727
|
+
# Before the paint: a host rebuilt to its why-line empties its
|
|
728
|
+
# row's lead, and the paint then reads the kind into the text.
|
|
729
|
+
host.follow()
|
|
730
|
+
for node in nodes_of(view):
|
|
731
|
+
self.painters[str(node.get("path"))].paint(node)
|
|
732
|
+
# The folded set may have been re-keyed under rows whose shape did
|
|
733
|
+
# not move (twin groups trading places, the U5 review): it is drawn
|
|
734
|
+
# on the rows standing, not left on the ones it was folded on.
|
|
735
|
+
self._apply_folds()
|
|
736
|
+
# A host rebuilt to its why-line takes its row's click away.
|
|
737
|
+
self.paint_clicks()
|
|
738
|
+
self._follow_editor()
|
|
739
|
+
|
|
740
|
+
def paint_clicks(self) -> None:
|
|
741
|
+
"""Each row whose click opens something — a criterion's (its
|
|
742
|
+
editor), a group's a panel registered one for (its fields) — is a
|
|
743
|
+
button to the keyboard and says whether that is open under it now
|
|
744
|
+
(U5, F5); any other row is neither. Sent only where it moved."""
|
|
745
|
+
form = self.page.form[0] if self.page.form is not None else None
|
|
746
|
+
for path, target in self.targets.items():
|
|
747
|
+
row = target.row
|
|
748
|
+
if row.is_deleted:
|
|
749
|
+
continue
|
|
750
|
+
on = path in self.leaves or target.click is not None
|
|
751
|
+
shown = None
|
|
752
|
+
if on:
|
|
753
|
+
shown = (
|
|
754
|
+
"true" if self.editor_path == path or form is target else "false"
|
|
755
|
+
)
|
|
756
|
+
moved = _set_prop(row, "role", "button" if on else None)
|
|
757
|
+
moved |= _set_prop(row, "tabindex", "0" if on else None)
|
|
758
|
+
moved |= _set_prop(row, "aria-expanded", shown)
|
|
759
|
+
if moved:
|
|
760
|
+
row.update()
|
|
761
|
+
|
|
762
|
+
# -- the criterion editor under a tree row (U5) -----------------------------
|
|
763
|
+
|
|
764
|
+
def open_editor(self, path: str) -> bool:
|
|
765
|
+
"""``path``'s editor under its tree row: Done, the "Edit on its
|
|
766
|
+
plot" link where its plot can do more, then every capability placed
|
|
767
|
+
at TREE_CRITERION (`PlotCard.open_editor`'s pattern with no plot).
|
|
768
|
+
The page keeps the one-editor rule: it closes what else is open
|
|
769
|
+
first. False where no row is drawn at ``path``."""
|
|
770
|
+
target, row = self.targets.get(path), self.rows_by_path.get(path)
|
|
771
|
+
if target is None or row is None or target.slot is None:
|
|
772
|
+
return False
|
|
773
|
+
self.close_editor()
|
|
774
|
+
self.open_path(path) # R2: an editor under a tree row is shown
|
|
775
|
+
model = self.page.model_id
|
|
776
|
+
self.editor_path = path
|
|
777
|
+
row.classes(add="dstf-crit-open")
|
|
778
|
+
with target.slot:
|
|
779
|
+
self.editor_box = ui.column().classes("dstf-editor gap-2")
|
|
780
|
+
with self.editor_box:
|
|
781
|
+
with ui.row().classes("items-center no-wrap gap-3 w-full"):
|
|
782
|
+
ui.label(
|
|
783
|
+
"Edits are staged as you make them; Save writes them."
|
|
784
|
+
).classes("dstf-why")
|
|
785
|
+
ui.space()
|
|
786
|
+
self.link_slot = ui.element("span")
|
|
787
|
+
ui.button("Done", on_click=self.page.close_editor).props(
|
|
788
|
+
"flat dense no-caps"
|
|
789
|
+
).mark(f"tree-editor-done:{path}")
|
|
790
|
+
self.editor = InlineHost(
|
|
791
|
+
self.page.shell,
|
|
792
|
+
Placement.TREE_CRITERION,
|
|
793
|
+
partial(Selection, tests=frozenset({model}), leaf_path=path),
|
|
794
|
+
figure=None,
|
|
795
|
+
marker=f"tree-editor:{path}",
|
|
796
|
+
on_applied=self._applied,
|
|
797
|
+
apply=self.page.apply,
|
|
798
|
+
)
|
|
799
|
+
self.editor.build()
|
|
800
|
+
self._edited = self._edited_now()
|
|
801
|
+
self._link = False
|
|
802
|
+
self._paint_link()
|
|
803
|
+
self.paint_clicks()
|
|
804
|
+
return True
|
|
805
|
+
|
|
806
|
+
def close_editor(self) -> None:
|
|
807
|
+
"""The editor goes, its column only: the row's slot also holds its
|
|
808
|
+
host's error labels and a move's line."""
|
|
809
|
+
if self.editor is not None:
|
|
810
|
+
self.editor.clear()
|
|
811
|
+
if self.editor_box is not None and not self.editor_box.is_deleted:
|
|
812
|
+
self.editor_box.delete()
|
|
813
|
+
row = self.rows_by_path.get(self.editor_path or "")
|
|
814
|
+
if row is not None and not row.is_deleted:
|
|
815
|
+
row.classes(remove="dstf-crit-open")
|
|
816
|
+
was = self.editor_path
|
|
817
|
+
self.editor, self.editor_box, self.link_slot = None, None, None
|
|
818
|
+
self.editor_path, self._edited, self._link = None, None, False
|
|
819
|
+
if was is not None:
|
|
820
|
+
self.paint_clicks()
|
|
821
|
+
|
|
822
|
+
def _applied(self, name: str) -> None:
|
|
823
|
+
"""A panel's own submit rebuilds every OTHER panel, leaving the one
|
|
824
|
+
being typed in; the leaf recorded as it now stands, so its own edit
|
|
825
|
+
is not read as one staged elsewhere."""
|
|
826
|
+
if self.editor is None:
|
|
827
|
+
return
|
|
828
|
+
self.editor.build(skip=name)
|
|
829
|
+
self._edited = self._edited_now()
|
|
830
|
+
|
|
831
|
+
def _edited_now(self) -> str | None:
|
|
832
|
+
"""The open editor's leaf as AUTHORED — staged edits included (the
|
|
833
|
+
Statistics card's rule: a test with nothing compared has no view)."""
|
|
834
|
+
if self.editor_path is None:
|
|
835
|
+
return None
|
|
836
|
+
session = self.page.shell.session
|
|
837
|
+
leaf = node_or_none(session.tree(self.page.model_id), self.editor_path)
|
|
838
|
+
return None if leaf is None else json.dumps(leaf, sort_keys=True, default=str)
|
|
839
|
+
|
|
840
|
+
def _follow_editor(self) -> None:
|
|
841
|
+
if self.editor is None:
|
|
842
|
+
return
|
|
843
|
+
edited = self._edited_now()
|
|
844
|
+
if edited != self._edited:
|
|
845
|
+
# The leaf moved by an edit staged ELSEWHERE (a Discard, its
|
|
846
|
+
# plot's drag): fields showing the undone value stage it back.
|
|
847
|
+
self.editor.build()
|
|
848
|
+
self._edited = edited
|
|
849
|
+
else:
|
|
850
|
+
self.editor.follow()
|
|
851
|
+
self._paint_link()
|
|
852
|
+
|
|
853
|
+
def _paint_link(self) -> None:
|
|
854
|
+
"""Q12 (F2): "Edit on its plot" while the plot that holds the
|
|
855
|
+
criterion — the first, as a jump opens it — offers more than this
|
|
856
|
+
editor does: a capability placed at CRITERION and not here is
|
|
857
|
+
available there (one that drags its handles). Asked of the
|
|
858
|
+
placements, never of a capability by name; redrawn only when its
|
|
859
|
+
answer moves (a mode switched to one with no handles takes it).
|
|
860
|
+
Bound to no plot: the page finds the plot when it is clicked
|
|
861
|
+
(`TestPage.edit_on_plot`) — this runs in a sync BEFORE the cards
|
|
862
|
+
rebuild, and an index read here named the plot the criterion was
|
|
863
|
+
on until a later sync drew the link again (J3)."""
|
|
864
|
+
if self.link_slot is None or self.link_slot.is_deleted:
|
|
865
|
+
return
|
|
866
|
+
path = self.editor_path
|
|
867
|
+
page, shell = self.page, self.page.shell
|
|
868
|
+
index = next((c.index for c in page.cards if path in c.rows), None)
|
|
869
|
+
if index is not None and path is not None:
|
|
870
|
+
selection = Selection(
|
|
871
|
+
tests=frozenset({page.model_id}),
|
|
872
|
+
variable=page.cards[index].rows[path].variable,
|
|
873
|
+
leaf_path=path,
|
|
874
|
+
)
|
|
875
|
+
more = any(
|
|
876
|
+
avail is True and Placement.TREE_CRITERION not in cap.placements
|
|
877
|
+
for cap, avail in shell.placed(
|
|
878
|
+
Placement.CRITERION, shell.view_for(selection)
|
|
879
|
+
)
|
|
880
|
+
)
|
|
881
|
+
if not more:
|
|
882
|
+
index = None
|
|
883
|
+
shown = index is not None and path is not None
|
|
884
|
+
if shown == self._link:
|
|
885
|
+
return
|
|
886
|
+
self._link = shown
|
|
887
|
+
self.link_slot.clear()
|
|
888
|
+
if not shown or path is None:
|
|
889
|
+
return
|
|
890
|
+
with self.link_slot:
|
|
891
|
+
ui.button(
|
|
892
|
+
"Edit on its plot",
|
|
893
|
+
on_click=partial(page.edit_on_plot, path),
|
|
894
|
+
).props("flat dense no-caps").mark(f"tree-editor-on-plot:{path}")
|
|
895
|
+
|
|
896
|
+
# -- the closed line -----------------------------------------------------------
|
|
897
|
+
|
|
898
|
+
def _reset_layout(self) -> None:
|
|
899
|
+
"""Reset layout, at the line's end while the test's EFFECTIVE entry
|
|
900
|
+
has a ``plots`` key (the owner's ruling, Q8 A) — on both of the
|
|
901
|
+
line's branches: a test with no tree can have extras. It takes the
|
|
902
|
+
context series and the watch plots; the derived plots are the
|
|
903
|
+
tree's, and stay."""
|
|
904
|
+
page = self.page
|
|
905
|
+
if "plots" not in (page.shell.session.spec.entry(page.model_id) or {}):
|
|
906
|
+
return
|
|
907
|
+
ui.space()
|
|
908
|
+
with (
|
|
909
|
+
ui.button("Reset layout", on_click=page.reset_layout)
|
|
910
|
+
.props("flat dense no-caps")
|
|
911
|
+
.mark("tree:reset-layout")
|
|
912
|
+
):
|
|
913
|
+
ui.tooltip(
|
|
914
|
+
"Stage removing this test's plots key: its context series and "
|
|
915
|
+
"watch plots go"
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
def _line(self, view: dict | None) -> None:
|
|
919
|
+
self.line.clear()
|
|
920
|
+
self.named, self.root_pill = [], None
|
|
921
|
+
with self.line:
|
|
922
|
+
if view is None:
|
|
923
|
+
ui.label(f"nothing scored: {self.page.unscored_reason()}").classes(
|
|
924
|
+
"text-secondary"
|
|
925
|
+
).mark("tree:summary")
|
|
926
|
+
self._reset_layout()
|
|
927
|
+
return
|
|
928
|
+
ui.button(
|
|
929
|
+
"Hide tree" if self.is_open else "Show tree",
|
|
930
|
+
icon="expand_more" if self.is_open else "chevron_right",
|
|
931
|
+
on_click=self.toggle,
|
|
932
|
+
).props("flat dense no-caps").mark("tree:toggle")
|
|
933
|
+
self.root_pill = Pill(root_status(view), "tree:root-pill")
|
|
934
|
+
ui.label(root_text(view)).classes("text-weight-medium").mark("tree:root")
|
|
935
|
+
text, self.named, more = summary_text(view)
|
|
936
|
+
ui.label(text).classes("text-secondary").mark("tree:summary")
|
|
937
|
+
for leaf in self.named:
|
|
938
|
+
button = ui.button(
|
|
939
|
+
leaf_name(leaf),
|
|
940
|
+
on_click=partial(self.page.jump_to, str(leaf["path"])),
|
|
941
|
+
).props("flat dense no-caps")
|
|
942
|
+
with button.mark("tree:jump"):
|
|
943
|
+
# Two criteria on one variable read identically ("range ·
|
|
944
|
+
# x, range · x"): the path is what tells them apart.
|
|
945
|
+
ui.tooltip(str(leaf["path"]))
|
|
946
|
+
if more:
|
|
947
|
+
ui.label(f"+{more} more").classes("text-secondary").mark("tree:more")
|
|
948
|
+
if self.is_open and any(
|
|
949
|
+
n.get("kind") != "leaf" for n in nodes_of(view)[1:]
|
|
950
|
+
):
|
|
951
|
+
# Y7: beside the tally, while the rows show and hold a group.
|
|
952
|
+
# Icons (H4, the U5 review): their words wrapped the line at
|
|
953
|
+
# 900 px. Named for a screen reader and by a tooltip.
|
|
954
|
+
for icon, on, name, marker in (
|
|
955
|
+
("unfold_more", False, "Expand all groups", "tree:expand-all"),
|
|
956
|
+
("unfold_less", True, "Collapse all groups", "tree:collapse-all"),
|
|
957
|
+
):
|
|
958
|
+
button = (
|
|
959
|
+
ui.button(icon=icon, on_click=partial(self.fold_all, on))
|
|
960
|
+
.props("flat dense round size=sm")
|
|
961
|
+
.mark(marker)
|
|
962
|
+
)
|
|
963
|
+
button.props["aria-label"] = name
|
|
964
|
+
with button:
|
|
965
|
+
ui.tooltip(name)
|
|
966
|
+
self._reset_layout()
|
|
967
|
+
|
|
968
|
+
# -- the rows ------------------------------------------------------------------
|
|
969
|
+
|
|
970
|
+
def _build_rows(self, view: dict) -> None:
|
|
971
|
+
if self.editor is not None:
|
|
972
|
+
# The rows it hangs under go, and the page's one editor with
|
|
973
|
+
# them: the node at its path may be another criterion now.
|
|
974
|
+
self.page.drop_editor()
|
|
975
|
+
for host in self.hosts:
|
|
976
|
+
host.clear()
|
|
977
|
+
self.hosts, self.painters, self.rows_by_path = [], {}, {}
|
|
978
|
+
self.targets, self.chevrons, self.leaves = {}, {}, set()
|
|
979
|
+
# A folded path that names no group any more is dropped (Y6): an
|
|
980
|
+
# edit that said where its nodes went has re-keyed the set already
|
|
981
|
+
# (`rekey`); this is for any other.
|
|
982
|
+
groups = {
|
|
983
|
+
str(n.get("path")) for n in nodes_of(view)[1:] if n.get("kind") != "leaf"
|
|
984
|
+
}
|
|
985
|
+
self._keep(self.folded & groups)
|
|
986
|
+
self.rows.clear()
|
|
987
|
+
self.revision += 1
|
|
988
|
+
self.rows.props["data-rev"] = str(self.revision)
|
|
989
|
+
self.rows.update()
|
|
990
|
+
with self.rows:
|
|
991
|
+
self._row(view, 0, self.folded)
|
|
992
|
+
self.paint_clicks()
|
|
993
|
+
|
|
994
|
+
def _show_icon(self, node: dict, path: str) -> tuple[ui.element, ui.tooltip]:
|
|
995
|
+
"""⤓ (Q7), at the start of the criterion's icon cluster: the
|
|
996
|
+
criterion where it is drawn — its plot's row, or its Statistics
|
|
997
|
+
row — scrolled to and flashed; nothing opens. The page owns the
|
|
998
|
+
jump, as it owns the closed line's named buttons. Its click is
|
|
999
|
+
stopped in the browser: the row's own click opens the editor."""
|
|
1000
|
+
tip = show_tip(node)
|
|
1001
|
+
button = (
|
|
1002
|
+
ui.button(icon="vertical_align_bottom")
|
|
1003
|
+
.props("flat dense round size=sm")
|
|
1004
|
+
.classes("dstf-show")
|
|
1005
|
+
.on(
|
|
1006
|
+
"click",
|
|
1007
|
+
partial(self.page.show_criterion, path),
|
|
1008
|
+
js_handler="(e) => { e.stopPropagation(); emit(); }",
|
|
1009
|
+
)
|
|
1010
|
+
)
|
|
1011
|
+
# Named for a screen reader: its icon's ligature names nothing.
|
|
1012
|
+
button.props["aria-label"] = tip
|
|
1013
|
+
with button:
|
|
1014
|
+
tooltip = ui.tooltip(tip).props('anchor="top middle" self="bottom middle"')
|
|
1015
|
+
button.mark(f"show:{path}")
|
|
1016
|
+
return button, tooltip
|
|
1017
|
+
|
|
1018
|
+
def _row(self, node: dict, depth: int, folded: set[str]) -> None:
|
|
1019
|
+
path, model = str(node["path"]), self.page.model_id
|
|
1020
|
+
group = node.get("kind") != "leaf"
|
|
1021
|
+
hide = any(up in folded for up in _ancestors(path))
|
|
1022
|
+
row = ui.row().classes("dstf-tree-row items-center no-wrap gap-3 w-full")
|
|
1023
|
+
self.rows_by_path[path] = row
|
|
1024
|
+
# What the page's scripts read: the row's node, its depth and
|
|
1025
|
+
# whether it is a group (the hovered branch, U5 Y2), and the root's
|
|
1026
|
+
# one drop zone.
|
|
1027
|
+
row.props["data-tree-path"] = path
|
|
1028
|
+
row.props["data-depth"] = str(depth)
|
|
1029
|
+
if group:
|
|
1030
|
+
row.props["data-group"] = "1"
|
|
1031
|
+
if depth == 0:
|
|
1032
|
+
row.props["data-root"] = "1"
|
|
1033
|
+
# The tab's folds, drawn at build as the page's script draws them.
|
|
1034
|
+
if hide:
|
|
1035
|
+
row.props["hidden"] = True
|
|
1036
|
+
if path in folded:
|
|
1037
|
+
row.props["data-folded"] = "1"
|
|
1038
|
+
# The row IS the way in (U5): a criterion's editor opens under it,
|
|
1039
|
+
# a k-of-n's or a weighted's fields — whatever a panel registered
|
|
1040
|
+
# (`Panel.on_click`) — and Enter or Space does the same where the
|
|
1041
|
+
# row is a button (`paint_clicks`). Its own controls stop the click
|
|
1042
|
+
# before it gets here: the chevron, the grip, the lead (the kind
|
|
1043
|
+
# dropdown) and the host.
|
|
1044
|
+
row.on("click", partial(self.page.tree_row_click, path))
|
|
1045
|
+
row.on("keydown", partial(self.page.tree_row_click, path), js_handler=_ROW_KEYS)
|
|
1046
|
+
if not group:
|
|
1047
|
+
self.leaves.add(path)
|
|
1048
|
+
# `--indent` is where a "before" line starts: the row's content,
|
|
1049
|
+
# right of its bars (U4 C15, U5 E4).
|
|
1050
|
+
with row.style(f"--indent: {content_left(depth)}px").mark(f"tree-row:{path}"):
|
|
1051
|
+
# A bar per level (U5, Q1): what says what is under what, in
|
|
1052
|
+
# place of an indent (Y3). Level i's colour cycles every four;
|
|
1053
|
+
# the root draws none.
|
|
1054
|
+
with (
|
|
1055
|
+
ui.element("span")
|
|
1056
|
+
.classes("dstf-bars")
|
|
1057
|
+
.style(f"width: {BAR_STEP * depth}px")
|
|
1058
|
+
.props('aria-hidden="true"')
|
|
1059
|
+
):
|
|
1060
|
+
for i in range(1, depth + 1):
|
|
1061
|
+
ui.element("span").classes(f"dstf-bar dstf-lvl-{(i - 1) % 4 + 1}")
|
|
1062
|
+
# The chevron's place (Q3), kept on every row — empty on a
|
|
1063
|
+
# criterion — so the pills stay in line. The root's folds with
|
|
1064
|
+
# the card's Show / Hide tree.
|
|
1065
|
+
chevron: ui.element | None = None
|
|
1066
|
+
with ui.element("span").classes("dstf-chev-slot"):
|
|
1067
|
+
if group and depth:
|
|
1068
|
+
on, name = path in folded, node_text(node)
|
|
1069
|
+
chevron = (
|
|
1070
|
+
ui.element("button")
|
|
1071
|
+
.classes("dstf-chev")
|
|
1072
|
+
.props('type="button"')
|
|
1073
|
+
.mark(f"fold:{path}")
|
|
1074
|
+
)
|
|
1075
|
+
# Set as items, not through the props string, whose
|
|
1076
|
+
# parser a quote in a node's text would break.
|
|
1077
|
+
chevron.props["data-name"] = name
|
|
1078
|
+
chevron.props["aria-expanded"] = "false" if on else "true"
|
|
1079
|
+
chevron.props["aria-label"] = f"{'Unfold' if on else 'Fold'} {name}"
|
|
1080
|
+
with chevron:
|
|
1081
|
+
ui.icon("expand_more").props('aria-hidden="true"')
|
|
1082
|
+
chevron.on(
|
|
1083
|
+
"click", partial(self._fold, path), js_handler=_FOLD_CLICK
|
|
1084
|
+
)
|
|
1085
|
+
self.chevrons[path] = chevron
|
|
1086
|
+
# The drag handle's place, before the pill: hidden — its width
|
|
1087
|
+
# kept, so every pill stays in line — until a panel makes the
|
|
1088
|
+
# row draggable (`Panel.movable`). The root's holds no handle:
|
|
1089
|
+
# nothing sits beside it. Hidden by its own class, never
|
|
1090
|
+
# `set_visibility`: Quasar's `.hidden` sits in a cascade layer
|
|
1091
|
+
# that beats an unlayered `!important`, and took the width too.
|
|
1092
|
+
grip = (
|
|
1093
|
+
ui.element("span")
|
|
1094
|
+
.classes("dstf-grip dstf-grip-off")
|
|
1095
|
+
.mark(f"grip:{path}")
|
|
1096
|
+
)
|
|
1097
|
+
grip.on("click", js_handler=_STOP)
|
|
1098
|
+
if depth:
|
|
1099
|
+
grip.props["draggable"] = "true"
|
|
1100
|
+
grip.props["aria-label"] = "Drag to move"
|
|
1101
|
+
grip.props["title"] = "Drag to move"
|
|
1102
|
+
with grip:
|
|
1103
|
+
ui.icon("drag_indicator").props('aria-hidden="true"')
|
|
1104
|
+
pill = Pill(node_status(node), f"tree-pill:{path}")
|
|
1105
|
+
# A combinator's lead place, right after its pill: where its
|
|
1106
|
+
# kind is read (`Panel.lead`) — a leaf has none.
|
|
1107
|
+
lead: ui.element | None = None
|
|
1108
|
+
if group:
|
|
1109
|
+
lead = ui.element("span").classes("dstf-node-lead")
|
|
1110
|
+
lead.on("click", js_handler=_STOP)
|
|
1111
|
+
text = (
|
|
1112
|
+
ui.label(node_text(node))
|
|
1113
|
+
.classes("dstf-tree-text text-body2")
|
|
1114
|
+
.mark(f"tree-text:{path}")
|
|
1115
|
+
)
|
|
1116
|
+
if depth == 0:
|
|
1117
|
+
# Q2: "and · 5 criteria · root" — the "· " is the tag's CSS.
|
|
1118
|
+
ui.label("root").classes("dstf-root-tag").mark("tree:root-tag")
|
|
1119
|
+
summary: ui.label | None = None
|
|
1120
|
+
if chevron is not None:
|
|
1121
|
+
summary = (
|
|
1122
|
+
ui.label(fold_text(node))
|
|
1123
|
+
.classes("dstf-fold-summary")
|
|
1124
|
+
.mark(f"tree-summary:{path}")
|
|
1125
|
+
)
|
|
1126
|
+
readout: ui.label | None = None
|
|
1127
|
+
show: tuple[ui.element, ui.tooltip] | None = None
|
|
1128
|
+
if node.get("kind") == "leaf":
|
|
1129
|
+
readout = ui.label(leaf_readout(node)).classes(
|
|
1130
|
+
"dstf-tree-readout dstf-mono text-xs"
|
|
1131
|
+
)
|
|
1132
|
+
ui.space()
|
|
1133
|
+
if not group:
|
|
1134
|
+
show = self._show_icon(node, path)
|
|
1135
|
+
target = RowTarget(self.page, f"tree:{path}", row, path, None)
|
|
1136
|
+
target.lead = lead
|
|
1137
|
+
# The tree card's rows are where a node moves from (V2).
|
|
1138
|
+
target.drag = True
|
|
1139
|
+
target.grip = grip if depth else None
|
|
1140
|
+
# And, on a group's row, where a click is a panel's to say (Q6,
|
|
1141
|
+
# `Panel.on_click`); a criterion's click is its editor (J4).
|
|
1142
|
+
target.clickable = group
|
|
1143
|
+
self.targets[path] = target
|
|
1144
|
+
host = InlineHost(
|
|
1145
|
+
self.page.shell,
|
|
1146
|
+
Placement.NODE,
|
|
1147
|
+
partial(Selection, tests=frozenset({model}), node_path=path),
|
|
1148
|
+
figure=None,
|
|
1149
|
+
marker=f"node:{path}",
|
|
1150
|
+
row=target,
|
|
1151
|
+
apply=self.page.apply,
|
|
1152
|
+
)
|
|
1153
|
+
# A host drawn inside a row keeps to its own width, or the
|
|
1154
|
+
# cluster sits left of where it belongs (the 3b review, in
|
|
1155
|
+
# Chromium).
|
|
1156
|
+
host.column.classes(remove="w-full", add="dstf-node-host")
|
|
1157
|
+
# The row's form slot, between it and the next node's row; the
|
|
1158
|
+
# host is built once its panels have somewhere to open a row.
|
|
1159
|
+
target.slot = ui.column().classes("w-full gap-0")
|
|
1160
|
+
# What the fold's script hides with the row (`_FOLD_CLICK`).
|
|
1161
|
+
target.slot.props["data-slot-of"] = path
|
|
1162
|
+
if hide:
|
|
1163
|
+
target.slot.props["hidden"] = True
|
|
1164
|
+
host.build()
|
|
1165
|
+
self.hosts.append(host)
|
|
1166
|
+
self.painters[path] = _Row(pill, text, readout, lead, summary, chevron, show)
|
|
1167
|
+
# Once the host has drawn: whether its lead holds the kind decides
|
|
1168
|
+
# the text.
|
|
1169
|
+
self.painters[path].paint(node)
|
|
1170
|
+
for child in node.get("children") or []:
|
|
1171
|
+
self._row(child, depth + 1, folded)
|