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.
Files changed (59) hide show
  1. {labforge-0.2.0 → labforge-0.3.0}/CHANGELOG.md +32 -0
  2. {labforge-0.2.0 → labforge-0.3.0}/PKG-INFO +13 -4
  3. {labforge-0.2.0 → labforge-0.3.0}/README.md +12 -3
  4. labforge-0.3.0/assets/multi_worker.png +0 -0
  5. labforge-0.3.0/assets/screenshot.png +0 -0
  6. labforge-0.3.0/assets/theme_glacier.png +0 -0
  7. labforge-0.3.0/assets/theme_instrument.png +0 -0
  8. labforge-0.3.0/assets/theme_lavender.png +0 -0
  9. labforge-0.3.0/assets/theme_mint.png +0 -0
  10. labforge-0.3.0/assets/theme_neon_gold.png +0 -0
  11. labforge-0.3.0/assets/theme_neon_violet.png +0 -0
  12. labforge-0.3.0/assets/theme_paper.png +0 -0
  13. labforge-0.3.0/assets/theme_retro_green.png +0 -0
  14. {labforge-0.2.0 → labforge-0.3.0}/examples/demo_lab.py +10 -8
  15. {labforge-0.2.0 → labforge-0.3.0}/pyproject.toml +1 -1
  16. {labforge-0.2.0 → labforge-0.3.0}/src/labforge/controls.py +133 -87
  17. {labforge-0.2.0 → labforge-0.3.0}/src/labforge/pages/analysis.py +4 -3
  18. {labforge-0.2.0 → labforge-0.3.0}/src/labforge/pages/panel.py +43 -18
  19. {labforge-0.2.0 → labforge-0.3.0}/src/labforge/pages/scroll.py +2 -4
  20. {labforge-0.2.0 → labforge-0.3.0}/src/labforge/pages/simulation.py +45 -6
  21. {labforge-0.2.0 → labforge-0.3.0}/src/labforge/pages/theory.py +6 -2
  22. {labforge-0.2.0 → labforge-0.3.0}/src/labforge/pages/visualization.py +5 -4
  23. {labforge-0.2.0 → labforge-0.3.0}/src/labforge/param.py +4 -0
  24. {labforge-0.2.0 → labforge-0.3.0}/src/labforge/scan.py +8 -1
  25. {labforge-0.2.0 → labforge-0.3.0}/src/labforge/shell.py +118 -30
  26. {labforge-0.2.0 → labforge-0.3.0}/src/labforge/state.py +7 -2
  27. {labforge-0.2.0 → labforge-0.3.0}/src/labforge/ui.py +49 -3
  28. {labforge-0.2.0 → labforge-0.3.0}/tests/test_scan.py +13 -0
  29. {labforge-0.2.0 → labforge-0.3.0}/tests/test_smoke.py +7 -7
  30. labforge-0.2.0/assets/multi_worker.png +0 -0
  31. labforge-0.2.0/assets/screenshot.png +0 -0
  32. labforge-0.2.0/assets/theme_glacier.png +0 -0
  33. labforge-0.2.0/assets/theme_instrument.png +0 -0
  34. labforge-0.2.0/assets/theme_lavender.png +0 -0
  35. labforge-0.2.0/assets/theme_mint.png +0 -0
  36. labforge-0.2.0/assets/theme_neon_gold.png +0 -0
  37. labforge-0.2.0/assets/theme_neon_violet.png +0 -0
  38. labforge-0.2.0/assets/theme_paper.png +0 -0
  39. labforge-0.2.0/assets/theme_retro_green.png +0 -0
  40. {labforge-0.2.0 → labforge-0.3.0}/.gitignore +0 -0
  41. {labforge-0.2.0 → labforge-0.3.0}/LICENSE +0 -0
  42. {labforge-0.2.0 → labforge-0.3.0}/examples/theory.md +0 -0
  43. {labforge-0.2.0 → labforge-0.3.0}/examples/theory_gamma.md +0 -0
  44. {labforge-0.2.0 → labforge-0.3.0}/src/labforge/__init__.py +0 -0
  45. {labforge-0.2.0 → labforge-0.3.0}/src/labforge/assets/fonts/Inter-OFL.txt +0 -0
  46. {labforge-0.2.0 → labforge-0.3.0}/src/labforge/assets/fonts/Inter.ttf +0 -0
  47. {labforge-0.2.0 → labforge-0.3.0}/src/labforge/assets/fonts/RobotoMono-OFL.txt +0 -0
  48. {labforge-0.2.0 → labforge-0.3.0}/src/labforge/assets/fonts/RobotoMono.ttf +0 -0
  49. {labforge-0.2.0 → labforge-0.3.0}/src/labforge/figures.py +0 -0
  50. {labforge-0.2.0 → labforge-0.3.0}/src/labforge/lab.py +0 -0
  51. {labforge-0.2.0 → labforge-0.3.0}/src/labforge/mathtext.py +0 -0
  52. {labforge-0.2.0 → labforge-0.3.0}/src/labforge/pages/__init__.py +0 -0
  53. {labforge-0.2.0 → labforge-0.3.0}/src/labforge/registry.py +0 -0
  54. {labforge-0.2.0 → labforge-0.3.0}/src/labforge/theme.py +0 -0
  55. {labforge-0.2.0 → labforge-0.3.0}/tests/stubs.py +0 -0
  56. {labforge-0.2.0 → labforge-0.3.0}/tests/test_lab.py +0 -0
  57. {labforge-0.2.0 → labforge-0.3.0}/tests/test_param.py +0 -0
  58. {labforge-0.2.0 → labforge-0.3.0}/tests/test_render.py +0 -0
  59. {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.2.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. Downstream functions then receive a
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. Downstream functions then receive a
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
@@ -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(default=1.0, bounds=(0.1, 4), scan=True),
63
- "n": Param(kind="int", default=2000, bounds=(10, 100_000)),
64
- "seed": "int",
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.2.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 updates during a slider drag.
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 slider_control(name, label, param, value, values, page):
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
- return ControlBinding(row=slider_row(label, slider, readout), read=read)
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 = ft.TextField(
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
- parsed = parse_scalar(param, field.value, fallback=values.get(name, param.default))
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
- field.on_change = lambda e: read()
126
- return ControlBinding(row=field, read=read)
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 = ft.TextField(
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
- parsed = parse_values(param, field.value, fallback=values.get(name, param.default))
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
- field.on_change = lambda e: read()
145
- return ControlBinding(row=field, read=read)
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
- def toggled_control(name, label, param, value, values, page):
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 Container between the slider and a
153
- comma-list field — the same mutate-in-place pattern the pages use for their
154
- result panes. Starting from a list value (a scan committed earlier) restores
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
- holder = ft.Container(content=field if scanning else slider_binding.row, expand=True)
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
- parsed = parse_values(param, field.value, fallback=values.get(name, param.default))
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 = field
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 = lambda e: read()
191
- row = ft.Row(vertical_alignment=ft.CrossAxisAlignment.CENTER, controls=[holder, switch])
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
- ft.TextField(value=f"{element:g}", width=90, text_align=ft.TextAlign.RIGHT, text_style=MONO)
199
- for element in tuple(value)
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 = tuple(
205
- parse_scalar(param, field.value, fallback=element)
206
- for field, element in zip(fields, previous)
207
- )
208
- values[name] = parsed
209
- return parsed
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 = lambda e: read()
213
- row = ft.Row(
214
- vertical_alignment=ft.CrossAxisAlignment.CENTER,
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
- """Parse one value of the param's kind, keeping fallback on bad input."""
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 returns a scalar (a one-point scan is just a run);
306
- several return a list; no valid values return the fallback.
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 = ft.Container()
19
+ pane = ui.fading_pane()
20
20
 
21
21
  def show(value):
22
- # Swap only the content: the mounted Container stays, so the tab survives.
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.image(figure_to_base64(unpack_figure(value)))
64
+ return ui.fitted_image(figure_to_base64(unpack_figure(value)))
64
65
  return ui.body(f"`{value!r}`")
65
66
 
66
67