dynamic-systems-testing-framework 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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
+ )