labforge 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {labforge-0.2.0 → labforge-0.3.0}/CHANGELOG.md +32 -0
- {labforge-0.2.0 → labforge-0.3.0}/PKG-INFO +13 -4
- {labforge-0.2.0 → labforge-0.3.0}/README.md +12 -3
- labforge-0.3.0/assets/multi_worker.png +0 -0
- labforge-0.3.0/assets/screenshot.png +0 -0
- labforge-0.3.0/assets/theme_glacier.png +0 -0
- labforge-0.3.0/assets/theme_instrument.png +0 -0
- labforge-0.3.0/assets/theme_lavender.png +0 -0
- labforge-0.3.0/assets/theme_mint.png +0 -0
- labforge-0.3.0/assets/theme_neon_gold.png +0 -0
- labforge-0.3.0/assets/theme_neon_violet.png +0 -0
- labforge-0.3.0/assets/theme_paper.png +0 -0
- labforge-0.3.0/assets/theme_retro_green.png +0 -0
- {labforge-0.2.0 → labforge-0.3.0}/examples/demo_lab.py +10 -8
- {labforge-0.2.0 → labforge-0.3.0}/pyproject.toml +1 -1
- {labforge-0.2.0 → labforge-0.3.0}/src/labforge/controls.py +133 -87
- {labforge-0.2.0 → labforge-0.3.0}/src/labforge/pages/analysis.py +4 -3
- {labforge-0.2.0 → labforge-0.3.0}/src/labforge/pages/panel.py +43 -18
- {labforge-0.2.0 → labforge-0.3.0}/src/labforge/pages/scroll.py +2 -4
- {labforge-0.2.0 → labforge-0.3.0}/src/labforge/pages/simulation.py +45 -6
- {labforge-0.2.0 → labforge-0.3.0}/src/labforge/pages/theory.py +6 -2
- {labforge-0.2.0 → labforge-0.3.0}/src/labforge/pages/visualization.py +5 -4
- {labforge-0.2.0 → labforge-0.3.0}/src/labforge/param.py +4 -0
- {labforge-0.2.0 → labforge-0.3.0}/src/labforge/scan.py +8 -1
- {labforge-0.2.0 → labforge-0.3.0}/src/labforge/shell.py +118 -30
- {labforge-0.2.0 → labforge-0.3.0}/src/labforge/state.py +7 -2
- {labforge-0.2.0 → labforge-0.3.0}/src/labforge/ui.py +49 -3
- {labforge-0.2.0 → labforge-0.3.0}/tests/test_scan.py +13 -0
- {labforge-0.2.0 → labforge-0.3.0}/tests/test_smoke.py +7 -7
- labforge-0.2.0/assets/multi_worker.png +0 -0
- labforge-0.2.0/assets/screenshot.png +0 -0
- labforge-0.2.0/assets/theme_glacier.png +0 -0
- labforge-0.2.0/assets/theme_instrument.png +0 -0
- labforge-0.2.0/assets/theme_lavender.png +0 -0
- labforge-0.2.0/assets/theme_mint.png +0 -0
- labforge-0.2.0/assets/theme_neon_gold.png +0 -0
- labforge-0.2.0/assets/theme_neon_violet.png +0 -0
- labforge-0.2.0/assets/theme_paper.png +0 -0
- labforge-0.2.0/assets/theme_retro_green.png +0 -0
- {labforge-0.2.0 → labforge-0.3.0}/.gitignore +0 -0
- {labforge-0.2.0 → labforge-0.3.0}/LICENSE +0 -0
- {labforge-0.2.0 → labforge-0.3.0}/examples/theory.md +0 -0
- {labforge-0.2.0 → labforge-0.3.0}/examples/theory_gamma.md +0 -0
- {labforge-0.2.0 → labforge-0.3.0}/src/labforge/__init__.py +0 -0
- {labforge-0.2.0 → labforge-0.3.0}/src/labforge/assets/fonts/Inter-OFL.txt +0 -0
- {labforge-0.2.0 → labforge-0.3.0}/src/labforge/assets/fonts/Inter.ttf +0 -0
- {labforge-0.2.0 → labforge-0.3.0}/src/labforge/assets/fonts/RobotoMono-OFL.txt +0 -0
- {labforge-0.2.0 → labforge-0.3.0}/src/labforge/assets/fonts/RobotoMono.ttf +0 -0
- {labforge-0.2.0 → labforge-0.3.0}/src/labforge/figures.py +0 -0
- {labforge-0.2.0 → labforge-0.3.0}/src/labforge/lab.py +0 -0
- {labforge-0.2.0 → labforge-0.3.0}/src/labforge/mathtext.py +0 -0
- {labforge-0.2.0 → labforge-0.3.0}/src/labforge/pages/__init__.py +0 -0
- {labforge-0.2.0 → labforge-0.3.0}/src/labforge/registry.py +0 -0
- {labforge-0.2.0 → labforge-0.3.0}/src/labforge/theme.py +0 -0
- {labforge-0.2.0 → labforge-0.3.0}/tests/stubs.py +0 -0
- {labforge-0.2.0 → labforge-0.3.0}/tests/test_lab.py +0 -0
- {labforge-0.2.0 → labforge-0.3.0}/tests/test_param.py +0 -0
- {labforge-0.2.0 → labforge-0.3.0}/tests/test_render.py +0 -0
- {labforge-0.2.0 → labforge-0.3.0}/tests/test_theme.py +0 -0
|
@@ -2,6 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
Notable changes to labforge. Versions follow [semantic versioning](https://semver.org).
|
|
4
4
|
|
|
5
|
+
## 0.3.0 — 2026-07-22
|
|
6
|
+
|
|
7
|
+
- **Wordmark animation** — hovering the app title blows its letters apart and
|
|
8
|
+
keeps them floating until the pointer leaves; they spring back into the
|
|
9
|
+
wordmark on exit.
|
|
10
|
+
- **Motion** — rail navigation cross-fades between pages; a re-rendered figure
|
|
11
|
+
or recomputed result fades over its predecessor instead of flashing; the run
|
|
12
|
+
gate dissolves into the first output; the scan toggle fades between slider
|
|
13
|
+
and comma field; and the RUNNING status pulses gently while a run is in
|
|
14
|
+
flight.
|
|
15
|
+
- **Reading measure** — every page constrains its content to a 720 px column,
|
|
16
|
+
so prose keeps a comfortable line length and control rows stay compact
|
|
17
|
+
instead of stretching across the window. Displayed equations sit centered in
|
|
18
|
+
the measure, and figures scale down to fit it (staying crisp on high-dpi
|
|
19
|
+
screens) instead of clipping at the pane edge.
|
|
20
|
+
- **Uniform control rows** — text fields share the sliders' fixed label column
|
|
21
|
+
and the house field styling (hairline outline, square corners), and a new
|
|
22
|
+
`help` string on `Param` surfaces as a tooltip on the row label.
|
|
23
|
+
- **Parse feedback** — an unreadable text or comma-list entry now flags the
|
|
24
|
+
field ("invalid, kept ...") while the last good value stays live, instead of
|
|
25
|
+
falling back silently.
|
|
26
|
+
- **Error tone** — a worker, viz or analysis exception shows its status line in
|
|
27
|
+
the theme's error colour rather than the muted telemetry tone.
|
|
28
|
+
- **Scan progress** — a running scan reports `RUNNING · k/N` as the grid fills
|
|
29
|
+
in; `run_worker` and `LabState.run` accept an optional `progress(done, total)`
|
|
30
|
+
callback.
|
|
31
|
+
- **Explorable pre-run pages** — the Visualization and Analysis tabs, their
|
|
32
|
+
descriptions and controls now show before the first Run; the NO DATA card
|
|
33
|
+
sits only in the output slot. Tab labels speak the app's monospace voice.
|
|
34
|
+
- **Enter to run** — pressing Enter in any text field fires its section's
|
|
35
|
+
Run/Render/Compute action.
|
|
36
|
+
|
|
5
37
|
## 0.2.0 — 2026-07-22
|
|
6
38
|
|
|
7
39
|
- **`choice` param** — `Param(kind="choice", options=[...])` renders a Dropdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: labforge
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Wrap plain Python functions into a small scientific Flet desktop app: theory, simulation, visualization, analysis.
|
|
5
5
|
Project-URL: Homepage, https://github.com/laroccod/labforge
|
|
6
6
|
Project-URL: Repository, https://github.com/laroccod/labforge
|
|
@@ -69,7 +69,7 @@ def sample(mu=0.0, sigma=1.0, n=2000, seed=42):
|
|
|
69
69
|
|
|
70
70
|
|
|
71
71
|
lab.add_worker(sample, {
|
|
72
|
-
"mu": Param(default=0.0, bounds=(-5, 5), scan=True),
|
|
72
|
+
"mu": Param(default=0.0, bounds=(-5, 5), scan=True, help="Mean of the distribution"),
|
|
73
73
|
"sigma": Param(default=1.0, bounds=(0.1, 4), scan=True),
|
|
74
74
|
"n": Param(kind="int", default=2000, bounds=(10, 100_000)),
|
|
75
75
|
"seed": "int",
|
|
@@ -108,7 +108,9 @@ lab.open()
|
|
|
108
108
|
That is the whole app. `lab.open()` opens a native window with four pages —
|
|
109
109
|
Theory, Simulation, Visualization, Analysis — a slider for every bounded
|
|
110
110
|
parameter, a Run button, and tabs for each registered visualization and
|
|
111
|
-
analysis.
|
|
111
|
+
analysis. Content keeps a book-like reading measure, navigation and result
|
|
112
|
+
updates cross-fade rather than cut, and every page is explorable before the
|
|
113
|
+
first Run.
|
|
112
114
|
|
|
113
115
|
An extended version of this example — same lab plus a fitted-density overlay
|
|
114
116
|
and a Q-Q plot tab — lives at [`examples/demo_lab.py`](examples/demo_lab.py):
|
|
@@ -133,6 +135,12 @@ spec nothing:
|
|
|
133
135
|
| `Param(default=1.0)` / `"scalar"` / `"int"` | validated text field |
|
|
134
136
|
| `Param(..., scan=True)` / `"scalar or array"` / `"int or array"` | scannable (see below) |
|
|
135
137
|
| `"N-tuple"` (e.g. `"2-tuple"`) | one field per element |
|
|
138
|
+
| `Param(kind="choice", options=["a", "b"])` | dropdown |
|
|
139
|
+
|
|
140
|
+
Every `Param` also takes `label=` to rename its control and `help=` for a
|
|
141
|
+
tooltip on the control's label. Pressing Enter in any text field runs the
|
|
142
|
+
section it belongs to, and an entry that fails to parse flags the field while
|
|
143
|
+
the last good value stays live.
|
|
136
144
|
|
|
137
145
|
Validation happens at registration: unknown spec keys, defaults outside
|
|
138
146
|
bounds, or a scan spec on a non-worker function raise `ValueError` when the
|
|
@@ -141,7 +149,8 @@ app is assembled, never mid-use.
|
|
|
141
149
|
**Parameter scans.** A kwarg declared `scan=True` gets a scan toggle (bounded)
|
|
142
150
|
or a comma-separated field (unbounded). Enter `0, 1, 2` and `labforge` calls the
|
|
143
151
|
worker once per point of the cartesian grid across all scanned parameters —
|
|
144
|
-
the worker itself always receives scalars.
|
|
152
|
+
the worker itself always receives scalars. A long scan reports its progress in
|
|
153
|
+
the status line as the grid fills in. Downstream functions then receive a
|
|
145
154
|
`ScanResult`: a list of `(params, result)` records with `keys`, `values()` and
|
|
146
155
|
`axis(name)` helpers, distinguished with `isinstance(data, ScanResult)`.
|
|
147
156
|
|
|
@@ -36,7 +36,7 @@ def sample(mu=0.0, sigma=1.0, n=2000, seed=42):
|
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
lab.add_worker(sample, {
|
|
39
|
-
"mu": Param(default=0.0, bounds=(-5, 5), scan=True),
|
|
39
|
+
"mu": Param(default=0.0, bounds=(-5, 5), scan=True, help="Mean of the distribution"),
|
|
40
40
|
"sigma": Param(default=1.0, bounds=(0.1, 4), scan=True),
|
|
41
41
|
"n": Param(kind="int", default=2000, bounds=(10, 100_000)),
|
|
42
42
|
"seed": "int",
|
|
@@ -75,7 +75,9 @@ lab.open()
|
|
|
75
75
|
That is the whole app. `lab.open()` opens a native window with four pages —
|
|
76
76
|
Theory, Simulation, Visualization, Analysis — a slider for every bounded
|
|
77
77
|
parameter, a Run button, and tabs for each registered visualization and
|
|
78
|
-
analysis.
|
|
78
|
+
analysis. Content keeps a book-like reading measure, navigation and result
|
|
79
|
+
updates cross-fade rather than cut, and every page is explorable before the
|
|
80
|
+
first Run.
|
|
79
81
|
|
|
80
82
|
An extended version of this example — same lab plus a fitted-density overlay
|
|
81
83
|
and a Q-Q plot tab — lives at [`examples/demo_lab.py`](examples/demo_lab.py):
|
|
@@ -100,6 +102,12 @@ spec nothing:
|
|
|
100
102
|
| `Param(default=1.0)` / `"scalar"` / `"int"` | validated text field |
|
|
101
103
|
| `Param(..., scan=True)` / `"scalar or array"` / `"int or array"` | scannable (see below) |
|
|
102
104
|
| `"N-tuple"` (e.g. `"2-tuple"`) | one field per element |
|
|
105
|
+
| `Param(kind="choice", options=["a", "b"])` | dropdown |
|
|
106
|
+
|
|
107
|
+
Every `Param` also takes `label=` to rename its control and `help=` for a
|
|
108
|
+
tooltip on the control's label. Pressing Enter in any text field runs the
|
|
109
|
+
section it belongs to, and an entry that fails to parse flags the field while
|
|
110
|
+
the last good value stays live.
|
|
103
111
|
|
|
104
112
|
Validation happens at registration: unknown spec keys, defaults outside
|
|
105
113
|
bounds, or a scan spec on a non-worker function raise `ValueError` when the
|
|
@@ -108,7 +116,8 @@ app is assembled, never mid-use.
|
|
|
108
116
|
**Parameter scans.** A kwarg declared `scan=True` gets a scan toggle (bounded)
|
|
109
117
|
or a comma-separated field (unbounded). Enter `0, 1, 2` and `labforge` calls the
|
|
110
118
|
worker once per point of the cartesian grid across all scanned parameters —
|
|
111
|
-
the worker itself always receives scalars.
|
|
119
|
+
the worker itself always receives scalars. A long scan reports its progress in
|
|
120
|
+
the status line as the grid fills in. Downstream functions then receive a
|
|
112
121
|
`ScanResult`: a list of `(params, result)` records with `keys`, `values()` and
|
|
113
122
|
`axis(name)` helpers, distinguished with `isinstance(data, ScanResult)`.
|
|
114
123
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -58,10 +58,12 @@ def sample(mu=0.0, sigma=1.0, n=2000, seed=42):
|
|
|
58
58
|
lab.add_worker(
|
|
59
59
|
sample,
|
|
60
60
|
{
|
|
61
|
-
"mu": Param(default=0.0, bounds=(-5, 5), scan=True),
|
|
62
|
-
"sigma": Param(
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
"mu": Param(default=0.0, bounds=(-5, 5), scan=True, help="Mean of the distribution"),
|
|
62
|
+
"sigma": Param(
|
|
63
|
+
default=1.0, bounds=(0.1, 4), scan=True, help="Standard deviation of the distribution"
|
|
64
|
+
),
|
|
65
|
+
"n": Param(kind="int", default=2000, bounds=(10, 100_000), help="Sample size of the draw"),
|
|
66
|
+
"seed": Param(kind="int", help="Seed of the random generator"),
|
|
65
67
|
},
|
|
66
68
|
name="Normal",
|
|
67
69
|
)
|
|
@@ -169,10 +171,10 @@ def sample_gamma(shape=2.0, scale=1.0, n=2000, seed=42):
|
|
|
169
171
|
lab.add_worker(
|
|
170
172
|
sample_gamma,
|
|
171
173
|
{
|
|
172
|
-
"shape": Param(default=2.0, bounds=(0.5, 10), scan=True),
|
|
173
|
-
"scale": Param(default=1.0, bounds=(0.1, 4), scan=True),
|
|
174
|
-
"n": Param(kind="int", default=2000, bounds=(10, 100_000)),
|
|
175
|
-
"seed": "int",
|
|
174
|
+
"shape": Param(default=2.0, bounds=(0.5, 10), scan=True, help="Shape parameter k"),
|
|
175
|
+
"scale": Param(default=1.0, bounds=(0.1, 4), scan=True, help="Scale parameter θ"),
|
|
176
|
+
"n": Param(kind="int", default=2000, bounds=(10, 100_000), help="Sample size of the draw"),
|
|
177
|
+
"seed": Param(kind="int", help="Seed of the random generator"),
|
|
176
178
|
},
|
|
177
179
|
name="Gamma",
|
|
178
180
|
)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "labforge"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Wrap plain Python functions into a small scientific Flet desktop app: theory, simulation, visualization, analysis."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -6,13 +6,17 @@ from dataclasses import dataclass
|
|
|
6
6
|
|
|
7
7
|
import flet as ft
|
|
8
8
|
|
|
9
|
-
from .ui import FONT_MONO
|
|
9
|
+
from .ui import FONT_MONO, fading_pane
|
|
10
10
|
|
|
11
11
|
# Parameter names and values are data, so every label, readout and field in a
|
|
12
12
|
# control row speaks the app's monospace voice.
|
|
13
13
|
MONO = ft.TextStyle(size=13, font_family=FONT_MONO)
|
|
14
14
|
MONO_LABEL = ft.TextStyle(size=12, font_family=FONT_MONO)
|
|
15
15
|
|
|
16
|
+
# Shared width of the leading label cell, so the control rows of a card align
|
|
17
|
+
# into a clean two-column grid.
|
|
18
|
+
LABEL_WIDTH = 120
|
|
19
|
+
|
|
16
20
|
|
|
17
21
|
@dataclass
|
|
18
22
|
class ControlBinding:
|
|
@@ -32,7 +36,7 @@ class ControlBinding:
|
|
|
32
36
|
read: callable
|
|
33
37
|
|
|
34
38
|
|
|
35
|
-
def build_control(name, param, values, page, on_change=None):
|
|
39
|
+
def build_control(name, param, values, page, on_change=None, on_submit=None):
|
|
36
40
|
"""
|
|
37
41
|
Build the control row for one Param.
|
|
38
42
|
|
|
@@ -49,11 +53,15 @@ def build_control(name, param, values, page, on_change=None):
|
|
|
49
53
|
The live values dict (state.worker_values or a per-tab kwargs dict);
|
|
50
54
|
read at build time and committed to on every change.
|
|
51
55
|
page: ft.Page
|
|
52
|
-
Needed to flush live readout
|
|
56
|
+
Needed to flush live readout and parse-error updates.
|
|
53
57
|
on_change: callable
|
|
54
58
|
Optional callback(value) fired after a choice commits — the Theory-page
|
|
55
59
|
selector uses it to rebuild the markdown its selection drives. Ignored
|
|
56
60
|
by every other kind.
|
|
61
|
+
on_submit: callable
|
|
62
|
+
Optional zero-arg callback fired when Enter is pressed in a text
|
|
63
|
+
field; the section builders pass their Run/Render/Compute action so a
|
|
64
|
+
keyboard edit can commit and run in one stroke.
|
|
57
65
|
|
|
58
66
|
Returns
|
|
59
67
|
-------
|
|
@@ -64,17 +72,45 @@ def build_control(name, param, values, page, on_change=None):
|
|
|
64
72
|
if param.kind == "choice":
|
|
65
73
|
return choice_control(name, label, param, value, values, on_change)
|
|
66
74
|
if param.kind == "tuple":
|
|
67
|
-
return tuple_control(name, label, param, value, values)
|
|
75
|
+
return tuple_control(name, label, param, value, values, page, on_submit)
|
|
68
76
|
if param.bounds is None and not param.scan:
|
|
69
|
-
return text_control(name, label, param, value, values)
|
|
77
|
+
return text_control(name, label, param, value, values, page, on_submit)
|
|
70
78
|
if param.bounds is None:
|
|
71
|
-
return scan_control(name, label, param, value, values)
|
|
79
|
+
return scan_control(name, label, param, value, values, page, on_submit)
|
|
72
80
|
if not param.scan:
|
|
73
81
|
return slider_control(name, label, param, value, values, page)
|
|
74
|
-
return toggled_control(name, label, param, value, values, page)
|
|
82
|
+
return toggled_control(name, label, param, value, values, page, on_submit)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def label_cell(label, param):
|
|
86
|
+
"""The fixed-width row label; carries the param's help as a tooltip."""
|
|
87
|
+
return ft.Text(label, width=LABEL_WIDTH, style=MONO, tooltip=param.help)
|
|
75
88
|
|
|
76
89
|
|
|
77
|
-
def
|
|
90
|
+
def label_row(label, param, body):
|
|
91
|
+
"""The uniform control row: a label cell, then the control body."""
|
|
92
|
+
return ft.Row(
|
|
93
|
+
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
|
94
|
+
controls=[label_cell(label, param), body],
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def text_field(value, width, on_submit=None, hint=None):
|
|
99
|
+
"""A text field in the house style: hairline outline, near-square corners."""
|
|
100
|
+
field = ft.TextField(
|
|
101
|
+
value=value,
|
|
102
|
+
width=width,
|
|
103
|
+
text_style=MONO,
|
|
104
|
+
border_color=ft.Colors.OUTLINE_VARIANT,
|
|
105
|
+
border_radius=2,
|
|
106
|
+
hint_text=hint,
|
|
107
|
+
)
|
|
108
|
+
if on_submit is not None:
|
|
109
|
+
field.on_submit = lambda e: on_submit()
|
|
110
|
+
return field
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def slider_control(name, label, param, value, values, page, labelled=True):
|
|
78
114
|
"""A bounded param: a slider with a live readout."""
|
|
79
115
|
lo, hi = param.bounds
|
|
80
116
|
if isinstance(value, list): # scan values left over from a stale spec edit
|
|
@@ -104,73 +140,80 @@ def slider_control(name, label, param, value, values, page):
|
|
|
104
140
|
def read():
|
|
105
141
|
return cast(param, slider.value)
|
|
106
142
|
|
|
107
|
-
|
|
143
|
+
# The bare body is what toggled_control swaps in and out of its holder,
|
|
144
|
+
# which keeps the label cell fixed while the control behind it changes.
|
|
145
|
+
body = ft.Row(
|
|
146
|
+
expand=True,
|
|
147
|
+
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
|
148
|
+
controls=[ft.Container(slider, expand=True), readout],
|
|
149
|
+
)
|
|
150
|
+
row = label_row(label, param, body) if labelled else body
|
|
151
|
+
return ControlBinding(row=row, read=read)
|
|
108
152
|
|
|
109
153
|
|
|
110
|
-
def text_control(name, label, param, value, values):
|
|
154
|
+
def text_control(name, label, param, value, values, page, on_submit):
|
|
111
155
|
"""An unbounded scalar or int: a validated text field."""
|
|
112
|
-
field =
|
|
113
|
-
label=label,
|
|
114
|
-
value=format_scalar(param, value),
|
|
115
|
-
width=160,
|
|
116
|
-
text_style=MONO,
|
|
117
|
-
label_style=MONO_LABEL,
|
|
118
|
-
)
|
|
156
|
+
field = text_field(format_scalar(param, value), 160, on_submit)
|
|
119
157
|
|
|
120
158
|
def read():
|
|
121
|
-
|
|
159
|
+
fallback = values.get(name, param.default)
|
|
160
|
+
parsed, ok = parse_scalar(param, field.value, fallback)
|
|
161
|
+
field.error = None if ok else f"invalid, kept {format_scalar(param, fallback)}"
|
|
122
162
|
values[name] = parsed
|
|
123
163
|
return parsed
|
|
124
164
|
|
|
125
|
-
|
|
126
|
-
|
|
165
|
+
def on_change(e):
|
|
166
|
+
read()
|
|
167
|
+
page.update() # paint or clear the parse-error flag as the user types
|
|
168
|
+
|
|
169
|
+
field.on_change = on_change
|
|
170
|
+
return ControlBinding(row=label_row(label, param, field), read=read)
|
|
127
171
|
|
|
128
172
|
|
|
129
|
-
def scan_control(name, label, param, value, values):
|
|
173
|
+
def scan_control(name, label, param, value, values, page, on_submit):
|
|
130
174
|
"""An unbounded scan param: always a comma-separated values field."""
|
|
131
|
-
field =
|
|
132
|
-
label=f"{label} (comma-separated to scan)",
|
|
133
|
-
value=format_values(param, value),
|
|
134
|
-
width=260,
|
|
135
|
-
text_style=MONO,
|
|
136
|
-
label_style=MONO_LABEL,
|
|
137
|
-
)
|
|
175
|
+
field = text_field(format_values(param, value), 260, on_submit, hint="comma-separated to scan")
|
|
138
176
|
|
|
139
177
|
def read():
|
|
140
|
-
|
|
178
|
+
fallback = values.get(name, param.default)
|
|
179
|
+
parsed, ok = parse_values(param, field.value, fallback)
|
|
180
|
+
field.error = None if ok else f"invalid, kept {format_values(param, fallback)}"
|
|
141
181
|
values[name] = parsed
|
|
142
182
|
return parsed
|
|
143
183
|
|
|
144
|
-
|
|
145
|
-
|
|
184
|
+
def on_change(e):
|
|
185
|
+
read()
|
|
186
|
+
page.update()
|
|
146
187
|
|
|
188
|
+
field.on_change = on_change
|
|
189
|
+
return ControlBinding(row=label_row(label, param, field), read=read)
|
|
147
190
|
|
|
148
|
-
|
|
191
|
+
|
|
192
|
+
def toggled_control(name, label, param, value, values, page, on_submit):
|
|
149
193
|
"""
|
|
150
194
|
A bounded scan param: a slider row with a scan Switch.
|
|
151
195
|
|
|
152
|
-
The switch swaps the content of a mounted
|
|
153
|
-
comma-list field — the same mutate-in-place pattern the pages use for
|
|
154
|
-
result panes
|
|
155
|
-
scan mode.
|
|
196
|
+
The switch swaps the content of a mounted fading pane between the slider
|
|
197
|
+
and a comma-list field — the same mutate-in-place pattern the pages use for
|
|
198
|
+
their result panes, so the mode change fades instead of cutting. Starting
|
|
199
|
+
from a list value (a scan committed earlier) restores scan mode.
|
|
156
200
|
"""
|
|
157
201
|
scanning = isinstance(value, list)
|
|
158
202
|
slider_binding = slider_control(
|
|
159
|
-
name, label, param, value if not scanning else param.default, values, page
|
|
160
|
-
)
|
|
161
|
-
field = ft.TextField(
|
|
162
|
-
label=f"{label} values",
|
|
163
|
-
value=format_values(param, value),
|
|
164
|
-
width=220,
|
|
165
|
-
text_style=MONO,
|
|
166
|
-
label_style=MONO_LABEL,
|
|
203
|
+
name, label, param, value if not scanning else param.default, values, page, labelled=False
|
|
167
204
|
)
|
|
168
|
-
|
|
205
|
+
field = text_field(format_values(param, value), 220, on_submit, hint="comma-separated to scan")
|
|
206
|
+
# The switcher centers a lone child, so the field rides in a left-aligning
|
|
207
|
+
# box to line up with the other rows' controls.
|
|
208
|
+
field_box = ft.Container(content=field, alignment=ft.Alignment.CENTER_LEFT)
|
|
209
|
+
holder = fading_pane(field_box if scanning else slider_binding.row, duration=200, expand=True)
|
|
169
210
|
switch = ft.Switch(label="SCAN", value=scanning, label_position=ft.LabelPosition.RIGHT)
|
|
170
211
|
|
|
171
212
|
def read():
|
|
172
213
|
if switch.value:
|
|
173
|
-
|
|
214
|
+
fallback = values.get(name, param.default)
|
|
215
|
+
parsed, ok = parse_values(param, field.value, fallback)
|
|
216
|
+
field.error = None if ok else f"invalid, kept {format_values(param, fallback)}"
|
|
174
217
|
else:
|
|
175
218
|
parsed = slider_binding.read()
|
|
176
219
|
values[name] = parsed
|
|
@@ -180,41 +223,49 @@ def toggled_control(name, label, param, value, values, page):
|
|
|
180
223
|
if switch.value:
|
|
181
224
|
# Carry the slider's value across, so switching to scan starts there.
|
|
182
225
|
field.value = format_values(param, slider_binding.read())
|
|
183
|
-
holder.content =
|
|
226
|
+
holder.content = field_box
|
|
184
227
|
else:
|
|
185
228
|
holder.content = slider_binding.row
|
|
186
229
|
read()
|
|
187
230
|
page.update()
|
|
188
231
|
|
|
232
|
+
def on_field_change(e):
|
|
233
|
+
read()
|
|
234
|
+
page.update()
|
|
235
|
+
|
|
189
236
|
switch.on_change = on_toggle
|
|
190
|
-
field.on_change =
|
|
191
|
-
row = ft.Row(
|
|
237
|
+
field.on_change = on_field_change
|
|
238
|
+
row = ft.Row(
|
|
239
|
+
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
|
240
|
+
controls=[label_cell(label, param), holder, switch],
|
|
241
|
+
)
|
|
192
242
|
return ControlBinding(row=row, read=read)
|
|
193
243
|
|
|
194
244
|
|
|
195
|
-
def tuple_control(name, label, param, value, values):
|
|
245
|
+
def tuple_control(name, label, param, value, values, page, on_submit):
|
|
196
246
|
"""A fixed-size tuple: one small text field per element."""
|
|
197
|
-
fields = [
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
]
|
|
247
|
+
fields = [text_field(f"{element:g}", 90, on_submit) for element in tuple(value)]
|
|
248
|
+
for field in fields:
|
|
249
|
+
field.text_align = ft.TextAlign.RIGHT
|
|
201
250
|
|
|
202
251
|
def read():
|
|
203
252
|
previous = tuple(values.get(name, param.default))
|
|
204
|
-
parsed =
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
253
|
+
parsed = []
|
|
254
|
+
for field, element in zip(fields, previous):
|
|
255
|
+
element_value, ok = parse_scalar(param, field.value, element)
|
|
256
|
+
field.error = None if ok else f"kept {element:g}"
|
|
257
|
+
parsed.append(element_value)
|
|
258
|
+
values[name] = tuple(parsed)
|
|
259
|
+
return values[name]
|
|
260
|
+
|
|
261
|
+
def on_change(e):
|
|
262
|
+
read()
|
|
263
|
+
page.update()
|
|
210
264
|
|
|
211
265
|
for field in fields:
|
|
212
|
-
field.on_change =
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
controls=[ft.Text(label, width=120, style=MONO), *fields],
|
|
216
|
-
)
|
|
217
|
-
return ControlBinding(row=row, read=read)
|
|
266
|
+
field.on_change = on_change
|
|
267
|
+
body = ft.Row(vertical_alignment=ft.CrossAxisAlignment.CENTER, controls=fields)
|
|
268
|
+
return ControlBinding(row=label_row(label, param, body), read=read)
|
|
218
269
|
|
|
219
270
|
|
|
220
271
|
def choice_control(name, label, param, value, values, on_change):
|
|
@@ -237,6 +288,7 @@ def choice_control(name, label, param, value, values, on_change):
|
|
|
237
288
|
border_color=ft.Colors.OUTLINE_VARIANT,
|
|
238
289
|
border_radius=2,
|
|
239
290
|
width=260,
|
|
291
|
+
tooltip=param.help,
|
|
240
292
|
)
|
|
241
293
|
|
|
242
294
|
def read():
|
|
@@ -252,18 +304,6 @@ def choice_control(name, label, param, value, values, on_change):
|
|
|
252
304
|
return ControlBinding(row=dropdown, read=read)
|
|
253
305
|
|
|
254
306
|
|
|
255
|
-
def slider_row(label, slider, readout):
|
|
256
|
-
"""A labelled slider with its live readout to the right."""
|
|
257
|
-
return ft.Row(
|
|
258
|
-
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
|
259
|
-
controls=[
|
|
260
|
-
ft.Text(label, width=120, style=MONO),
|
|
261
|
-
ft.Container(slider, expand=True),
|
|
262
|
-
readout,
|
|
263
|
-
],
|
|
264
|
-
)
|
|
265
|
-
|
|
266
|
-
|
|
267
307
|
def cast(param, value):
|
|
268
308
|
"""
|
|
269
309
|
Cast a slider value or one token of user text to the param's kind, rounding
|
|
@@ -291,19 +331,25 @@ def format_values(param, value):
|
|
|
291
331
|
|
|
292
332
|
|
|
293
333
|
def parse_scalar(param, text, fallback):
|
|
294
|
-
"""
|
|
334
|
+
"""
|
|
335
|
+
Parse one value of the param's kind into (value, ok).
|
|
336
|
+
|
|
337
|
+
Bad input keeps the fallback with ok False, so the caller can hold the last
|
|
338
|
+
good value live while flagging the field.
|
|
339
|
+
"""
|
|
295
340
|
try:
|
|
296
|
-
return cast(param, text)
|
|
341
|
+
return cast(param, text), True
|
|
297
342
|
except (TypeError, ValueError):
|
|
298
|
-
return fallback
|
|
343
|
+
return fallback, False
|
|
299
344
|
|
|
300
345
|
|
|
301
346
|
def parse_values(param, text, fallback):
|
|
302
347
|
"""
|
|
303
|
-
Parse a comma-separated scan entry.
|
|
348
|
+
Parse a comma-separated scan entry into (value, ok).
|
|
304
349
|
|
|
305
|
-
A single valid value
|
|
306
|
-
several
|
|
350
|
+
A single valid value parses to a scalar (a one-point scan is just a run)
|
|
351
|
+
and several to a list; any bad token, or no token at all, keeps the
|
|
352
|
+
fallback with ok False.
|
|
307
353
|
"""
|
|
308
354
|
parsed = []
|
|
309
355
|
for token in str(text).split(","):
|
|
@@ -313,7 +359,7 @@ def parse_values(param, text, fallback):
|
|
|
313
359
|
try:
|
|
314
360
|
parsed.append(cast(param, token))
|
|
315
361
|
except ValueError:
|
|
316
|
-
return fallback
|
|
362
|
+
return fallback, False
|
|
317
363
|
if not parsed:
|
|
318
|
-
return fallback
|
|
319
|
-
return parsed[0] if len(parsed) == 1 else parsed
|
|
364
|
+
return fallback, False
|
|
365
|
+
return (parsed[0] if len(parsed) == 1 else parsed), True
|
|
@@ -16,10 +16,11 @@ def section_controls(entry, state, page):
|
|
|
16
16
|
One analysis's description, kwarg controls, Compute action and result pane,
|
|
17
17
|
as a list of controls. Shared by the tabbed page and the scroll layout.
|
|
18
18
|
"""
|
|
19
|
-
pane =
|
|
19
|
+
pane = ui.fading_pane()
|
|
20
20
|
|
|
21
21
|
def show(value):
|
|
22
|
-
# Swap only the content: the mounted
|
|
22
|
+
# Swap only the content: the mounted pane stays, so the tab survives,
|
|
23
|
+
# and each Compute cross-fades the new result over the old.
|
|
23
24
|
pane.content = render_result(value)
|
|
24
25
|
|
|
25
26
|
return panel.section_controls(
|
|
@@ -60,7 +61,7 @@ def render_result(value):
|
|
|
60
61
|
if isinstance(value, str):
|
|
61
62
|
return ui.body(value)
|
|
62
63
|
if is_figure(value):
|
|
63
|
-
return ui.
|
|
64
|
+
return ui.fitted_image(figure_to_base64(unpack_figure(value)))
|
|
64
65
|
return ui.body(f"`{value!r}`")
|
|
65
66
|
|
|
66
67
|
|