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
dstf/authoring/forms.py
ADDED
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
"""NiceGUI controls from a mode's Config dataclass (§4.7 "Edit criteria").
|
|
2
|
+
|
|
3
|
+
``derive_schema`` describes the dataclass — field name, type, default,
|
|
4
|
+
choices, label, help, bounds; this module turns each ``FieldSpec`` into one
|
|
5
|
+
control and hands the caller the live values. The NiceGUI emitter is the
|
|
6
|
+
sibling of ``reporting/schema_export``'s JSON-Schema emitter: same input,
|
|
7
|
+
different surface.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import copy
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
from collections.abc import Callable, Mapping
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from functools import partial
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from nicegui import ui
|
|
21
|
+
|
|
22
|
+
from ..comparison.mode_schema import SIDE_LABELS, FieldSpec, Schema
|
|
23
|
+
from .typed_number import TypedNumber
|
|
24
|
+
|
|
25
|
+
OnChange = Callable[[dict], None]
|
|
26
|
+
|
|
27
|
+
#: What a source of a value in effect reads as (K3); the keys are
|
|
28
|
+
#: `TestComparison.tolerance_source`'s.
|
|
29
|
+
SOURCES = {
|
|
30
|
+
"test": "the test's tolerance",
|
|
31
|
+
"reference": "the baseline's tolerance",
|
|
32
|
+
"project": "the project's tolerance",
|
|
33
|
+
}
|
|
34
|
+
#: The one bottom-space rule of an editor row (K4): every one-line field
|
|
35
|
+
#: reserves Quasar's bottom line, whether it holds a hint, a validation
|
|
36
|
+
#: message or nothing, so the input lines of a row share one y. Without it
|
|
37
|
+
#: a number with a validation rule reserved the line and nothing else did,
|
|
38
|
+
#: and its input line sat 20 px above the row's.
|
|
39
|
+
BOTTOM = "bottom-slots"
|
|
40
|
+
#: A hint is on ONE line (K8): Quasar's bottom line is absolutely placed,
|
|
41
|
+
#: so its text never widens the field, and at the row's 140 px the hint
|
|
42
|
+
#: "in effect: 0.01 (the test's tolerance)" wrapped to three lines under
|
|
43
|
+
#: its field. A field with a hint is at least as wide as the hint: px per
|
|
44
|
+
#: character of Quasar's 11 px hint font, measured in Chromium (185 px for
|
|
45
|
+
#: that 38-character hint, 252 px for 50: 4.9-5.0 px, rounded up for a
|
|
46
|
+
#: wider fallback font), plus the bottom line's 12 px padding each side.
|
|
47
|
+
HINT_PX_PER_CHAR, HINT_PADDING_PX = 5.4, 24
|
|
48
|
+
#: An EMPTY field draws its label at full size on its input line — a filled
|
|
49
|
+
#: one's floats above it, smaller — and cuts it with "…" where the field is
|
|
50
|
+
#: narrower: at a table cell's 120 px "before time_tolerance" read "before
|
|
51
|
+
#: time_tole…". px per character of Quasar's 14 px label font, measured in
|
|
52
|
+
#: Chromium (146 px for those 21 characters: 6.95), rounded up for a wider
|
|
53
|
+
#: fallback font.
|
|
54
|
+
LABEL_PX_PER_CHAR = 7.2
|
|
55
|
+
#: What the link toggle between a tolerance's two fields says, by state.
|
|
56
|
+
LINK_TIPS = {
|
|
57
|
+
True: "Linked: both sides change together",
|
|
58
|
+
False: "Unlinked: each side is set on its own",
|
|
59
|
+
}
|
|
60
|
+
#: The toggle's accessible name, by state: the state first, and whose sides.
|
|
61
|
+
#: One name for both states left a screen reader the pressed flag alone.
|
|
62
|
+
LINK_NAMES = {
|
|
63
|
+
True: "Linked: both sides of {label} change together",
|
|
64
|
+
False: "Unlinked: each side of {label} is set on its own",
|
|
65
|
+
}
|
|
66
|
+
_OTHER = {"lower": "upper", "upper": "lower"}
|
|
67
|
+
#: `render_form`'s ``place``: offered every number field and each side of
|
|
68
|
+
#: a tolerance — its pointer (its keys into the leaf), its label and the
|
|
69
|
+
#: field itself — to draw a ⌖ in where a click on the plot can set it.
|
|
70
|
+
Place = Callable[[tuple, str, ui.element], None]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def in_effect_hint(entry: dict) -> str:
|
|
74
|
+
"""``in effect: 0.01 (the test's tolerance)``."""
|
|
75
|
+
source = SOURCES.get(str(entry.get("source")), str(entry.get("source")))
|
|
76
|
+
return f"in effect: {entry['value']:g} ({source})"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def hint_width(hint: str) -> int:
|
|
80
|
+
"""The px a field needs for ``hint`` on one line under it."""
|
|
81
|
+
return math.ceil(len(hint) * HINT_PX_PER_CHAR) + HINT_PADDING_PX
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class Form:
|
|
86
|
+
"""The rendered controls, by field name, and their current values."""
|
|
87
|
+
|
|
88
|
+
controls: dict[str, ui.element] = field(default_factory=dict)
|
|
89
|
+
_values: dict = field(default_factory=dict)
|
|
90
|
+
_passthrough: dict = field(default_factory=dict)
|
|
91
|
+
|
|
92
|
+
def values(self) -> dict:
|
|
93
|
+
"""Every field the caller gave a value or the user edited — ``None``
|
|
94
|
+
is "unset" and is omitted; a dataclass default is shown as the
|
|
95
|
+
control's placeholder and never written (the evaluator falls back
|
|
96
|
+
to the test's base tolerance for a leaf without one, and a written
|
|
97
|
+
1e-4 would change that verdict). Passthrough fields keep the value
|
|
98
|
+
they were given."""
|
|
99
|
+
out = {k: v for k, v in self._values.items() if v is not None}
|
|
100
|
+
out.update({k: v for k, v in self._passthrough.items() if v is not None})
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
def preview(self, values: dict | None, revision: int = 0) -> None:
|
|
104
|
+
"""Show ``values`` — the params a handle held on the plot would
|
|
105
|
+
write (D149) — in the number fields and tolerance sides whose value
|
|
106
|
+
would change, staging nothing; None shows what they hold again."""
|
|
107
|
+
for name, control in self.controls.items():
|
|
108
|
+
held = self._values.get(name)
|
|
109
|
+
would = held if values is None else values.get(name)
|
|
110
|
+
if isinstance(control, SidesControl):
|
|
111
|
+
control.preview(None if would == held else would, revision)
|
|
112
|
+
elif isinstance(control, TypedNumber):
|
|
113
|
+
control.preview(None if would == held else _shown(would), revision)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def render_form(
|
|
117
|
+
schema: Schema,
|
|
118
|
+
values: dict,
|
|
119
|
+
on_change: OnChange,
|
|
120
|
+
*,
|
|
121
|
+
classes: str = "",
|
|
122
|
+
in_effect: dict | None = None,
|
|
123
|
+
shown: Mapping[str, dict[str, float | None]] | None = None,
|
|
124
|
+
on_edit: Callable[[str, SidesControl], None] | None = None,
|
|
125
|
+
place: Place | None = None,
|
|
126
|
+
) -> Form:
|
|
127
|
+
"""Build one control per field into the current NiceGUI container.
|
|
128
|
+
|
|
129
|
+
``values`` pre-fills (missing keys fall back to the field's default);
|
|
130
|
+
``on_change`` receives the full values dict after every edit.
|
|
131
|
+
``classes`` is added to every control, for a caller laying the fields
|
|
132
|
+
out itself — the criterion editor puts them in one wrapping row, where
|
|
133
|
+
each control needs a width of its own. A multi-line control (a list
|
|
134
|
+
shown as JSON) takes the whole line instead, whatever ``classes`` says.
|
|
135
|
+
|
|
136
|
+
``in_effect`` (the tree view leaf's, K3) names the value a field
|
|
137
|
+
applies while it is EMPTY: its hint line says it whenever the field is
|
|
138
|
+
empty — at open, and after an own value is cleared — in place of the
|
|
139
|
+
dataclass default as a placeholder, which is not what applies, and
|
|
140
|
+
``values()`` never writes it. A field holding a value shows no hint.
|
|
141
|
+
|
|
142
|
+
A number field handed what is no number — a pair on nrmse, ``"0.1"``,
|
|
143
|
+
``true``, a list: the leaf's config error, which its criteria row says —
|
|
144
|
+
opens BLANK and without that hint: the hint means "nothing is set here,
|
|
145
|
+
so this applies", and something is set. The value stays the form's
|
|
146
|
+
until the field is edited.
|
|
147
|
+
|
|
148
|
+
``shown`` and ``on_edit`` are for a caller that rebuilds the form and
|
|
149
|
+
has to keep a HALF pair across that (:func:`sides_control`'s, per
|
|
150
|
+
field): ``on_edit(name, control)`` is called after every edit of the
|
|
151
|
+
two-sided field ``name`` — the caller reads the control's ``half()``
|
|
152
|
+
and ``shown()`` — and ``shown[name]`` is the half pair that field's
|
|
153
|
+
two fields show, while ``values[name]`` is still what the leaf holds.
|
|
154
|
+
|
|
155
|
+
``place`` is offered every number field and each side of a tolerance
|
|
156
|
+
— ``place(pointer, label, field)``, the pointer ``(name,)`` or
|
|
157
|
+
``(name, "lower")`` — and draws a ⌖ in the field's ``append`` slot
|
|
158
|
+
where a click on the plot can set it (D149); which fields those are is
|
|
159
|
+
the caller's to say, not a list here.
|
|
160
|
+
"""
|
|
161
|
+
in_effect = in_effect or {}
|
|
162
|
+
form = Form()
|
|
163
|
+
for spec in schema.fields:
|
|
164
|
+
current = values.get(spec.name) # the default is a placeholder, not a value
|
|
165
|
+
if spec.type == "passthrough":
|
|
166
|
+
# Structured data (tube control points, declared peaks,
|
|
167
|
+
# checkpoints): shown as JSON, read-only — on-plot editing (§5
|
|
168
|
+
# step 6) is where these get their controls.
|
|
169
|
+
form._passthrough[spec.name] = current
|
|
170
|
+
form.controls[spec.name] = (
|
|
171
|
+
ui.textarea(
|
|
172
|
+
label=spec.label or spec.name,
|
|
173
|
+
value=json.dumps(current) if current is not None else "",
|
|
174
|
+
)
|
|
175
|
+
.props("readonly outlined dense")
|
|
176
|
+
.classes("dstf-field-wide")
|
|
177
|
+
.tooltip(spec.help or "edited on the plot, not here")
|
|
178
|
+
)
|
|
179
|
+
continue
|
|
180
|
+
if spec.name in values:
|
|
181
|
+
form._values[spec.name] = current
|
|
182
|
+
effect = in_effect.get(spec.name)
|
|
183
|
+
hint = in_effect_hint(effect) if effect is not None else None
|
|
184
|
+
if spec.type == "sides":
|
|
185
|
+
# A tolerance with two sides: one group, which keeps its own
|
|
186
|
+
# hint and reserves the bottom line under both its fields.
|
|
187
|
+
group = sides_control(
|
|
188
|
+
spec.label or spec.name,
|
|
189
|
+
current,
|
|
190
|
+
partial(_set, form, spec.name, on_change=on_change),
|
|
191
|
+
side_labels=spec.side_labels or SIDE_LABELS,
|
|
192
|
+
hint=hint,
|
|
193
|
+
mark=spec.name,
|
|
194
|
+
shown=(shown or {}).get(spec.name),
|
|
195
|
+
on_edit=_sides_edited(form, spec.name, on_edit),
|
|
196
|
+
)
|
|
197
|
+
for end, side in (("lower", group.lower), ("upper", group.upper)):
|
|
198
|
+
side.props(BOTTOM)
|
|
199
|
+
if classes:
|
|
200
|
+
side.classes(classes)
|
|
201
|
+
if spec.help:
|
|
202
|
+
side.tooltip(spec.help)
|
|
203
|
+
if place is not None:
|
|
204
|
+
place((spec.name, end), side.props["label"], side)
|
|
205
|
+
form.controls[spec.name] = group
|
|
206
|
+
continue
|
|
207
|
+
control = _control(
|
|
208
|
+
spec,
|
|
209
|
+
current,
|
|
210
|
+
lambda v, name=spec.name, hint=hint: _edited(
|
|
211
|
+
form, name, v, on_change, hint
|
|
212
|
+
),
|
|
213
|
+
)
|
|
214
|
+
if hint is not None:
|
|
215
|
+
if current is None:
|
|
216
|
+
control.props["hint"] = hint
|
|
217
|
+
# Wide enough for it with a value too: clearing one does not
|
|
218
|
+
# reflow the row.
|
|
219
|
+
control.style(f"min-width: {hint_width(hint)}px")
|
|
220
|
+
elif current is None and spec.default is not None:
|
|
221
|
+
control.props(f'placeholder="{spec.default}"')
|
|
222
|
+
if spec.type != "bool":
|
|
223
|
+
control.props(BOTTOM)
|
|
224
|
+
if classes:
|
|
225
|
+
control.classes(classes)
|
|
226
|
+
if spec.help:
|
|
227
|
+
control.tooltip(spec.help)
|
|
228
|
+
if place is not None and spec.type in ("float", "int"):
|
|
229
|
+
place((spec.name,), spec.label or spec.name, control)
|
|
230
|
+
form.controls[spec.name] = control
|
|
231
|
+
return form
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _sides_edited(
|
|
235
|
+
form: Form, name: str, on_edit: Callable[[str, SidesControl], None] | None
|
|
236
|
+
) -> Callable[[], None] | None:
|
|
237
|
+
"""``on_edit`` as the two-sided field ``name``'s control calls it: with
|
|
238
|
+
the field's name and the control itself, read when it is called — the
|
|
239
|
+
control is not built yet where this is asked for."""
|
|
240
|
+
if on_edit is None:
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
def edited() -> None:
|
|
244
|
+
control = form.controls[name]
|
|
245
|
+
assert isinstance(control, SidesControl) # `render_form` built it
|
|
246
|
+
on_edit(name, control)
|
|
247
|
+
|
|
248
|
+
return edited
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def label_width(label: str) -> int:
|
|
252
|
+
"""The px a field needs for ``label`` uncut while the field is empty."""
|
|
253
|
+
return math.ceil(len(label) * LABEL_PX_PER_CHAR)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _set(form: Form, name: str, value: Any, on_change: OnChange) -> None:
|
|
257
|
+
form._values[name] = value
|
|
258
|
+
on_change(form.values())
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _edited(
|
|
262
|
+
form: Form, name: str, value: Any, on_change: OnChange, hint: str | None
|
|
263
|
+
) -> None:
|
|
264
|
+
"""An edit of ``name``: a typed value is the one in effect now, so the
|
|
265
|
+
hint naming another goes; cleared, the field says it again. The width
|
|
266
|
+
the hint needed is kept: the row does not reflow as a value is typed."""
|
|
267
|
+
control = form.controls.get(name)
|
|
268
|
+
if hint is not None and control is not None:
|
|
269
|
+
# Only when it changes, as `SidesControl._say_hint`: every write
|
|
270
|
+
# of a prop is a round trip that resends the field.
|
|
271
|
+
shown = None if value is not None else hint
|
|
272
|
+
if control.props.get("hint") != shown:
|
|
273
|
+
control.props["hint"] = shown
|
|
274
|
+
control.update()
|
|
275
|
+
_set(form, name, value, on_change)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _at_least(lo: float) -> Callable[[Any], bool]:
|
|
279
|
+
return lambda v: v is None or v >= lo
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _at_most(hi: float) -> Callable[[Any], bool]:
|
|
283
|
+
return lambda v: v is None or v <= hi
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _control(spec: FieldSpec, current: Any, set_value: Callable) -> ui.element:
|
|
287
|
+
label = spec.label or spec.name
|
|
288
|
+
if spec.type in ("float", "int"):
|
|
289
|
+
# ui_min / ui_max are the schema's SOFT bounds (a hint for a control,
|
|
290
|
+
# never a clamp — mode_schema.py); NiceGUI's own `min`/`max` props
|
|
291
|
+
# clamp on blur and fire on_change with the clamped value, which
|
|
292
|
+
# would stage a rewrite the user never typed. Validate instead.
|
|
293
|
+
validation: dict[str, Callable[[Any], bool]] = {}
|
|
294
|
+
if spec.ui_min is not None:
|
|
295
|
+
validation[f"below {spec.ui_min}"] = _at_least(spec.ui_min)
|
|
296
|
+
if spec.ui_max is not None:
|
|
297
|
+
validation[f"above {spec.ui_max}"] = _at_most(spec.ui_max)
|
|
298
|
+
kwargs: dict[str, Any] = {"validation": validation}
|
|
299
|
+
if spec.type == "int":
|
|
300
|
+
kwargs["step"] = 1
|
|
301
|
+
kwargs["precision"] = 0
|
|
302
|
+
cast = int if spec.type == "int" else float
|
|
303
|
+
|
|
304
|
+
def on_number(e, cast=cast):
|
|
305
|
+
set_value(None if e.value is None else cast(e.value))
|
|
306
|
+
|
|
307
|
+
return TypedNumber(
|
|
308
|
+
label=label, value=_shown(current), on_change=on_number, **kwargs
|
|
309
|
+
).props("dense")
|
|
310
|
+
if spec.type == "bool":
|
|
311
|
+
# A checkbox has no placeholder: show the EFFECTIVE state (the
|
|
312
|
+
# default when unset) and write only when the user changes it.
|
|
313
|
+
shown = bool(spec.default) if current is None else bool(current)
|
|
314
|
+
return ui.checkbox(label, value=shown, on_change=lambda e: set_value(e.value))
|
|
315
|
+
if spec.type == "enum":
|
|
316
|
+
# Same for a select: the default is what the leaf scores with.
|
|
317
|
+
return ui.select(
|
|
318
|
+
list(spec.choices or []),
|
|
319
|
+
label=label,
|
|
320
|
+
value=spec.default if current is None else current,
|
|
321
|
+
on_change=lambda e: set_value(e.value),
|
|
322
|
+
).props("dense")
|
|
323
|
+
return ui.input(
|
|
324
|
+
label=label,
|
|
325
|
+
value="" if current is None else str(current),
|
|
326
|
+
on_change=lambda e: set_value(e.value),
|
|
327
|
+
).props("dense")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _shown(value: object) -> Any:
|
|
331
|
+
"""What a number field shows for ``value``: a number, else blank — a
|
|
332
|
+
field or a side the file holds as anything else is the leaf's config
|
|
333
|
+
error, which its criteria row says; the editor still opens."""
|
|
334
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
335
|
+
return value
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class SidesControl(ui.row):
|
|
340
|
+
"""A tolerance's two sides: the ``lower`` field, the ``link`` toggle and
|
|
341
|
+
the ``upper`` field, in one group. :func:`sides_control` says what it
|
|
342
|
+
shows and what it hands ``on_change``."""
|
|
343
|
+
|
|
344
|
+
def __init__(
|
|
345
|
+
self,
|
|
346
|
+
label: str,
|
|
347
|
+
value: Any,
|
|
348
|
+
on_change: Callable[[Any], None],
|
|
349
|
+
*,
|
|
350
|
+
side_labels: tuple[str, str],
|
|
351
|
+
hint: str | None,
|
|
352
|
+
mark: str | None,
|
|
353
|
+
required: bool,
|
|
354
|
+
shown: dict[str, float | None] | None,
|
|
355
|
+
on_edit: Callable[[], None] | None,
|
|
356
|
+
) -> None:
|
|
357
|
+
# Not `wrap=False`: that is an inline style, and whether the group
|
|
358
|
+
# wraps is the stylesheet's to say (it does where it has no room).
|
|
359
|
+
super().__init__()
|
|
360
|
+
self.classes("dstf-sides")
|
|
361
|
+
#: What the fields show: the spec's value, or the half pair a
|
|
362
|
+
#: caller kept beside it (``shown``).
|
|
363
|
+
showing = value if shown is None else shown
|
|
364
|
+
pair = isinstance(showing, dict)
|
|
365
|
+
#: The shape the spec holds IS the link state: a number (or nothing)
|
|
366
|
+
#: is linked, a pair is not — equal sides or not.
|
|
367
|
+
self.linked = not pair
|
|
368
|
+
self._sides: dict[str, float | None] = {
|
|
369
|
+
side: _shown(showing.get(side) if pair else showing)
|
|
370
|
+
for side in ("lower", "upper")
|
|
371
|
+
}
|
|
372
|
+
self._on_change, self._hint, self._syncing = on_change, hint, False
|
|
373
|
+
self._on_edit = on_edit
|
|
374
|
+
self._required, self._label = required, label
|
|
375
|
+
#: What the spec holds: what `on_change` last got, at first what the
|
|
376
|
+
#: control was built on. And what it held when the field being
|
|
377
|
+
#: edited took the focus (`_entered`): what a side that ends up
|
|
378
|
+
#: blank puts back.
|
|
379
|
+
self._held: Any = copy.deepcopy(value)
|
|
380
|
+
self._at_focus: Any = copy.deepcopy(value)
|
|
381
|
+
self._editing: str | None = None
|
|
382
|
+
#: The spec holds something no side shows (``"0.1"``, ``true``, a
|
|
383
|
+
#: list, a pair of two non-numbers): both fields are blank, and the
|
|
384
|
+
#: hint is not said — it means nothing is set here. Until an edit.
|
|
385
|
+
self._unusable = value is not None and not any(
|
|
386
|
+
v is not None for v in self._sides.values()
|
|
387
|
+
)
|
|
388
|
+
with self:
|
|
389
|
+
self.lower = self._field("lower", f"{side_labels[0]} {label}")
|
|
390
|
+
# Its icon, colour and name are the state's (`_paint`). The
|
|
391
|
+
# colour is the stylesheet's, read off `aria-pressed`, so the
|
|
392
|
+
# button takes none of Quasar's: its `text-primary` is
|
|
393
|
+
# `!important` in a layer, which no rule of ours overrides.
|
|
394
|
+
self.link = ui.button(on_click=self.toggle, color=None).props(
|
|
395
|
+
"flat dense round size=sm"
|
|
396
|
+
)
|
|
397
|
+
with self.link:
|
|
398
|
+
self._tip = ui.tooltip("")
|
|
399
|
+
self.upper = self._field("upper", f"{side_labels[1]} {label}")
|
|
400
|
+
if mark:
|
|
401
|
+
self.lower.mark(f"{mark}:lower")
|
|
402
|
+
self.upper.mark(f"{mark}:upper")
|
|
403
|
+
self.link.mark(f"{mark}:link")
|
|
404
|
+
if hint is not None:
|
|
405
|
+
# Wide enough for the hint on one line, as a single field is —
|
|
406
|
+
# but as a variable the stylesheet reads, not a min-width: where
|
|
407
|
+
# the row is too narrow for the group that width is dropped
|
|
408
|
+
# rather than push the editor wider than its card.
|
|
409
|
+
self.classes("dstf-sides-hinted")
|
|
410
|
+
self.style(f"--dstf-hint-width: {hint_width(hint)}px")
|
|
411
|
+
self._paint()
|
|
412
|
+
self._say_hint()
|
|
413
|
+
self._mark_blanks()
|
|
414
|
+
|
|
415
|
+
def half(self) -> bool:
|
|
416
|
+
"""Whether one side is blank and the other is not, unlinked: a pair
|
|
417
|
+
with one side is no value, so nothing was handed to ``on_change``
|
|
418
|
+
and what it last got is stale."""
|
|
419
|
+
return self._blank("lower") or self._blank("upper")
|
|
420
|
+
|
|
421
|
+
def shown(self) -> dict[str, float | None]:
|
|
422
|
+
"""What the two fields show, as the pair a rebuilt control is told
|
|
423
|
+
to show (``shown``): a half pair too, which ``on_change`` never
|
|
424
|
+
carried."""
|
|
425
|
+
return dict(self._sides)
|
|
426
|
+
|
|
427
|
+
def preview(self, value: Any, revision: int = 0) -> None:
|
|
428
|
+
"""Show ``value`` — a number, or a pair — in the two fields in place
|
|
429
|
+
of what they hold, a side that would not change left as it is;
|
|
430
|
+
None shows what they hold again. Nothing is handed to
|
|
431
|
+
``on_change`` (`TypedNumber.preview`)."""
|
|
432
|
+
pair = isinstance(value, dict)
|
|
433
|
+
for side, number in (("lower", self.lower), ("upper", self.upper)):
|
|
434
|
+
would = None if value is None else _shown(value[side] if pair else value)
|
|
435
|
+
number.preview(None if would == self._sides[side] else would, revision)
|
|
436
|
+
|
|
437
|
+
def toggle(self) -> None:
|
|
438
|
+
"""Unlink: the number becomes the pair holding it twice (nothing
|
|
439
|
+
stays nothing). Link: both sides take the LARGER one, so a re-link
|
|
440
|
+
never narrows what the criterion allows."""
|
|
441
|
+
self._toggle()
|
|
442
|
+
if self._on_edit is not None:
|
|
443
|
+
self._on_edit()
|
|
444
|
+
|
|
445
|
+
def _toggle(self) -> None:
|
|
446
|
+
self.linked = not self.linked
|
|
447
|
+
self._paint()
|
|
448
|
+
present = [v for v in self._sides.values() if v is not None]
|
|
449
|
+
if not self.linked:
|
|
450
|
+
if present: # linked, both fields held the one number
|
|
451
|
+
self._hand({"lower": present[0], "upper": present[0]})
|
|
452
|
+
elif present:
|
|
453
|
+
both = max(present)
|
|
454
|
+
for side in ("lower", "upper"):
|
|
455
|
+
self._sides[side] = both
|
|
456
|
+
self._show(side, both)
|
|
457
|
+
self._mark_blanks()
|
|
458
|
+
self._hand(both)
|
|
459
|
+
# A new start: a side emptied next puts back what the toggle left,
|
|
460
|
+
# in the shape it left it.
|
|
461
|
+
self._entered(None)
|
|
462
|
+
|
|
463
|
+
def _field(self, side: str, label: str) -> TypedNumber:
|
|
464
|
+
# "required" through the field's own validation: NiceGUI validates
|
|
465
|
+
# a field after its change handlers and resets an `error` prop it
|
|
466
|
+
# did not set itself, so a mark written as a prop never showed.
|
|
467
|
+
field = TypedNumber(
|
|
468
|
+
label=label,
|
|
469
|
+
value=self._sides[side],
|
|
470
|
+
on_change=lambda e: self._edited(side, e.value),
|
|
471
|
+
validation=lambda _value: "required" if self._missing(side) else None,
|
|
472
|
+
).props("dense")
|
|
473
|
+
field.on("focus", lambda _e: self._entered(side), [])
|
|
474
|
+
return field
|
|
475
|
+
|
|
476
|
+
def _entered(self, side: str | None) -> None:
|
|
477
|
+
"""``side``'s field took the focus — or is the first edited since
|
|
478
|
+
another was, where no focus was heard (a value set from Python):
|
|
479
|
+
what the spec holds now is what that side, left blank, puts back."""
|
|
480
|
+
self._editing, self._at_focus = side, copy.deepcopy(self._held)
|
|
481
|
+
|
|
482
|
+
def _hand(self, value: Any) -> None:
|
|
483
|
+
"""Hand ``value`` to ``on_change``: it is what the spec holds next."""
|
|
484
|
+
self._held = copy.deepcopy(value)
|
|
485
|
+
self._on_change(value)
|
|
486
|
+
|
|
487
|
+
def _blank(self, side: str) -> bool:
|
|
488
|
+
return (
|
|
489
|
+
not self.linked
|
|
490
|
+
and self._sides[side] is None
|
|
491
|
+
and self._sides[_OTHER[side]] is not None
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
def _missing(self, side: str) -> bool:
|
|
495
|
+
"""Whether ``side``'s field says "required": the blank half of a
|
|
496
|
+
pair, or — where the tolerance itself is required — any blank."""
|
|
497
|
+
return self._blank(side) or (self._required and self._sides[side] is None)
|
|
498
|
+
|
|
499
|
+
def _edited(self, side: str, value: Any) -> None:
|
|
500
|
+
if self._syncing: # the other field, set from here: not an edit
|
|
501
|
+
return
|
|
502
|
+
self._take(side, value)
|
|
503
|
+
if self._on_edit is not None:
|
|
504
|
+
self._on_edit()
|
|
505
|
+
|
|
506
|
+
def _take(self, side: str, value: Any) -> None:
|
|
507
|
+
if side != self._editing:
|
|
508
|
+
self._entered(side)
|
|
509
|
+
self._unusable = False # what the spec holds next is this control's
|
|
510
|
+
value = None if value is None else float(value)
|
|
511
|
+
self._sides[side] = value
|
|
512
|
+
if self.linked:
|
|
513
|
+
self._sides[_OTHER[side]] = value
|
|
514
|
+
self._show(_OTHER[side], value)
|
|
515
|
+
self._say_hint()
|
|
516
|
+
self._hand(value)
|
|
517
|
+
return
|
|
518
|
+
lower, upper = self._sides["lower"], self._sides["upper"]
|
|
519
|
+
if lower is None and upper is None:
|
|
520
|
+
self.linked = True # nothing is one shape, and it is linked
|
|
521
|
+
self._paint()
|
|
522
|
+
self._mark_blanks()
|
|
523
|
+
self._say_hint()
|
|
524
|
+
if lower is None and upper is None:
|
|
525
|
+
self._hand(None)
|
|
526
|
+
elif lower is not None and upper is not None:
|
|
527
|
+
self._hand({"lower": lower, "upper": upper})
|
|
528
|
+
elif self._held != self._at_focus:
|
|
529
|
+
# One side ended up blank, by way of values that were handed
|
|
530
|
+
# over (`0.2` backspaced away is `0.`, `0`, then nothing): the
|
|
531
|
+
# spec held a pair with a zero nobody typed beside a field
|
|
532
|
+
# reading "required". It goes back to what it held when the
|
|
533
|
+
# field took the focus — the whole pair, as it then was.
|
|
534
|
+
self._hand(copy.deepcopy(self._at_focus))
|
|
535
|
+
|
|
536
|
+
def _show(self, side: str, value: float | None) -> None:
|
|
537
|
+
"""Set ``side``'s field without it counting as an edit: a value set
|
|
538
|
+
from the server fires the field's own change handler, and unguarded
|
|
539
|
+
every keystroke reached ``on_change`` twice."""
|
|
540
|
+
self._syncing = True
|
|
541
|
+
try:
|
|
542
|
+
(self.lower if side == "lower" else self.upper).value = value
|
|
543
|
+
finally:
|
|
544
|
+
self._syncing = False
|
|
545
|
+
|
|
546
|
+
def _paint(self) -> None:
|
|
547
|
+
self.link.icon = "link" if self.linked else "link_off"
|
|
548
|
+
self.link.props["aria-pressed"] = "true" if self.linked else "false"
|
|
549
|
+
self.link.props["aria-label"] = LINK_NAMES[self.linked].format(
|
|
550
|
+
label=self._label
|
|
551
|
+
)
|
|
552
|
+
self._tip.text = LINK_TIPS[self.linked]
|
|
553
|
+
|
|
554
|
+
def _say_hint(self) -> None:
|
|
555
|
+
"""The value in effect, under the first field while BOTH are empty:
|
|
556
|
+
with a side typed it named a value no longer applied. Nor while the
|
|
557
|
+
spec holds a value the fields cannot show: that one is set."""
|
|
558
|
+
if self._hint is None:
|
|
559
|
+
return
|
|
560
|
+
empty = self._sides["lower"] is None and self._sides["upper"] is None
|
|
561
|
+
hint = self._hint if empty and not self._unusable else None
|
|
562
|
+
if self.lower.props.get("hint") != hint:
|
|
563
|
+
self.lower.props["hint"] = hint
|
|
564
|
+
self.lower.update()
|
|
565
|
+
|
|
566
|
+
def _mark_blanks(self) -> None:
|
|
567
|
+
for side in (self.lower, self.upper):
|
|
568
|
+
side.validate(return_result=False)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def sides_control(
|
|
572
|
+
label: str,
|
|
573
|
+
value: Any,
|
|
574
|
+
on_change: Callable[[Any], None],
|
|
575
|
+
*,
|
|
576
|
+
side_labels: tuple[str, str],
|
|
577
|
+
hint: str | None = None,
|
|
578
|
+
mark: str | None = None,
|
|
579
|
+
required: bool = False,
|
|
580
|
+
shown: dict[str, float | None] | None = None,
|
|
581
|
+
on_edit: Callable[[], None] | None = None,
|
|
582
|
+
) -> SidesControl:
|
|
583
|
+
"""Two number fields and a link toggle for a tolerance that is a number
|
|
584
|
+
or a ``{"lower", "upper"}`` pair — the leaf form's control and a table
|
|
585
|
+
row's. The fields read ``{side_labels[0]} {label}`` and
|
|
586
|
+
``{side_labels[1]} {label}``; ``mark`` marks them ``{mark}:lower`` and
|
|
587
|
+
``{mark}:upper`` and the toggle ``{mark}:link``.
|
|
588
|
+
|
|
589
|
+
``value`` is what the spec holds, and its shape is the link state:
|
|
590
|
+
``None`` is unset (both fields empty, linked, ``hint`` — the value in
|
|
591
|
+
effect — under the first one while both are empty), a number is linked
|
|
592
|
+
(both fields show it), a pair is unlinked. ``on_change`` gets what the
|
|
593
|
+
spec should hold next:
|
|
594
|
+
|
|
595
|
+
- linked, typing in either field sets both and hands over the number;
|
|
596
|
+
clearing either clears both and hands over ``None``;
|
|
597
|
+
- unlinked, typing hands over the pair; while ONE side is blank no pair
|
|
598
|
+
is handed over and the blank field is marked "required"
|
|
599
|
+
(:meth:`SidesControl.half` says so to a caller holding a stale
|
|
600
|
+
value) — a side that ends up blank after values typed on the way
|
|
601
|
+
were handed over hands back what the control held when its field
|
|
602
|
+
took the focus, so the spec holds no pair nobody meant; both blank
|
|
603
|
+
is ``None``, and the control is linked again;
|
|
604
|
+
- the toggle: unlinking hands over the pair holding the number twice
|
|
605
|
+
(unset stays unset, and unlinked); linking hands over the larger side
|
|
606
|
+
— of those present — for both.
|
|
607
|
+
|
|
608
|
+
``required`` (a table's required column) marks a blank field "required"
|
|
609
|
+
while the whole tolerance is unset too; ``None`` is still handed over,
|
|
610
|
+
and holding it back is the caller's. The fields reserve no bottom line
|
|
611
|
+
of their own: a form's row does (`render_form`), a table's does not.
|
|
612
|
+
|
|
613
|
+
``shown`` and ``on_edit`` are for a caller that rebuilds the control
|
|
614
|
+
and has to keep a HALF pair across that, which ``on_change`` never
|
|
615
|
+
carried: ``on_edit`` is called after every edit of a field and every
|
|
616
|
+
toggle, once ``on_change`` has had what it gets — the caller reads
|
|
617
|
+
:meth:`SidesControl.half` and :meth:`SidesControl.shown` then — and
|
|
618
|
+
``shown``, that pair, is what the rebuilt fields show (one blank and
|
|
619
|
+
marked) while ``value`` is still what the spec holds.
|
|
620
|
+
"""
|
|
621
|
+
return SidesControl(
|
|
622
|
+
label,
|
|
623
|
+
value,
|
|
624
|
+
on_change,
|
|
625
|
+
side_labels=side_labels,
|
|
626
|
+
hint=hint,
|
|
627
|
+
mark=mark,
|
|
628
|
+
required=required,
|
|
629
|
+
shown=shown,
|
|
630
|
+
on_edit=on_edit,
|
|
631
|
+
)
|