millwork 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.
Files changed (70) hide show
  1. millwork/__init__.py +45 -0
  2. millwork/_bundle/dist/.vite/manifest.json +92 -0
  3. millwork/_bundle/dist/charts-inner-Cq0vWbjt.js +39 -0
  4. millwork/_bundle/dist/favicon.svg +15 -0
  5. millwork/_bundle/dist/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  6. millwork/_bundle/dist/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  7. millwork/_bundle/dist/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  8. millwork/_bundle/dist/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  9. millwork/_bundle/dist/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  10. millwork/_bundle/dist/icons.svg +24 -0
  11. millwork/_bundle/dist/index.global-cOFus1M1.js +952 -0
  12. millwork/_bundle/dist/index.modern-CITEfQrV.js +135 -0
  13. millwork/_bundle/dist/lib-3DI92Zxk.js +1 -0
  14. millwork/_bundle/dist/main-BoZpFGOH.js +64 -0
  15. millwork/_bundle/dist/main-F2tEXmC5.css +2 -0
  16. millwork/_bundle/dist/react-markdown-DLcbNoI3.js +14 -0
  17. millwork/_bundle/dist/remark-gfm-DoHjwpPg.js +16 -0
  18. millwork/app.py +726 -0
  19. millwork/auth.py +877 -0
  20. millwork/chat.py +397 -0
  21. millwork/cli.py +316 -0
  22. millwork/context.py +42 -0
  23. millwork/files.py +450 -0
  24. millwork/forms.py +349 -0
  25. millwork/manual/__init__.py +449 -0
  26. millwork/manual/components/Hello.jsx +24 -0
  27. millwork/manual/components/Voice.jsx +46 -0
  28. millwork/manual/pages/app/auth.md +188 -0
  29. millwork/manual/pages/app/pages.md +200 -0
  30. millwork/manual/pages/app/running.md +242 -0
  31. millwork/manual/pages/app/state.md +151 -0
  32. millwork/manual/pages/app/testing.md +147 -0
  33. millwork/manual/pages/app/theming.md +122 -0
  34. millwork/manual/pages/components/charts.md +263 -0
  35. millwork/manual/pages/components/chat.md +280 -0
  36. millwork/manual/pages/components/custom.md +148 -0
  37. millwork/manual/pages/components/data.md +112 -0
  38. millwork/manual/pages/components/feedback.md +212 -0
  39. millwork/manual/pages/components/files.md +194 -0
  40. millwork/manual/pages/components/form.md +135 -0
  41. millwork/manual/pages/components/inputs.md +360 -0
  42. millwork/manual/pages/components/layout.md +154 -0
  43. millwork/manual/pages/components/tasks.md +140 -0
  44. millwork/manual/pages/components/text.md +145 -0
  45. millwork/manual/pages/index.md +67 -0
  46. millwork/manual/public/fonts/InterVariable.woff2 +0 -0
  47. millwork/manual/public/fonts/LICENSE.txt +2 -0
  48. millwork/manual/public/mark.svg +15 -0
  49. millwork/manual/public/theme.json +97 -0
  50. millwork/nodes.py +300 -0
  51. millwork/protocol.py +238 -0
  52. millwork/proxy.py +122 -0
  53. millwork/py.typed +0 -0
  54. millwork/session.py +1422 -0
  55. millwork/shell.py +226 -0
  56. millwork/tasks.py +473 -0
  57. millwork/testing.py +336 -0
  58. millwork/theme.py +338 -0
  59. millwork/ui/__init__.py +725 -0
  60. millwork/ui/charts.py +214 -0
  61. millwork/ui/chat.py +97 -0
  62. millwork/ui/files.py +101 -0
  63. millwork/ui/form.py +102 -0
  64. millwork/ui/table.py +145 -0
  65. millwork/ws.py +220 -0
  66. millwork-0.1.0.dist-info/METADATA +131 -0
  67. millwork-0.1.0.dist-info/RECORD +70 -0
  68. millwork-0.1.0.dist-info/WHEEL +4 -0
  69. millwork-0.1.0.dist-info/entry_points.txt +2 -0
  70. millwork-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,360 @@
