labforge 0.1.0__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.
- labforge/__init__.py +17 -0
- labforge/controls.py +278 -0
- labforge/figures.py +92 -0
- labforge/lab.py +165 -0
- labforge/mathtext.py +140 -0
- labforge/pages/__init__.py +6 -0
- labforge/pages/analysis.py +99 -0
- labforge/pages/panel.py +109 -0
- labforge/pages/scroll.py +61 -0
- labforge/pages/simulation.py +71 -0
- labforge/pages/theory.py +40 -0
- labforge/pages/visualization.py +30 -0
- labforge/param.py +186 -0
- labforge/registry.py +36 -0
- labforge/scan.py +75 -0
- labforge/shell.py +220 -0
- labforge/state.py +74 -0
- labforge/theme.py +323 -0
- labforge/ui.py +167 -0
- labforge-0.1.0.dist-info/METADATA +232 -0
- labforge-0.1.0.dist-info/RECORD +23 -0
- labforge-0.1.0.dist-info/WHEEL +4 -0
- labforge-0.1.0.dist-info/licenses/LICENSE +21 -0
labforge/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
labforge: wrap plain Python functions into a small scientific desktop app,
|
|
3
|
+
following the theory -> simulation -> visualization -> analysis workflow.
|
|
4
|
+
|
|
5
|
+
Lab collects the registrations and launches the app; Param specifies a kwarg's
|
|
6
|
+
control; ScanResult is what a parameter scan hands to viz and analysis
|
|
7
|
+
functions; style is the optional house figure treatment; palette exposes the
|
|
8
|
+
active theme's colours; themes lists what open(theme=...) accepts.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .figures import palette, style
|
|
12
|
+
from .lab import Lab
|
|
13
|
+
from .param import Param
|
|
14
|
+
from .scan import ScanResult
|
|
15
|
+
from .theme import names as themes
|
|
16
|
+
|
|
17
|
+
__all__ = ["Lab", "Param", "ScanResult", "style", "palette", "themes"]
|
labforge/controls.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Param -> a Flet control row plus a read() closure for its current value.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import flet as ft
|
|
8
|
+
|
|
9
|
+
from .ui import FONT_MONO
|
|
10
|
+
|
|
11
|
+
# Parameter names and values are data, so every label, readout and field in a
|
|
12
|
+
# control row speaks the app's monospace voice.
|
|
13
|
+
MONO = ft.TextStyle(size=13, font_family=FONT_MONO)
|
|
14
|
+
MONO_LABEL = ft.TextStyle(size=12, font_family=FONT_MONO)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ControlBinding:
|
|
19
|
+
"""
|
|
20
|
+
One built control row and its value accessor.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
row: Control
|
|
25
|
+
The labelled row to place in the page tree.
|
|
26
|
+
read: callable
|
|
27
|
+
Zero-arg closure returning the current parsed value (scalar, tuple,
|
|
28
|
+
or list of scan values).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
row: object
|
|
32
|
+
read: callable
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_control(name, param, values, page):
|
|
36
|
+
"""
|
|
37
|
+
Build the control row for one Param.
|
|
38
|
+
|
|
39
|
+
Every control commits to values as it changes, so edits survive navigation
|
|
40
|
+
even before the next Run; read() is also what Run, Render and Compute pull.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
name: str
|
|
45
|
+
The kwarg name; keys the values dict and labels the row by default.
|
|
46
|
+
param: Param
|
|
47
|
+
The normalized spec (default resolved, step filled for bounded kinds).
|
|
48
|
+
values: dict
|
|
49
|
+
The live values dict (state.worker_values or a per-tab kwargs dict);
|
|
50
|
+
read at build time and committed to on every change.
|
|
51
|
+
page: ft.Page
|
|
52
|
+
Needed to flush live readout updates during a slider drag.
|
|
53
|
+
|
|
54
|
+
Returns
|
|
55
|
+
-------
|
|
56
|
+
ControlBinding for the row.
|
|
57
|
+
"""
|
|
58
|
+
label = param.label or name
|
|
59
|
+
value = values.get(name, param.default)
|
|
60
|
+
if param.kind == "tuple":
|
|
61
|
+
return tuple_control(name, label, param, value, values)
|
|
62
|
+
if param.bounds is None and not param.scan:
|
|
63
|
+
return text_control(name, label, param, value, values)
|
|
64
|
+
if param.bounds is None:
|
|
65
|
+
return scan_control(name, label, param, value, values)
|
|
66
|
+
if not param.scan:
|
|
67
|
+
return slider_control(name, label, param, value, values, page)
|
|
68
|
+
return toggled_control(name, label, param, value, values, page)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def slider_control(name, label, param, value, values, page):
|
|
72
|
+
"""A bounded param: a slider with a live readout."""
|
|
73
|
+
lo, hi = param.bounds
|
|
74
|
+
if isinstance(value, list): # scan values left over from a stale spec edit
|
|
75
|
+
value = param.default
|
|
76
|
+
# Built here and returned inside the tree, so it is mounted before any drag
|
|
77
|
+
# fires; on_change mutates it and flushes via page.update(), never
|
|
78
|
+
# readout.update(), which Flet 0.86 drops.
|
|
79
|
+
readout = ft.Text(format_scalar(param, value), width=64, style=MONO)
|
|
80
|
+
slider = ft.Slider(
|
|
81
|
+
min=lo,
|
|
82
|
+
max=hi,
|
|
83
|
+
divisions=max(1, round((hi - lo) / param.step)),
|
|
84
|
+
value=value,
|
|
85
|
+
label="{value}",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def on_change(e):
|
|
89
|
+
readout.value = format_scalar(param, cast(param, e.control.value))
|
|
90
|
+
page.update()
|
|
91
|
+
|
|
92
|
+
def on_commit(e):
|
|
93
|
+
values[name] = cast(param, e.control.value)
|
|
94
|
+
|
|
95
|
+
slider.on_change = on_change
|
|
96
|
+
slider.on_change_end = on_commit # commit on release, not on every frame
|
|
97
|
+
|
|
98
|
+
def read():
|
|
99
|
+
return cast(param, slider.value)
|
|
100
|
+
|
|
101
|
+
return ControlBinding(row=slider_row(label, slider, readout), read=read)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def text_control(name, label, param, value, values):
|
|
105
|
+
"""An unbounded scalar or int: a validated text field."""
|
|
106
|
+
field = ft.TextField(
|
|
107
|
+
label=label,
|
|
108
|
+
value=format_scalar(param, value),
|
|
109
|
+
width=160,
|
|
110
|
+
text_style=MONO,
|
|
111
|
+
label_style=MONO_LABEL,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def read():
|
|
115
|
+
parsed = parse_scalar(param, field.value, fallback=values.get(name, param.default))
|
|
116
|
+
values[name] = parsed
|
|
117
|
+
return parsed
|
|
118
|
+
|
|
119
|
+
field.on_change = lambda e: read()
|
|
120
|
+
return ControlBinding(row=field, read=read)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def scan_control(name, label, param, value, values):
|
|
124
|
+
"""An unbounded scan param: always a comma-separated values field."""
|
|
125
|
+
field = ft.TextField(
|
|
126
|
+
label=f"{label} (comma-separated to scan)",
|
|
127
|
+
value=format_values(param, value),
|
|
128
|
+
width=260,
|
|
129
|
+
text_style=MONO,
|
|
130
|
+
label_style=MONO_LABEL,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def read():
|
|
134
|
+
parsed = parse_values(param, field.value, fallback=values.get(name, param.default))
|
|
135
|
+
values[name] = parsed
|
|
136
|
+
return parsed
|
|
137
|
+
|
|
138
|
+
field.on_change = lambda e: read()
|
|
139
|
+
return ControlBinding(row=field, read=read)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def toggled_control(name, label, param, value, values, page):
|
|
143
|
+
"""
|
|
144
|
+
A bounded scan param: a slider row with a scan Switch.
|
|
145
|
+
|
|
146
|
+
The switch swaps the content of a mounted Container between the slider and a
|
|
147
|
+
comma-list field — the same mutate-in-place pattern the pages use for their
|
|
148
|
+
result panes. Starting from a list value (a scan committed earlier) restores
|
|
149
|
+
scan mode.
|
|
150
|
+
"""
|
|
151
|
+
scanning = isinstance(value, list)
|
|
152
|
+
slider_binding = slider_control(
|
|
153
|
+
name, label, param, value if not scanning else param.default, values, page
|
|
154
|
+
)
|
|
155
|
+
field = ft.TextField(
|
|
156
|
+
label=f"{label} values",
|
|
157
|
+
value=format_values(param, value),
|
|
158
|
+
width=220,
|
|
159
|
+
text_style=MONO,
|
|
160
|
+
label_style=MONO_LABEL,
|
|
161
|
+
)
|
|
162
|
+
holder = ft.Container(content=field if scanning else slider_binding.row, expand=True)
|
|
163
|
+
switch = ft.Switch(label="SCAN", value=scanning, label_position=ft.LabelPosition.RIGHT)
|
|
164
|
+
|
|
165
|
+
def read():
|
|
166
|
+
if switch.value:
|
|
167
|
+
parsed = parse_values(param, field.value, fallback=values.get(name, param.default))
|
|
168
|
+
else:
|
|
169
|
+
parsed = slider_binding.read()
|
|
170
|
+
values[name] = parsed
|
|
171
|
+
return parsed
|
|
172
|
+
|
|
173
|
+
def on_toggle(e):
|
|
174
|
+
if switch.value:
|
|
175
|
+
# Carry the slider's value across, so switching to scan starts there.
|
|
176
|
+
field.value = format_values(param, slider_binding.read())
|
|
177
|
+
holder.content = field
|
|
178
|
+
else:
|
|
179
|
+
holder.content = slider_binding.row
|
|
180
|
+
read()
|
|
181
|
+
page.update()
|
|
182
|
+
|
|
183
|
+
switch.on_change = on_toggle
|
|
184
|
+
field.on_change = lambda e: read()
|
|
185
|
+
row = ft.Row(vertical_alignment=ft.CrossAxisAlignment.CENTER, controls=[holder, switch])
|
|
186
|
+
return ControlBinding(row=row, read=read)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def tuple_control(name, label, param, value, values):
|
|
190
|
+
"""A fixed-size tuple: one small text field per element."""
|
|
191
|
+
fields = [
|
|
192
|
+
ft.TextField(value=f"{element:g}", width=90, text_align=ft.TextAlign.RIGHT, text_style=MONO)
|
|
193
|
+
for element in tuple(value)
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
def read():
|
|
197
|
+
previous = tuple(values.get(name, param.default))
|
|
198
|
+
parsed = tuple(
|
|
199
|
+
parse_scalar(param, field.value, fallback=element)
|
|
200
|
+
for field, element in zip(fields, previous)
|
|
201
|
+
)
|
|
202
|
+
values[name] = parsed
|
|
203
|
+
return parsed
|
|
204
|
+
|
|
205
|
+
for field in fields:
|
|
206
|
+
field.on_change = lambda e: read()
|
|
207
|
+
row = ft.Row(
|
|
208
|
+
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
|
209
|
+
controls=[ft.Text(label, width=120, style=MONO), *fields],
|
|
210
|
+
)
|
|
211
|
+
return ControlBinding(row=row, read=read)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def slider_row(label, slider, readout):
|
|
215
|
+
"""A labelled slider with its live readout to the right."""
|
|
216
|
+
return ft.Row(
|
|
217
|
+
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
|
218
|
+
controls=[
|
|
219
|
+
ft.Text(label, width=120, style=MONO),
|
|
220
|
+
ft.Container(slider, expand=True),
|
|
221
|
+
readout,
|
|
222
|
+
],
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def cast(param, value):
|
|
227
|
+
"""
|
|
228
|
+
Cast a slider value or one token of user text to the param's kind, rounding
|
|
229
|
+
to the nearest int; raises on unparseable text.
|
|
230
|
+
|
|
231
|
+
A tuple param's elements are floats, so they take the non-int branch.
|
|
232
|
+
"""
|
|
233
|
+
return int(round(float(value))) if param.kind == "int" else float(value)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def format_scalar(param, value):
|
|
237
|
+
"""Display form of one value: thousands-grouped ints, compact floats."""
|
|
238
|
+
return f"{int(value):,}" if param.kind == "int" else f"{float(value):g}"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def format_values(param, value):
|
|
242
|
+
"""
|
|
243
|
+
Display form of a committed value or scan list for a comma field.
|
|
244
|
+
|
|
245
|
+
Ints are ungrouped here, unlike format_scalar: a thousands separator inside
|
|
246
|
+
a comma-separated list would parse back as two values.
|
|
247
|
+
"""
|
|
248
|
+
entries = value if isinstance(value, list) else [value]
|
|
249
|
+
return ", ".join(f"{int(v):d}" if param.kind == "int" else f"{float(v):g}" for v in entries)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def parse_scalar(param, text, fallback):
|
|
253
|
+
"""Parse one value of the param's kind, keeping fallback on bad input."""
|
|
254
|
+
try:
|
|
255
|
+
return cast(param, text)
|
|
256
|
+
except (TypeError, ValueError):
|
|
257
|
+
return fallback
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def parse_values(param, text, fallback):
|
|
261
|
+
"""
|
|
262
|
+
Parse a comma-separated scan entry.
|
|
263
|
+
|
|
264
|
+
A single valid value returns a scalar (a one-point scan is just a run);
|
|
265
|
+
several return a list; no valid values return the fallback.
|
|
266
|
+
"""
|
|
267
|
+
parsed = []
|
|
268
|
+
for token in str(text).split(","):
|
|
269
|
+
token = token.strip()
|
|
270
|
+
if not token:
|
|
271
|
+
continue
|
|
272
|
+
try:
|
|
273
|
+
parsed.append(cast(param, token))
|
|
274
|
+
except ValueError:
|
|
275
|
+
return fallback
|
|
276
|
+
if not parsed:
|
|
277
|
+
return fallback
|
|
278
|
+
return parsed[0] if len(parsed) == 1 else parsed
|
labforge/figures.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Serialize the author's matplotlib figures to base64 PNGs, plus an optional
|
|
3
|
+
house style.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import io
|
|
8
|
+
|
|
9
|
+
import matplotlib
|
|
10
|
+
|
|
11
|
+
matplotlib.use("Agg") # render to a buffer; no GUI backend on the UI thread
|
|
12
|
+
import matplotlib.pyplot as plt
|
|
13
|
+
from matplotlib.figure import Figure
|
|
14
|
+
|
|
15
|
+
from . import theme
|
|
16
|
+
|
|
17
|
+
RENDER_DPI = 150
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def palette():
|
|
21
|
+
"""
|
|
22
|
+
The active Theme, for an author colouring their own figure; its data /
|
|
23
|
+
model / highlight / ink / grid fields are the plot palette. Read at call
|
|
24
|
+
time rather than bound at import, so a figure drawn with palette().data
|
|
25
|
+
follows whichever theme open(theme=...) selected.
|
|
26
|
+
"""
|
|
27
|
+
return theme.active()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_figure(value):
|
|
31
|
+
"""True for a matplotlib Figure or a (fig, ...) tuple, the two viz returns."""
|
|
32
|
+
if isinstance(value, tuple):
|
|
33
|
+
return bool(value) and isinstance(value[0], Figure)
|
|
34
|
+
return isinstance(value, Figure)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def unpack_figure(value):
|
|
38
|
+
"""Extract the Figure from a viz return: a bare Figure or a (fig, ...) tuple."""
|
|
39
|
+
if not is_figure(value):
|
|
40
|
+
raise TypeError(
|
|
41
|
+
f"Expected a matplotlib Figure or a (fig, ax) tuple, got {type(value).__name__}."
|
|
42
|
+
)
|
|
43
|
+
return value[0] if isinstance(value, tuple) else value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def figure_to_base64(fig):
|
|
47
|
+
"""
|
|
48
|
+
Serialize a matplotlib figure to a base64 PNG and close it, so repeated
|
|
49
|
+
renders never leak pyplot state.
|
|
50
|
+
"""
|
|
51
|
+
buffer = io.BytesIO()
|
|
52
|
+
# transparent=True clears the figure and axes patches so the app surface
|
|
53
|
+
# shows through; the pale ink still reads on the dark theme.
|
|
54
|
+
fig.savefig(buffer, format="png", dpi=RENDER_DPI, bbox_inches="tight", transparent=True)
|
|
55
|
+
plt.close(fig)
|
|
56
|
+
return base64.b64encode(buffer.getvalue()).decode("ascii")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def style(fig, ax):
|
|
60
|
+
"""
|
|
61
|
+
Apply the optional house figure treatment in place.
|
|
62
|
+
|
|
63
|
+
Open top/right spines, a light dotted grid behind the data, muted ink and
|
|
64
|
+
compact type, and a frameless legend — so an opted-in figure reads as part
|
|
65
|
+
of the app rather than a stock matplotlib window.
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
fig: Figure
|
|
70
|
+
The figure (accepted for signature symmetry with viz returns).
|
|
71
|
+
ax: Axes
|
|
72
|
+
The axes to restyle, already populated by the caller.
|
|
73
|
+
"""
|
|
74
|
+
colors = theme.active()
|
|
75
|
+
for side in ("top", "right"):
|
|
76
|
+
ax.spines[side].set_visible(False)
|
|
77
|
+
for side in ("left", "bottom"):
|
|
78
|
+
ax.spines[side].set_color(colors.grid)
|
|
79
|
+
ax.grid(True, axis="y", color=colors.grid, alpha=0.35, linewidth=0.6, linestyle=":")
|
|
80
|
+
ax.set_axisbelow(True)
|
|
81
|
+
ax.tick_params(colors=colors.ink, labelsize=9)
|
|
82
|
+
ax.title.set_color(colors.ink)
|
|
83
|
+
ax.title.set_fontsize(11)
|
|
84
|
+
for label in (ax.xaxis.label, ax.yaxis.label):
|
|
85
|
+
label.set_color(colors.ink)
|
|
86
|
+
label.set_fontsize(10)
|
|
87
|
+
legend = ax.get_legend()
|
|
88
|
+
if legend is not None:
|
|
89
|
+
legend.set_frame_on(False)
|
|
90
|
+
for text in legend.get_texts():
|
|
91
|
+
text.set_color(colors.ink)
|
|
92
|
+
text.set_fontsize(9)
|
labforge/lab.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lab: register a worker, visualizations, analyses and theory, then open the app.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from .param import normalize_spec
|
|
8
|
+
from .registry import AnalysisSpec, VizSpec, WorkerSpec
|
|
9
|
+
from .theme import DEFAULT as DEFAULT_THEME
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Lab:
|
|
13
|
+
"""
|
|
14
|
+
A four-page scientific desktop app assembled from plain functions.
|
|
15
|
+
|
|
16
|
+
Every registration is validated on the spot — spec keys checked against the
|
|
17
|
+
function signature, defaults resolved — so a malformed app fails at
|
|
18
|
+
assembly rather than mid-navigation.
|
|
19
|
+
|
|
20
|
+
Parameters
|
|
21
|
+
----------
|
|
22
|
+
title: str
|
|
23
|
+
App name shown in the top bar.
|
|
24
|
+
page_title: str
|
|
25
|
+
Window title; defaults to title.
|
|
26
|
+
icon: str
|
|
27
|
+
A Flet icon constant for the top-bar app mark; a volume mark by default.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, title, page_title=None, icon=""):
|
|
31
|
+
self.title = title
|
|
32
|
+
self.page_title = page_title or title
|
|
33
|
+
self.icon = icon
|
|
34
|
+
self.theory_source = None
|
|
35
|
+
self.worker = None
|
|
36
|
+
self.vizzes = []
|
|
37
|
+
self.analyses = []
|
|
38
|
+
|
|
39
|
+
def set_theory(self, source):
|
|
40
|
+
"""
|
|
41
|
+
Register the Theory page content.
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
source: str or Path
|
|
46
|
+
Path to a markdown file, or raw markdown. Displayed equations in
|
|
47
|
+
$$...$$ blocks render as LaTeX images.
|
|
48
|
+
"""
|
|
49
|
+
source = os.fspath(source)
|
|
50
|
+
if os.path.isfile(source):
|
|
51
|
+
with open(source, encoding="utf-8") as handle:
|
|
52
|
+
source = handle.read()
|
|
53
|
+
self.theory_source = source
|
|
54
|
+
|
|
55
|
+
def add_worker(self, func, spec=None):
|
|
56
|
+
"""
|
|
57
|
+
Register the data-producing function.
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
func: callable
|
|
62
|
+
Called with one scalar per kwarg; a scan calls it once per grid
|
|
63
|
+
point. Its return is the data handed to viz and analysis functions.
|
|
64
|
+
spec: dict
|
|
65
|
+
{kwarg: Param or shorthand str}; unspecced kwargs are inferred from
|
|
66
|
+
their signature defaults.
|
|
67
|
+
"""
|
|
68
|
+
# One worker per Lab: a second would need a picker and a workspace stash
|
|
69
|
+
# to switch between their results, which is a larger design than this.
|
|
70
|
+
if self.worker is not None:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
"labforge supports one worker per Lab; already registered "
|
|
73
|
+
f"{self.worker.func.__name__}."
|
|
74
|
+
)
|
|
75
|
+
self.worker = WorkerSpec(func=func, params=normalize_spec(func, spec))
|
|
76
|
+
|
|
77
|
+
def add_viz(self, func, title, desc="", spec=None):
|
|
78
|
+
"""
|
|
79
|
+
Register a visualization tab.
|
|
80
|
+
|
|
81
|
+
Parameters
|
|
82
|
+
----------
|
|
83
|
+
func: callable
|
|
84
|
+
func(data, **kwargs) returning a matplotlib Figure or (fig, ax).
|
|
85
|
+
data is the worker's bare return, or a ScanResult after a scan.
|
|
86
|
+
title: str
|
|
87
|
+
Tab label.
|
|
88
|
+
desc: str
|
|
89
|
+
Short markdown note shown above the controls.
|
|
90
|
+
spec: dict
|
|
91
|
+
{kwarg: Param or shorthand str} for the kwargs after data.
|
|
92
|
+
"""
|
|
93
|
+
params = normalize_spec(func, spec, skip_first=True, allow_scan=False)
|
|
94
|
+
self.vizzes.append(VizSpec(func=func, title=title, desc=desc, params=params))
|
|
95
|
+
|
|
96
|
+
def add_analysis(self, func, title, desc="", spec=None):
|
|
97
|
+
"""
|
|
98
|
+
Register an analysis tab.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
func: callable
|
|
103
|
+
func(data, **kwargs) returning a dict (two-column table), a list of
|
|
104
|
+
dicts (table), a string (markdown), or a matplotlib figure.
|
|
105
|
+
title: str
|
|
106
|
+
Tab label.
|
|
107
|
+
desc: str
|
|
108
|
+
Short markdown note shown above the controls.
|
|
109
|
+
spec: dict
|
|
110
|
+
{kwarg: Param or shorthand str} for the kwargs after data.
|
|
111
|
+
"""
|
|
112
|
+
params = normalize_spec(func, spec, skip_first=True, allow_scan=False)
|
|
113
|
+
self.analyses.append(AnalysisSpec(func=func, title=title, desc=desc, params=params))
|
|
114
|
+
|
|
115
|
+
def build_main(self, layout="pages", theme=DEFAULT_THEME):
|
|
116
|
+
"""
|
|
117
|
+
Return the Flet main(page) entry point; also the headless test seam.
|
|
118
|
+
|
|
119
|
+
Parameters
|
|
120
|
+
----------
|
|
121
|
+
layout: str
|
|
122
|
+
"pages" or "scroll", as in open().
|
|
123
|
+
theme: str
|
|
124
|
+
A key of labforge.themes(), as in open().
|
|
125
|
+
|
|
126
|
+
Returns
|
|
127
|
+
-------
|
|
128
|
+
A main(page) callable.
|
|
129
|
+
"""
|
|
130
|
+
if self.worker is None:
|
|
131
|
+
raise ValueError("Register a worker with add_worker before opening the lab.")
|
|
132
|
+
# Imported here, not at module scope, so registration and validation
|
|
133
|
+
# never pay for Flet's UI layer.
|
|
134
|
+
from .shell import build_main
|
|
135
|
+
|
|
136
|
+
return build_main(self, layout=layout, theme=theme)
|
|
137
|
+
|
|
138
|
+
def open(self, view="app", layout="pages", port=0, theme=DEFAULT_THEME):
|
|
139
|
+
"""
|
|
140
|
+
Launch the app.
|
|
141
|
+
|
|
142
|
+
Parameters
|
|
143
|
+
----------
|
|
144
|
+
view: str
|
|
145
|
+
"app" opens a native desktop window; "browser" serves the app and
|
|
146
|
+
opens it in the default web browser — handy for iterating, since a
|
|
147
|
+
served app is reachable from any browser on a stable URL.
|
|
148
|
+
layout: str
|
|
149
|
+
"pages" navigates the four pages with a rail; "scroll" shows the
|
|
150
|
+
whole lab as one continuous scrolling page.
|
|
151
|
+
port: int
|
|
152
|
+
Port for the "browser" view; 0 lets the OS pick a free one. With a
|
|
153
|
+
fixed port the app stays at http://localhost:<port> across relaunches.
|
|
154
|
+
theme: str
|
|
155
|
+
The colour scheme: any key of labforge.themes(), which lists each
|
|
156
|
+
with a one-line note. Sets the chrome, the plot palette and the
|
|
157
|
+
equation ink together, so a figure drawn with labforge.palette()
|
|
158
|
+
follows it.
|
|
159
|
+
"""
|
|
160
|
+
import flet as ft
|
|
161
|
+
|
|
162
|
+
views = {"app": ft.AppView.FLET_APP, "browser": ft.AppView.WEB_BROWSER}
|
|
163
|
+
if view not in views:
|
|
164
|
+
raise ValueError(f"view must be one of {sorted(views)}, got {view!r}.")
|
|
165
|
+
ft.run(self.build_main(layout=layout, theme=theme), view=views[view], port=port)
|
labforge/mathtext.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Render LaTeX math to a base64 PNG: system LaTeX when present, matplotlib's
|
|
3
|
+
mathtext otherwise.
|
|
4
|
+
|
|
5
|
+
Flet has no equation widget, and an image renders identically on every platform.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import io
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
|
|
13
|
+
import matplotlib
|
|
14
|
+
|
|
15
|
+
matplotlib.use("Agg") # headless: render to a buffer, never open a window
|
|
16
|
+
import matplotlib.pyplot as plt
|
|
17
|
+
|
|
18
|
+
from . import theme
|
|
19
|
+
|
|
20
|
+
# Rendered at 2x, displayed at 1x, so equations stay crisp on retina screens.
|
|
21
|
+
RENDER_DPI = 220
|
|
22
|
+
DISPLAY_SCALE = 0.5
|
|
23
|
+
|
|
24
|
+
# amssymb supplies \mathbb; amsmath the spacing/alignment macros the prose uses;
|
|
25
|
+
# eulervm sets the math font to Euler (Zapf), the app's mathematical voice.
|
|
26
|
+
LATEX_PREAMBLE = r"\usepackage{amsmath}\usepackage{amssymb}\usepackage[euler-digits]{eulervm}"
|
|
27
|
+
|
|
28
|
+
# MacTeX installs its binaries here, but a GUI-launched process may not inherit it
|
|
29
|
+
# on PATH — add it so both the availability probe and matplotlib's subprocess find
|
|
30
|
+
# latex/dvipng.
|
|
31
|
+
TEX_BIN = "/Library/TeX/texbin"
|
|
32
|
+
if os.path.isdir(TEX_BIN) and TEX_BIN not in os.environ.get("PATH", "").split(os.pathsep):
|
|
33
|
+
os.environ["PATH"] = TEX_BIN + os.pathsep + os.environ.get("PATH", "")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def latex_available():
|
|
37
|
+
"""True when a system LaTeX toolchain (latex + dvipng) is on PATH."""
|
|
38
|
+
return bool(shutil.which("latex") and shutil.which("dvipng"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Probed once at import. A render failure later (missing package, unsupported
|
|
42
|
+
# glyph) latches this off for the session, since that failure mode is systemic and
|
|
43
|
+
# retrying it pays a slow subprocess timeout on every equation.
|
|
44
|
+
USE_LATEX = latex_available()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def render_png(latex, fontsize, usetex):
|
|
48
|
+
"""
|
|
49
|
+
Render a LaTeX math string to a base64 PNG and its display size.
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
latex: str
|
|
54
|
+
Math content without surrounding dollar signs, e.g. r"f(x)=\\lambda".
|
|
55
|
+
fontsize: int
|
|
56
|
+
Point size of the rendered equation.
|
|
57
|
+
usetex: bool
|
|
58
|
+
Compile with the system LaTeX toolchain when True, else use mathtext.
|
|
59
|
+
The usetex path accepts the full LaTeX command set (\\mathbb, \\mathcal,
|
|
60
|
+
alignment environments) that mathtext only partly supports, and sets the
|
|
61
|
+
math font to Euler; mathtext's cost is a narrower vocabulary and
|
|
62
|
+
Computer-Modern glyphs.
|
|
63
|
+
|
|
64
|
+
Returns
|
|
65
|
+
-------
|
|
66
|
+
Tuple of (base64 src, width in inches, height in inches).
|
|
67
|
+
"""
|
|
68
|
+
# rc_context keeps usetex local to this figure, so it never touches an
|
|
69
|
+
# author's own plots.
|
|
70
|
+
rc = {"text.usetex": usetex}
|
|
71
|
+
if usetex:
|
|
72
|
+
rc["text.latex.preamble"] = LATEX_PREAMBLE
|
|
73
|
+
with plt.rc_context(rc):
|
|
74
|
+
fig = plt.figure()
|
|
75
|
+
# Displayed equations get display style — full-size fractions, sum
|
|
76
|
+
# limits above and below. Only usetex knows \displaystyle; the mathtext
|
|
77
|
+
# fallback keeps text style, one more of its accepted costs.
|
|
78
|
+
body = f"\\displaystyle {latex}" if usetex else latex
|
|
79
|
+
# Ink follows the active theme, so equations sit on the app surface the
|
|
80
|
+
# transparent PNG is composited onto rather than fighting it.
|
|
81
|
+
text = fig.text(0, 0, f"${body}$", fontsize=fontsize, color=theme.active().ink)
|
|
82
|
+
|
|
83
|
+
# Measure the drawn text so we can crop the canvas tightly around it.
|
|
84
|
+
fig.canvas.draw()
|
|
85
|
+
bbox = text.get_window_extent()
|
|
86
|
+
pad = fontsize * 0.35
|
|
87
|
+
width_in = (bbox.width + 2 * pad) / fig.dpi
|
|
88
|
+
height_in = (bbox.height + 2 * pad) / fig.dpi
|
|
89
|
+
fig.set_size_inches(width_in, height_in)
|
|
90
|
+
text.set_position((pad / bbox.width if bbox.width else 0.1, 0.5))
|
|
91
|
+
text.set_verticalalignment("center")
|
|
92
|
+
|
|
93
|
+
buffer = io.BytesIO()
|
|
94
|
+
fig.savefig(
|
|
95
|
+
buffer,
|
|
96
|
+
format="png",
|
|
97
|
+
dpi=RENDER_DPI,
|
|
98
|
+
transparent=True,
|
|
99
|
+
bbox_inches="tight",
|
|
100
|
+
pad_inches=0.08,
|
|
101
|
+
)
|
|
102
|
+
plt.close(fig)
|
|
103
|
+
|
|
104
|
+
src = base64.b64encode(buffer.getvalue()).decode("ascii")
|
|
105
|
+
return src, width_in, height_in
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def equation_image(latex, fontsize=18):
|
|
109
|
+
"""
|
|
110
|
+
Render a LaTeX math string to a base64 PNG suitable for ft.Image.
|
|
111
|
+
|
|
112
|
+
Prefers the system LaTeX toolchain and falls back to mathtext when it is
|
|
113
|
+
absent or a render fails, so a page never blanks on an equation.
|
|
114
|
+
|
|
115
|
+
Parameters
|
|
116
|
+
----------
|
|
117
|
+
latex: str
|
|
118
|
+
Math content without surrounding dollar signs, e.g. r"f(x)=\\lambda".
|
|
119
|
+
fontsize: int
|
|
120
|
+
Point size of the rendered equation.
|
|
121
|
+
|
|
122
|
+
Returns
|
|
123
|
+
-------
|
|
124
|
+
dict with base64 src plus display width and height in logical pixels.
|
|
125
|
+
"""
|
|
126
|
+
global USE_LATEX
|
|
127
|
+
if USE_LATEX:
|
|
128
|
+
try:
|
|
129
|
+
src, width_in, height_in = render_png(latex, fontsize, usetex=True)
|
|
130
|
+
except Exception:
|
|
131
|
+
USE_LATEX = False
|
|
132
|
+
src, width_in, height_in = render_png(latex, fontsize, usetex=False)
|
|
133
|
+
else:
|
|
134
|
+
src, width_in, height_in = render_png(latex, fontsize, usetex=False)
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
"src": src,
|
|
138
|
+
"width": width_in * RENDER_DPI * DISPLAY_SCALE,
|
|
139
|
+
"height": height_in * RENDER_DPI * DISPLAY_SCALE,
|
|
140
|
+
}
|