1
+ ---
2
+ title: Inputs
3
+ order: 2
4
+ ---
5
+
6
+ # Inputs
7
+
8
+ Controls the user types into or picks from. Each one tells Python what
9
+ happened through an `on_change` handler, and most can be owned by either side.
10
+
11
+ ## Who owns the value
12
+
13
+ A control with `default=` keeps its own value in the browser and reports
14
+ changes; a control with `value=` shows what Python says and only changes when
15
+ Python changes it. Give one or the other, never both. Pass `value` when the
16
+ page needs to read or reset the control, `default` when it does not.
17
+
18
+ ```python live
19
+ from pydantic import BaseModel
20
+
21
+
22
+ class Owner(BaseModel):
23
+ text: str = ""
24
+
25
+
26
+ def example(s):
27
+ st = state(Owner)
28
+ return ui.Stack(
29
+ ui.Input(
30
+ value=st.text,
31
+ placeholder="Python owns this one",
32
+ on_change=lambda s, v: setattr(st, "text", v),
33
+ ),
34
+ ui.Row(
35
+ ui.Text(f"Python sees: {st.text!r}", variant="muted"),
36
+ ui.Button(
37
+ "Clear from Python",
38
+ size="sm",
39
+ variant="outline",
40
+ on_click=lambda s, _: setattr(st, "text", ""),
41
+ ),
42
+ gap=3,
43
+ ),
44
+ ui.Input(
45
+ default="The browser owns this one", on_change=lambda s, v: s.toast(f"Changed to {v!r}")
46
+ ),
47
+ gap=3,
48
+ )
49
+ ```
50
+
51
+ ## Button
52
+
53
+ `ui.Button` takes a label, a `variant`, a `size`, an `icon` (before the label,
54
+ or after with `icon_position="end"`), `loading`, `disabled` and a `tooltip`.
55
+ `confirm="..."` asks before the handler runs. `form="key"` submits the Form
56
+ with that key from anywhere on the page.
57
+
58
+ ```python live
59
+ ui.Stack(
60
+ ui.Row(
61
+ ui.Button("Primary"),
62
+ ui.Button("Secondary", variant="secondary"),
63
+ ui.Button("Outline", variant="outline"),
64
+ ui.Button("Ghost", variant="ghost"),
65
+ ui.Button("Link", variant="link"),
66
+ ui.Button("Destructive", variant="destructive"),
67
+ gap=2,
68
+ wrap=True,
69
+ ),
70
+ ui.Row(
71
+ ui.Button("Small", size="sm", variant="outline"),
72
+ ui.Button("Medium", variant="outline"),
73
+ ui.Button("Large", size="lg", variant="outline"),
74
+ ui.Button("With icon", icon="download", variant="outline"),
75
+ ui.Button("Icon after", icon="arrow-right", icon_position="end", variant="outline"),
76
+ gap=2,
77
+ wrap=True,
78
+ ),
79
+ ui.Row(
80
+ ui.Button("Loading", loading=True),
81
+ ui.Button("Disabled", disabled=True),
82
+ ui.Button("With tooltip", tooltip="Explains itself on hover", variant="outline"),
83
+ ui.Button(
84
+ "Delete",
85
+ variant="destructive",
86
+ confirm="Delete this for good?",
87
+ on_click=lambda s, _: s.toast("Deleted.", tone="destructive"),
88
+ ),
89
+ gap=2,
90
+ wrap=True,
91
+ ),
92
+ gap=3,
93
+ )
94
+ ```
95
+
96
+ ## ButtonGroup
97
+
98
+ `ui.ButtonGroup` joins buttons into one control.
99
+
100
+ ```python live
101
+ ui.ButtonGroup(
102
+ ui.Button("Day", variant="outline", on_click=lambda s, _: s.toast("Day")),
103
+ ui.Button("Week", variant="outline", on_click=lambda s, _: s.toast("Week")),
104
+ ui.Button("Month", variant="outline", on_click=lambda s, _: s.toast("Month")),
105
+ )
106
+ ```
107
+
108
+ ## Input
109
+
110
+ `ui.Input` is a single line of text. `kind` chooses the keyboard and
111
+ validation (`text`, `password`, `email`, `number`, `search`, `url`),
112
+ `prefix_icon` and `suffix` decorate it, `clearable` adds a clear button.
113
+ `on_change` fires after a pause in typing (`debounce_ms`, 250 by default);
114
+ `on_submit` fires on Enter.
115
+
116
+ ```python live
117
+ ui.Stack(
118
+ ui.Input(
119
+ placeholder="Search",
120
+ prefix_icon="search",
121
+ kind="search",
122
+ clearable=True,
123
+ on_submit=lambda s, v: s.toast(f"Searching for {v!r}"),
124
+ ),
125
+ ui.Input(placeholder="Distance", suffix="km", kind="number"),
126
+ ui.Input(placeholder="Password", kind="password"),
127
+ ui.Input(value="Read only", readonly=True),
128
+ ui.Input(value="Disabled", disabled=True),
129
+ gap=3,
130
+ )
131
+ ```
132
+
133
+ ## Textarea
134
+
135
+ `ui.Textarea` is several lines. `rows` sets the starting height, `autosize`
136
+ grows it with the text, `max_length` caps it. Ctrl+Enter submits.
137
+
138
+ ```python live
139
+ ui.Textarea(
140
+ placeholder="Notes. Ctrl+Enter submits.",
141
+ rows=3,
142
+ autosize=True,
143
+ max_length=200,
144
+ on_submit=lambda s, v: s.toast(f"{len(v)} characters"),
145
+ )
146
+ ```
147
+
148
+ ## NumberInput
149
+
150
+ `ui.NumberInput` takes a number within `min` and `max`, stepping by `step`,
151
+ with an optional `suffix`. It reports on blur and on Enter.
152
+
153
+ ```python live
154
+ ui.Row(
155
+ ui.NumberInput(default=3, min=1, max=10, step=1, suffix="items"),
156
+ ui.NumberInput(default=0.5, min=0, max=1, step=0.1, placeholder="0 to 1"),
157
+ gap=4,
158
+ )
159
+ ```
160
+
161
+ ## Slider
162
+
163
+ `ui.Slider` picks a number by dragging. `marks` labels points under the
164
+ track; `show_value` prints the value beside it. It reports on release.
165
+
166
+ ```python live
167
+ ui.Stack(
168
+ ui.Slider(default=40, min=0, max=100, step=10),
169
+ ui.Slider(default=0.3, min=0, max=1, step=0.1, marks={0: "cold", 0.5: "warm", 1: "hot"}),
170
+ gap=6,
171
+ )
172
+ ```
173
+
174
+ ## Switch and Checkbox
175
+
176
+ Both are a yes or no with a `label` and an optional `description`. Use
177
+ `checked` or `default_checked`.
178
+
179
+ ```python live
180
+ ui.Stack(
181
+ ui.Switch(
182
+ label="Email me a summary", description="Once a day, at eight.", default_checked=True
183
+ ),
184
+ ui.Checkbox(
185
+ label="I have read the terms", on_change=lambda s, v: s.toast("Read" if v else "Unread")
186
+ ),
187
+ ui.Checkbox(label="Disabled", disabled=True),
188
+ gap=3,
189
+ )
190
+ ```
191
+
192
+ ## Select
193
+
194
+ `ui.Select` picks one from `options`. Options are strings, `(value, label)`
195
+ pairs, or `ui.Option` objects with a `description`, an `icon`, `disabled` and a
196
+ `group`. `clearable` allows no choice.
197
+
198
+ ```python live
199
+ ui.Row(
200
+ ui.Select(options=["Fast", "Balanced", "Thorough"], default="Balanced"),
201
+ ui.Select(
202
+ options=[
203
+ ui.Option(value="nl", label="Dutch", group="Europe"),
204
+ ui.Option(value="de", label="German", group="Europe"),
205
+ ui.Option(value="ja", label="Japanese", group="Asia", disabled=True),
206
+ ],
207
+ placeholder="Language",
208
+ clearable=True,
209
+ ),
210
+ gap=4,
211
+ )
212
+ ```
213
+
214
+ ## Combobox
215
+
216
+ `ui.Combobox` is a Select with a search box. With `options` it filters in the
217
+ browser; with `on_search` Python answers each search over the session's
218
+ socket, without a render. `allow_custom=True` lets typed text that matches
219
+ nothing be the value.
220
+
221
+ ```python live
222
+ PEOPLE = [("ada", "Ada Lovelace"), ("grace", "Grace Hopper"), ("edsger", "Edsger Dijkstra")]
223
+
224
+
225
+ async def search(s, query: str):
226
+ return [p for p in PEOPLE if query.lower() in p[1].lower()]
227
+
228
+
229
+ def example(s):
230
+ return ui.Row(
231
+ ui.Combobox(options=PEOPLE, placeholder="Filters in the browser"),
232
+ ui.Combobox(on_search=search, placeholder="Asks Python as you type", clearable=True),
233
+ ui.Combobox(options=["red", "green"], allow_custom=True, placeholder="Or type your own"),
234
+ gap=4,
235
+ wrap=True,
236
+ )
237
+ ```
238
+
239
+ ## MultiSelect
240
+
241
+ `ui.MultiSelect` picks several, shown as chips. `max` stops further picks.
242
+
243
+ ```python live
244
+ ui.MultiSelect(
245
+ options=["Alpha", "Beta", "Gamma", "Delta"],
246
+ default=["Alpha"],
247
+ max=3,
248
+ placeholder="Pick up to three",
249
+ )
250
+ ```
251
+
252
+ The chips keep the order they were picked in, and that is the order Python
253
+ receives. `sortable=True` lets the order be changed afterwards: drag a chip
254
+ to its place, or focus it and press Alt with an arrow key. In a form, the
255
+ same hint on the field (`json_schema_extra={"sortable": True}`) does it.
256
+
257
+ ```python live
258
+ from pydantic import BaseModel
259
+
260
+
261
+ class Pipeline(BaseModel):
262
+ steps: list[str] = ["Collect", "Clean", "Model"]
263
+
264
+
265
+ def reorder(s, steps):
266
+ state(Pipeline).steps = steps
267
+
268
+
269
+ def example(s):
270
+ st = state(Pipeline)
271
+ return ui.Stack(
272
+ ui.MultiSelect(
273
+ options=["Collect", "Clean", "Model", "Report", "Share"],
274
+ value=st.steps,
275
+ sortable=True,
276
+ on_change=reorder,
277
+ ),
278
+ ui.Text(" then ".join(st.steps) or "Nothing yet.", variant="muted"),
279
+ gap=2,
280
+ )
281
+ ```
282
+
283
+ ## RadioGroup, CheckboxGroup and ToggleGroup
284
+
285
+ Three ways to choose from a short list: one with radios, several with
286
+ checkboxes, or either with pressed buttons (`multiple=True` for several).
287
+
288
+ ```python live
289
+ ui.Stack(
290
+ ui.RadioGroup(options=["Small", "Medium", "Large"], default="Medium", orientation="horizontal"),
291
+ ui.CheckboxGroup(
292
+ options=[
293
+ ui.Option(value="a", label="Alerts", description="Only the important ones"),
294
+ ui.Option(value="d", label="Digest"),
295
+ ],
296
+ default=["a"],
297
+ ),
298
+ ui.ToggleGroup(
299
+ options=[("left", "Left"), ("center", "Center"), ("right", "Right")], default="left"
300
+ ),
301
+ ui.ToggleGroup(
302
+ options=["Bold", "Italic", "Underline"], multiple=True, default=["Bold"], size="sm"
303
+ ),
304
+ gap=4,
305
+ )
306
+ ```
307
+
308
+ ## DatePicker
309
+
310
+ `ui.DatePicker` picks a date from a calendar; `range=True` picks a span.
311
+ Values are ISO strings on the wire, `date` objects are accepted. `min` and
312
+ `max` bound the pickable days.
313
+
314
+ ```python live
315
+ ui.Row(
316
+ ui.DatePicker(
317
+ placeholder="A day", clearable=True, on_change=lambda s, v: s.toast(f"Picked {v}")
318
+ ),
319
+ ui.DatePicker(range=True, placeholder="A span", min="2026-01-01"),
320
+ gap=4,
321
+ wrap=True,
322
+ )
323
+ ```
324
+
325
+ ## DropdownMenu
326
+
327
+ `ui.DropdownMenu` opens a menu behind a `trigger` button. Items carry an
328
+ `icon`, `disabled`, `destructive` and `separator_after`; `on_select` receives
329
+ the chosen id.
330
+
331
+ ```python live
332
+ ui.DropdownMenu(
333
+ trigger=ui.Button("Actions", icon="more-horizontal", variant="outline"),
334
+ items=[
335
+ ui.MenuItem(id="rename", label="Rename", icon="pencil"),
336
+ ui.MenuItem(id="copy", label="Duplicate", icon="copy", separator_after=True),
337
+ ui.MenuItem(id="delete", label="Delete", icon="trash-2", destructive=True),
338
+ ],
339
+ on_select=lambda s, item: s.toast(f"Chose {item}"),
340
+ )
341
+ ```
342
+
343
+ ## Field
344
+
345
+ `ui.Field` puts a `label`, `help` and an `error` around one control, for a
346
+ form built by hand rather than from a model.
347
+
348
+ ```python live
349
+ from pydantic import Field
350
+
351
+ ui.Grid(
352
+ ui.Field(ui.Input(placeholder="Ada"), label="Name", required=True, help="As on your badge."),
353
+ ui.Field(
354
+ ui.Input(placeholder="ada@example.com", kind="email"),
355
+ label="Email",
356
+ error="This address bounced.",
357
+ ),
358
+ cols={"sm": 1, "md": 2},
359
+ )
360
+ ```
@@ -0,0 +1,154 @@
1
+ ---
2
+ title: Layout
3
+ order: 0
4
+ ---
5
+
6
+ # Layout
7
+
8
+ A page is a tree. Layout components arrange their children; they carry no
9
+ state and emit no events. Children are positional, so a page reads like the
10
+ screen it makes.
11
+
12
+ ## Page
13
+
14
+ `ui.Page` is the root of every page. It is as high as the viewport, scrolls its
15
+ body under a fixed header, and lays itself out around a `ui.Sidebar` child when
16
+ there is one. Give it a `title` for the browser tab and a `header` for the app
17
+ bar; `padding=False` lets a child such as `ui.Chat` fill it edge to edge.
18
+
19
+ This manual is a `Page` with a `Sidebar`, so it is not shown inline.
20
+
21
+ ## Stack and Row
22
+
23
+ `ui.Stack` lays children out vertically, `ui.Row` horizontally. Both take
24
+ `gap`, `align` and `justify`; a `Row` can `wrap`.
25
+
26
+ ```python live
27
+ ui.Stack(
28
+ ui.Row(ui.Badge("one"), ui.Badge("two"), ui.Badge("three"), gap=2),
29
+ ui.Row(ui.Button("Left", variant="outline"), ui.Button("Right"), justify="between"),
30
+ ui.Row(ui.Text("Centred", variant="muted"), justify="center"),
31
+ gap=4,
32
+ )
33
+ ```
34
+
35
+ `divided=True` draws a rule between the children of a `Stack`.
36
+
37
+ ```python live
38
+ ui.Stack(ui.Text("First"), ui.Text("Second"), ui.Text("Third"), divided=True, gap=2)
39
+ ```
40
+
41
+ ## Grid
42
+
43
+ `ui.Grid` places children in columns. `cols` is a number, or a mapping with
44
+ `sm`, `md` and `lg` for the breakpoints at which the count changes.
45
+
46
+ ```python live
47
+ ui.Grid(
48
+ ui.Stat(label="Sessions", value="1,204", hint="today"),
49
+ ui.Stat(label="Replies", value="3.8 s", hint="median", tone="success"),
50
+ ui.Stat(label="Errors", value="2", tone="destructive"),
51
+ cols={"sm": 1, "md": 3},
52
+ )
53
+ ```
54
+
55
+ ## Card
56
+
57
+ `ui.Card` frames content with an optional `title`, `description` and `footer`.
58
+ `tone` colours its border; `on_click` makes the whole card a target.
59
+
60
+ ```python live
61
+ ui.Grid(
62
+ ui.Card(
63
+ ui.Text("The body of the card goes here, as children."),
64
+ title="A card",
65
+ description="With a description under the title.",
66
+ footer=ui.Button("Action", size="sm"),
67
+ ),
68
+ ui.Card(
69
+ ui.Text("Clicking me toasts."),
70
+ title="Clickable",
71
+ tone="primary",
72
+ on_click=lambda s, _: s.toast("The card was clicked."),
73
+ ),
74
+ cols={"sm": 1, "md": 2},
75
+ )
76
+ ```
77
+
78
+ ## Separator
79
+
80
+ A rule between things, with an optional `label`.
81
+
82
+ ```python live
83
+ ui.Stack(
84
+ ui.Text("Above"),
85
+ ui.Separator(),
86
+ ui.Text("Between, with a label below"),
87
+ ui.Separator(label="or"),
88
+ ui.Text("Below"),
89
+ gap=3,
90
+ )
91
+ ```
92
+
93
+ ## Split
94
+
95
+ `ui.Split` puts its children in resizable panes. `sizes` are the starting
96
+ percentages, `min_sizes` the smallest each may be dragged to, and
97
+ `collapsible` lets a pane fold away under its minimum. Give it a height with
98
+ `cls`, since it fills what it is given.
99
+
100
+ ```python live
101
+ ui.Split(
102
+ ui.Card(ui.Text("Left pane. Drag the handle."), title="Left"),
103
+ ui.Card(ui.Text("Right pane."), title="Right"),
104
+ sizes=[40, 60],
105
+ min_sizes=[20, 20],
106
+ cls="h-48",
107
+ )
108
+ ```
109
+
110
+ ## Tabs
111
+
112
+ `ui.Tabs` holds `ui.Tab` children, each with an `id` and a `label`, and an
113
+ optional `icon` or `badge`. The chosen tab is the tab's business unless the
114
+ page passes `value`; `default` names the first one shown. Panels stay mounted
115
+ when hidden, so what the user typed in one survives a switch.
116
+
117
+ ```python live
118
+ ui.Tabs(
119
+ ui.Tab(ui.Text("Overview text."), id="overview", label="Overview"),
120
+ ui.Tab(ui.Input(placeholder="Type, switch away and back"), id="edit", label="Edit", badge="1"),
121
+ ui.Tab(ui.Text("Settings text."), id="settings", label="Settings", icon="settings"),
122
+ default="overview",
123
+ )
124
+ ```
125
+
126
+ ## Accordion and Collapsible
127
+
128
+ `ui.Accordion` opens one `ui.AccordionItem` at a time, or several with
129
+ `multiple=True`. `ui.Collapsible` is a single section with a `title`.
130
+
131
+ ```python live
132
+ ui.Stack(
133
+ ui.Accordion(
134
+ ui.AccordionItem(
135
+ ui.Text("What a session is, and what it holds."), id="s", title="Sessions"
136
+ ),
137
+ ui.AccordionItem(ui.Text("How a handler changes state."), id="h", title="Handlers"),
138
+ default=["s"],
139
+ ),
140
+ ui.Collapsible(ui.Text("Details that are usually folded away."), title="Advanced"),
141
+ gap=4,
142
+ )
143
+ ```
144
+
145
+ ## ScrollArea
146
+
147
+ `ui.ScrollArea` scrolls its children inside a fixed `height`.
148
+
149
+ ```python live
150
+ ui.ScrollArea(
151
+ ui.Stack(*(ui.Text(f"Line {i + 1}") for i in range(30)), gap=1),
152
+ height="8rem",
153
+ )
154
+ ```
@@ -0,0 +1,140 @@
1
+ ---
2
+ title: Tasks
3
+ order: 8
4
+ ---
5
+
6
+ # Tasks
7
+
8
+ Work that takes longer than a click goes in a task. The session owns it, the
9
+ page shows its progress, and the user can cancel it. A handler that awaits a
10
+ task releases the handler lock, so the page stays live meanwhile.
11
+
12
+ ## Starting one
13
+
14
+ `s.task(work, *args, label=)` runs a coroutine or a plain function (a
15
+ function runs in a thread). It returns a `mw.Task` whose `done`, `status`,
16
+ `progress`, `steps` and `result` the page can read. `ui.Progress(task=)`
17
+ and `ui.Steps(task=)` draw it, and `task.cancel()` stops it.
18
+
19
+ ```python live
20
+ import time
21
+
22
+ from pydantic import BaseModel
23
+
24
+
25
+ def crunch(n: int) -> str:
26
+ p = mw.progress()
27
+ p.plan(["Read", "Analyse", "Write"])
28
+ with p.step("Read"):
29
+ time.sleep(0.4)
30
+ with p.step("Analyse") as step:
31
+ for i in range(n):
32
+ p.check()
33
+ p.set((i + 1) / n, f"row {i + 1} of {n}")
34
+ time.sleep(0.15)
35
+ step.note(f"{n} rows")
36
+ with p.step("Write"):
37
+ time.sleep(0.3)
38
+ return f"{n} rows analysed"
39
+
40
+
41
+ class Job(BaseModel):
42
+ run: object = None
43
+
44
+
45
+ def example(s):
46
+ st = state(Job)
47
+ running = st.run is not None and not st.run.done
48
+ return ui.Stack(
49
+ ui.Row(
50
+ ui.Button(
51
+ "Start",
52
+ disabled=running,
53
+ on_click=lambda s, _: setattr(st, "run", s.task(crunch, 12, label="Crunching")),
54
+ ),
55
+ ui.Button(
56
+ "Cancel",
57
+ variant="outline",
58
+ disabled=not running,
59
+ on_click=lambda s, _: st.run.cancel(),
60
+ ),
61
+ gap=2,
62
+ ),
63
+ ui.Progress(task=st.run) if st.run else ui.Text("No job yet.", variant="muted"),
64
+ ui.Steps(task=st.run) if st.run else None,
65
+ ui.Text(f"Result: {st.run.result}" if st.run and st.run.ok else "", variant="muted"),
66
+ gap=4,
67
+ )
68
+ ```
69
+
70
+ ## Reporting from the work
71
+
72
+ Inside the work, `mw.progress()` is the reporter. `set(value, message)`
73
+ moves the bar, `plan([...])` declares steps up front, `step(label)` opens
74
+ one as a context manager, `note(text)` leaves a short result on it,
75
+ `log(line)` appends output, and `check()` raises `mw.Cancelled` when the
76
+ user cancelled, so a loop stops at its next check.
77
+
78
+ ## Waiting for a result
79
+
80
+ A handler can `await` the task to use its result; the lock is released while
81
+ it waits. `on_done=` names a handler to run when the task ends, with the
82
+ task as its argument. `timeout=` cancels a task that runs too long.
83
+
84
+ ```python live
85
+ import asyncio
86
+
87
+ from pydantic import BaseModel
88
+
89
+
90
+ async def slow_sum(a: int, b: int) -> int:
91
+ await asyncio.sleep(0.8)
92
+ return a + b
93
+
94
+
95
+ class Sum(BaseModel):
96
+ text: str = "not computed"
97
+
98
+
99
+ async def compute(s, _):
100
+ st = state(Sum)
101
+ st.text = "computing"
102
+ result = await s.task(slow_sum, 2, 3, label="Adding")
103
+ st.text = f"2 + 3 = {result}"
104
+
105
+
106
+ def example(s):
107
+ return ui.Row(
108
+ ui.Button("Compute", on_click=compute),
109
+ ui.Text(state(Sum).text, variant="muted"),
110
+ gap=3,
111
+ )
112
+ ```
113
+
114
+ ## Later and threads
115
+
116
+ `s.later(seconds, work)` runs work after a delay; `s.thread(fn, *args)`
117
+ runs a blocking function in the thread pool and is what `s.task` does for a
118
+ plain function.
119
+
120
+ ```python live
121
+ from pydantic import BaseModel
122
+
123
+
124
+ class Tick(BaseModel):
125
+ text: str = "waiting"
126
+
127
+
128
+ def example(s):
129
+ st = state(Tick)
130
+
131
+ def arm(s, _):
132
+ st.text = "armed"
133
+ s.later(1.5, lambda: setattr(st, "text", "fired"))
134
+
135
+ return ui.Row(
136
+ ui.Button("Arm a timer", variant="outline", on_click=arm),
137
+ ui.Text(st.text, variant="muted"),
138
+ gap=3,
139
+ )
140
+ ```