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.
- millwork/__init__.py +45 -0
- millwork/_bundle/dist/.vite/manifest.json +92 -0
- millwork/_bundle/dist/charts-inner-Cq0vWbjt.js +39 -0
- millwork/_bundle/dist/favicon.svg +15 -0
- millwork/_bundle/dist/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- millwork/_bundle/dist/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- millwork/_bundle/dist/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- millwork/_bundle/dist/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- millwork/_bundle/dist/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- millwork/_bundle/dist/icons.svg +24 -0
- millwork/_bundle/dist/index.global-cOFus1M1.js +952 -0
- millwork/_bundle/dist/index.modern-CITEfQrV.js +135 -0
- millwork/_bundle/dist/lib-3DI92Zxk.js +1 -0
- millwork/_bundle/dist/main-BoZpFGOH.js +64 -0
- millwork/_bundle/dist/main-F2tEXmC5.css +2 -0
- millwork/_bundle/dist/react-markdown-DLcbNoI3.js +14 -0
- millwork/_bundle/dist/remark-gfm-DoHjwpPg.js +16 -0
- millwork/app.py +726 -0
- millwork/auth.py +877 -0
- millwork/chat.py +397 -0
- millwork/cli.py +316 -0
- millwork/context.py +42 -0
- millwork/files.py +450 -0
- millwork/forms.py +349 -0
- millwork/manual/__init__.py +449 -0
- millwork/manual/components/Hello.jsx +24 -0
- millwork/manual/components/Voice.jsx +46 -0
- millwork/manual/pages/app/auth.md +188 -0
- millwork/manual/pages/app/pages.md +200 -0
- millwork/manual/pages/app/running.md +242 -0
- millwork/manual/pages/app/state.md +151 -0
- millwork/manual/pages/app/testing.md +147 -0
- millwork/manual/pages/app/theming.md +122 -0
- millwork/manual/pages/components/charts.md +263 -0
- millwork/manual/pages/components/chat.md +280 -0
- millwork/manual/pages/components/custom.md +148 -0
- millwork/manual/pages/components/data.md +112 -0
- millwork/manual/pages/components/feedback.md +212 -0
- millwork/manual/pages/components/files.md +194 -0
- millwork/manual/pages/components/form.md +135 -0
- millwork/manual/pages/components/inputs.md +360 -0
- millwork/manual/pages/components/layout.md +154 -0
- millwork/manual/pages/components/tasks.md +140 -0
- millwork/manual/pages/components/text.md +145 -0
- millwork/manual/pages/index.md +67 -0
- millwork/manual/public/fonts/InterVariable.woff2 +0 -0
- millwork/manual/public/fonts/LICENSE.txt +2 -0
- millwork/manual/public/mark.svg +15 -0
- millwork/manual/public/theme.json +97 -0
- millwork/nodes.py +300 -0
- millwork/protocol.py +238 -0
- millwork/proxy.py +122 -0
- millwork/py.typed +0 -0
- millwork/session.py +1422 -0
- millwork/shell.py +226 -0
- millwork/tasks.py +473 -0
- millwork/testing.py +336 -0
- millwork/theme.py +338 -0
- millwork/ui/__init__.py +725 -0
- millwork/ui/charts.py +214 -0
- millwork/ui/chat.py +97 -0
- millwork/ui/files.py +101 -0
- millwork/ui/form.py +102 -0
- millwork/ui/table.py +145 -0
- millwork/ws.py +220 -0
- millwork-0.1.0.dist-info/METADATA +131 -0
- millwork-0.1.0.dist-info/RECORD +70 -0
- millwork-0.1.0.dist-info/WHEEL +4 -0
- millwork-0.1.0.dist-info/entry_points.txt +2 -0
- millwork-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Text
|
|
3
|
+
order: 1
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Text
|
|
7
|
+
|
|
8
|
+
Words on a page: plain text with a variant and a tone, Markdown, code, and
|
|
9
|
+
the small pieces that label things.
|
|
10
|
+
|
|
11
|
+
## Text
|
|
12
|
+
|
|
13
|
+
`ui.Text` is a string with a `variant`: `body`, `muted`, `caption`, `label`,
|
|
14
|
+
`title`, `heading`, `subheading` or `mono`. `tone` colours it, `truncate`
|
|
15
|
+
keeps it to one line, `align` sets the alignment.
|
|
16
|
+
|
|
17
|
+
```python live
|
|
18
|
+
ui.Stack(
|
|
19
|
+
ui.Text("Heading", variant="heading"),
|
|
20
|
+
ui.Text("Title", variant="title"),
|
|
21
|
+
ui.Text("Subheading", variant="subheading"),
|
|
22
|
+
ui.Text("Body text, the default."),
|
|
23
|
+
ui.Text("Muted text for secondary information.", variant="muted"),
|
|
24
|
+
ui.Text("A caption.", variant="caption"),
|
|
25
|
+
ui.Text("A label", variant="label"),
|
|
26
|
+
ui.Text("mono: for identifiers and paths", variant="mono"),
|
|
27
|
+
ui.Row(
|
|
28
|
+
ui.Text("success", tone="success"),
|
|
29
|
+
ui.Text("warning", tone="warning"),
|
|
30
|
+
ui.Text("destructive", tone="destructive"),
|
|
31
|
+
ui.Text("primary", tone="primary"),
|
|
32
|
+
gap=4,
|
|
33
|
+
),
|
|
34
|
+
ui.Text(
|
|
35
|
+
"A long line that is cut with an ellipsis when it does not fit the width it is given, however long it goes on.",
|
|
36
|
+
truncate=True,
|
|
37
|
+
),
|
|
38
|
+
gap=2,
|
|
39
|
+
)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Markdown
|
|
43
|
+
|
|
44
|
+
`ui.Markdown` renders GitHub-flavoured Markdown as real elements: headings,
|
|
45
|
+
lists, tables, links and code. `tight=True` removes the outer spacing for use
|
|
46
|
+
inside a card or a table cell.
|
|
47
|
+
|
|
48
|
+
```python live
|
|
49
|
+
ui.Markdown("""
|
|
50
|
+
### A heading inside Markdown
|
|
51
|
+
|
|
52
|
+
Text with **bold**, *italic*, `code` and a [link](https://example.com).
|
|
53
|
+
|
|
54
|
+
- a list
|
|
55
|
+
- with items
|
|
56
|
+
|
|
57
|
+
| Column | Value |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| one | 1 |
|
|
60
|
+
| two | 2 |
|
|
61
|
+
""")
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Code
|
|
65
|
+
|
|
66
|
+
`ui.Code` shows a block of code with a `language` for highlighting, an
|
|
67
|
+
optional `title`, a copy button (`copyable`, on by default), `wrap` for long
|
|
68
|
+
lines and `max_height` to scroll past.
|
|
69
|
+
|
|
70
|
+
```python live
|
|
71
|
+
ui.Code(
|
|
72
|
+
'def greet(name: str) -> str:\n return f"Hello, {name}."\n',
|
|
73
|
+
language="python",
|
|
74
|
+
title="greet.py",
|
|
75
|
+
)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Badge
|
|
79
|
+
|
|
80
|
+
`ui.Badge` is a small label with a `tone` and an optional `icon`.
|
|
81
|
+
|
|
82
|
+
```python live
|
|
83
|
+
ui.Row(
|
|
84
|
+
ui.Badge("default"),
|
|
85
|
+
ui.Badge("muted", tone="muted"),
|
|
86
|
+
ui.Badge("primary", tone="primary"),
|
|
87
|
+
ui.Badge("accent", tone="accent"),
|
|
88
|
+
ui.Badge("success", tone="success", icon="check"),
|
|
89
|
+
ui.Badge("warning", tone="warning"),
|
|
90
|
+
ui.Badge("destructive", tone="destructive"),
|
|
91
|
+
gap=2,
|
|
92
|
+
wrap=True,
|
|
93
|
+
)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Kbd
|
|
97
|
+
|
|
98
|
+
`ui.Kbd` shows keys as keycaps.
|
|
99
|
+
|
|
100
|
+
```python live
|
|
101
|
+
ui.Row(
|
|
102
|
+
ui.Text("Save with"),
|
|
103
|
+
ui.Kbd(keys=["Ctrl", "S"]),
|
|
104
|
+
ui.Text("or press"),
|
|
105
|
+
ui.Kbd(keys=["Enter"]),
|
|
106
|
+
gap=2,
|
|
107
|
+
)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Link
|
|
111
|
+
|
|
112
|
+
`ui.Link` is a hyperlink. A path inside the app navigates over the session
|
|
113
|
+
without a reload; `target="_blank"` opens a new tab.
|
|
114
|
+
|
|
115
|
+
```python live
|
|
116
|
+
ui.Row(
|
|
117
|
+
ui.Link(label="Welcome, inside the app", href="/"),
|
|
118
|
+
ui.Link(
|
|
119
|
+
label="An outside site", href="https://example.com", target="_blank", icon="external-link"
|
|
120
|
+
),
|
|
121
|
+
gap=6,
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Stat
|
|
126
|
+
|
|
127
|
+
`ui.Stat` is a number with a `label`, an optional `hint`, a `tone` and an
|
|
128
|
+
`icon`, for telemetry and summaries.
|
|
129
|
+
|
|
130
|
+
```python live
|
|
131
|
+
ui.Grid(
|
|
132
|
+
ui.Stat(label="Tokens", value="12,480", hint="this session", icon="hash"),
|
|
133
|
+
ui.Stat(label="Cost", value="€0.42", tone="success", icon="zap"),
|
|
134
|
+
ui.Stat(label="Latency", value="1.9 s", hint="p95", tone="warning", icon="clock"),
|
|
135
|
+
cols={"sm": 1, "md": 3},
|
|
136
|
+
)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Tooltip
|
|
140
|
+
|
|
141
|
+
`ui.Tooltip` wraps one child and shows `content` on hover or focus.
|
|
142
|
+
|
|
143
|
+
```python live
|
|
144
|
+
ui.Tooltip(ui.Button("Hover me", variant="outline"), content="More about this button.")
|
|
145
|
+
```
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Welcome
|
|
3
|
+
order: 0
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# millwork
|
|
7
|
+
|
|
8
|
+
millwork is a Python framework for data tools and AI applications. The browser
|
|
9
|
+
runs one prebuilt React bundle; Python describes the page and handles its
|
|
10
|
+
events. The project is Python only: the bundle is prebuilt, and Python owns
|
|
11
|
+
every piece of state.
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import millwork as mw
|
|
15
|
+
from millwork import ui
|
|
16
|
+
|
|
17
|
+
app = mw.App("hello")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.page("/")
|
|
21
|
+
def home(s: mw.Session) -> ui.Component:
|
|
22
|
+
return ui.Page(ui.Text("Hello."))
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Run it with `millwork run app.py` and open the address it prints.
|
|
26
|
+
|
|
27
|
+
## How this manual works
|
|
28
|
+
|
|
29
|
+
This manual is itself a millwork app. `millwork docs` serves it; its pages
|
|
30
|
+
are Markdown files inside the package, and the sidebar, the search box and
|
|
31
|
+
the header are catalogue components. Every example runs: the component you
|
|
32
|
+
see is produced by the Python shown beneath it, executed by the server that
|
|
33
|
+
serves these pages. Try the button:
|
|
34
|
+
|
|
35
|
+
```python live
|
|
36
|
+
ui.Button("Say hello", icon="sparkles", on_click=lambda s, _: s.toast("Hello from Python."))
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
An example may hold state. `state(Model)` returns an instance of your model
|
|
40
|
+
that lives for the session, so a handler can change it and the page re-renders:
|
|
41
|
+
|
|
42
|
+
```python live
|
|
43
|
+
from pydantic import BaseModel
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Counter(BaseModel):
|
|
47
|
+
n: int = 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def example(s):
|
|
51
|
+
st = state(Counter)
|
|
52
|
+
return ui.Row(
|
|
53
|
+
ui.Button("Add one", on_click=lambda s, _: setattr(st, "n", st.n + 1)),
|
|
54
|
+
ui.Text(f"Clicked {st.n} times.", variant="muted"),
|
|
55
|
+
)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A block ends in an expression, which is the component, or defines
|
|
59
|
+
`example(s)`, a page function over the session. `mw` and `ui` are in scope;
|
|
60
|
+
everything else is imported, as in an app.
|
|
61
|
+
|
|
62
|
+
## Where to go next
|
|
63
|
+
|
|
64
|
+
The App chapters say how an app is put together: pages and routing, state
|
|
65
|
+
and handlers, signing in, theming, testing, running and deploying. The
|
|
66
|
+
Components chapters walk through the catalogue, one group at a time, each
|
|
67
|
+
component in its states with the Python that makes it.
|
|
Binary file
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
|
2
|
+
<title>millwork</title>
|
|
3
|
+
<defs>
|
|
4
|
+
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
|
5
|
+
<stop offset="0" stop-color="#1fb3d3"/>
|
|
6
|
+
<stop offset=".55" stop-color="#0a7ea4"/>
|
|
7
|
+
<stop offset="1" stop-color="#6d5ae0"/>
|
|
8
|
+
</linearGradient>
|
|
9
|
+
</defs>
|
|
10
|
+
<g fill="url(#g)">
|
|
11
|
+
<path id="b" d="M24 23C17 18 15 9 19 5c5-4 13 3 13 10 0 5-3 7-8 8Z"/>
|
|
12
|
+
<use href="#b" transform="rotate(120 24 24)"/>
|
|
13
|
+
<use href="#b" transform="rotate(240 24 24)"/>
|
|
14
|
+
</g>
|
|
15
|
+
</svg>
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
{
|
|
2
|
+
"mode": "system",
|
|
3
|
+
"logo": "/public/mark.svg",
|
|
4
|
+
"favicon": "/public/mark.svg",
|
|
5
|
+
"fonts": [
|
|
6
|
+
{
|
|
7
|
+
"family": "Inter",
|
|
8
|
+
"src": "/public/fonts/InterVariable.woff2",
|
|
9
|
+
"weights": "100 900"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"density": "comfortable",
|
|
13
|
+
"tokens": {
|
|
14
|
+
"light": {
|
|
15
|
+
"background": "#ffffff",
|
|
16
|
+
"foreground": "#16232e",
|
|
17
|
+
"card": "#ffffff",
|
|
18
|
+
"card-foreground": "#16232e",
|
|
19
|
+
"popover": "#ffffff",
|
|
20
|
+
"popover-foreground": "#16232e",
|
|
21
|
+
"primary": "#0a7ea4",
|
|
22
|
+
"primary-foreground": "#ffffff",
|
|
23
|
+
"secondary": "#f6f8f9",
|
|
24
|
+
"secondary-foreground": "#3d4c58",
|
|
25
|
+
"muted": "#f6f8f9",
|
|
26
|
+
"muted-foreground": "#5a6a76",
|
|
27
|
+
"accent": "#e9f5f9",
|
|
28
|
+
"accent-foreground": "#08637f",
|
|
29
|
+
"border": "#e4e9ed",
|
|
30
|
+
"input": "#cfd8de",
|
|
31
|
+
"ring": "#0a7ea4",
|
|
32
|
+
"destructive": "#b83232",
|
|
33
|
+
"destructive-foreground": "#ffffff",
|
|
34
|
+
"success": "#08637f",
|
|
35
|
+
"success-foreground": "#ffffff",
|
|
36
|
+
"warning": "#8a5a0a",
|
|
37
|
+
"warning-foreground": "#ffffff",
|
|
38
|
+
"info": "#6d5ae0",
|
|
39
|
+
"info-foreground": "#ffffff",
|
|
40
|
+
"sidebar": "#f6f8f9",
|
|
41
|
+
"sidebar-foreground": "#16232e",
|
|
42
|
+
"sidebar-primary": "#0a7ea4",
|
|
43
|
+
"sidebar-primary-foreground": "#ffffff",
|
|
44
|
+
"sidebar-accent": "#eaeff2",
|
|
45
|
+
"sidebar-accent-foreground": "#16232e",
|
|
46
|
+
"sidebar-border": "#e4e9ed",
|
|
47
|
+
"sidebar-ring": "#0a7ea4",
|
|
48
|
+
"chart-1": "#0a7ea4",
|
|
49
|
+
"chart-2": "#eb6834",
|
|
50
|
+
"chart-3": "#6d5ae0",
|
|
51
|
+
"chart-4": "#eda100",
|
|
52
|
+
"chart-5": "#d55181",
|
|
53
|
+
"radius": "0.5rem",
|
|
54
|
+
"font-sans": "'Inter', ui-sans-serif, system-ui, sans-serif"
|
|
55
|
+
},
|
|
56
|
+
"dark": {
|
|
57
|
+
"background": "#0f1519",
|
|
58
|
+
"foreground": "#e6edf2",
|
|
59
|
+
"card": "#171f25",
|
|
60
|
+
"card-foreground": "#e6edf2",
|
|
61
|
+
"popover": "#171f25",
|
|
62
|
+
"popover-foreground": "#e6edf2",
|
|
63
|
+
"primary": "#3fb0d8",
|
|
64
|
+
"primary-foreground": "#0f1519",
|
|
65
|
+
"secondary": "#171f25",
|
|
66
|
+
"secondary-foreground": "#c3ced6",
|
|
67
|
+
"muted": "#171f25",
|
|
68
|
+
"muted-foreground": "#93a3ae",
|
|
69
|
+
"accent": "#12303a",
|
|
70
|
+
"accent-foreground": "#72c8e6",
|
|
71
|
+
"border": "#26323b",
|
|
72
|
+
"input": "#364550",
|
|
73
|
+
"ring": "#3fb0d8",
|
|
74
|
+
"destructive": "#f08080",
|
|
75
|
+
"destructive-foreground": "#0f1519",
|
|
76
|
+
"success": "#72c8e6",
|
|
77
|
+
"success-foreground": "#0f1519",
|
|
78
|
+
"warning": "#f0c060",
|
|
79
|
+
"warning-foreground": "#0f1519",
|
|
80
|
+
"info": "#9a8cf0",
|
|
81
|
+
"info-foreground": "#0f1519",
|
|
82
|
+
"sidebar": "#141b20",
|
|
83
|
+
"sidebar-foreground": "#e6edf2",
|
|
84
|
+
"sidebar-primary": "#3fb0d8",
|
|
85
|
+
"sidebar-primary-foreground": "#0f1519",
|
|
86
|
+
"sidebar-accent": "#202a31",
|
|
87
|
+
"sidebar-accent-foreground": "#e6edf2",
|
|
88
|
+
"sidebar-border": "#26323b",
|
|
89
|
+
"sidebar-ring": "#3fb0d8",
|
|
90
|
+
"chart-1": "#2a9ac4",
|
|
91
|
+
"chart-2": "#d95926",
|
|
92
|
+
"chart-3": "#9085e9",
|
|
93
|
+
"chart-4": "#c98500",
|
|
94
|
+
"chart-5": "#d55181"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
millwork/nodes.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Components, the tree they become, and the diff between two trees.
|
|
2
|
+
|
|
3
|
+
A page function returns a ``Component``. ``build`` walks it into a ``Tree``:
|
|
4
|
+
a flat table of wire ``Node``s with stable ids, plus the handlers those nodes
|
|
5
|
+
bound. ``diff`` turns the previous and the next ``Tree`` into patch ops.
|
|
6
|
+
|
|
7
|
+
Identity: a node's id is its parent's id, a slash, and either its ``key`` or
|
|
8
|
+
its position among the parent's children, counting ``None`` children so a
|
|
9
|
+
conditional sibling does not shift the ones after it. The position carries
|
|
10
|
+
the component type, so a different component in the same place is a
|
|
11
|
+
remove and an add, never a prop update on the wrong kind of node.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import datetime as dt
|
|
17
|
+
import enum
|
|
18
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
19
|
+
from pathlib import PurePath
|
|
20
|
+
from typing import Any, ClassVar, Protocol, runtime_checkable
|
|
21
|
+
|
|
22
|
+
from pydantic import BaseModel, ConfigDict
|
|
23
|
+
from pydantic_core import to_jsonable_python
|
|
24
|
+
|
|
25
|
+
from millwork.protocol import AddOp, ChildrenOp, Node, PatchOp, RemoveOp, SetOp, UnsetOp
|
|
26
|
+
|
|
27
|
+
Handler = Callable[..., Any]
|
|
28
|
+
ROOT_ID = "root"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@runtime_checkable
|
|
32
|
+
class Wireable(Protocol):
|
|
33
|
+
"""Anything that knows how to appear in props: tasks, file references."""
|
|
34
|
+
|
|
35
|
+
def __millwork_wire__(self) -> Any: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Component(BaseModel):
|
|
39
|
+
"""Base of every catalogue component. Subclasses set ``type``.
|
|
40
|
+
|
|
41
|
+
Children are positional: ``Card(Text("a"), Button("b"), title="t")``. A
|
|
42
|
+
bare string child becomes a ``Text``; ``None`` is skipped but keeps its
|
|
43
|
+
position. Props named ``on_*`` are handlers and never reach the wire;
|
|
44
|
+
the wire sees ``on_*: true`` so the bundle knows which events to emit.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
|
48
|
+
|
|
49
|
+
type: ClassVar[str] = "component"
|
|
50
|
+
key: str | None = None
|
|
51
|
+
hidden: bool = False
|
|
52
|
+
cls: str | None = None
|
|
53
|
+
children: list[Any] = []
|
|
54
|
+
|
|
55
|
+
def __init__(self, *children: Any, **props: Any) -> None:
|
|
56
|
+
if children and "children" in props:
|
|
57
|
+
raise TypeError("children are positional or keyword, not both")
|
|
58
|
+
if children:
|
|
59
|
+
props["children"] = list(children)
|
|
60
|
+
if "children" in props:
|
|
61
|
+
props["children"] = _flatten(props["children"])
|
|
62
|
+
super().__init__(**props)
|
|
63
|
+
|
|
64
|
+
# Fields that never reach the wire as they are (a callable evaluated at
|
|
65
|
+
# render, say); ``wire_extra`` supplies what goes instead.
|
|
66
|
+
wire_exclude: ClassVar[frozenset[str]] = frozenset()
|
|
67
|
+
|
|
68
|
+
def wire_extra(self) -> dict[str, Any]:
|
|
69
|
+
"""Extra props computed at render time. Override in components that need it."""
|
|
70
|
+
return {}
|
|
71
|
+
|
|
72
|
+
def wire_type(self) -> str:
|
|
73
|
+
"""The node type on the wire; a custom component names its own."""
|
|
74
|
+
return self.type
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Text(Component):
|
|
78
|
+
type: ClassVar[str] = "Text"
|
|
79
|
+
content: str = ""
|
|
80
|
+
variant: str = "body"
|
|
81
|
+
truncate: bool = False
|
|
82
|
+
align: str | None = None
|
|
83
|
+
tone: str | None = None
|
|
84
|
+
|
|
85
|
+
def __init__(self, content: str = "", **props: Any) -> None:
|
|
86
|
+
super().__init__(content=content, **props)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Tree:
|
|
90
|
+
"""A built page: flat nodes, the handlers they bound, and the root id."""
|
|
91
|
+
|
|
92
|
+
def __init__(self) -> None:
|
|
93
|
+
self.root: str = ROOT_ID
|
|
94
|
+
self.nodes: dict[str, Node] = {}
|
|
95
|
+
self.handlers: dict[tuple[str, str], Handler] = {}
|
|
96
|
+
self.callables: dict[tuple[str, str], Callable[..., Any]] = {}
|
|
97
|
+
self.components: dict[str, Component] = {}
|
|
98
|
+
# Nodes whose children include a run of keyless siblings of one type, and
|
|
99
|
+
# that type: a list built from data, if the run grows or shrinks later.
|
|
100
|
+
self.keyless_runs: dict[str, str] = {}
|
|
101
|
+
|
|
102
|
+
def handler(self, node_id: str, event: str) -> Handler | None:
|
|
103
|
+
return self.handlers.get((node_id, event))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _flatten(children: Any) -> list[Any]:
|
|
107
|
+
"""Lists and tuples among children are spliced in, so a helper may return a list."""
|
|
108
|
+
out: list[Any] = []
|
|
109
|
+
for child in children:
|
|
110
|
+
if isinstance(child, list | tuple):
|
|
111
|
+
out.extend(_flatten(child))
|
|
112
|
+
else:
|
|
113
|
+
out.append(child)
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def build(root: Component, root_id: str = ROOT_ID) -> Tree:
|
|
118
|
+
tree = Tree()
|
|
119
|
+
tree.root = root_id
|
|
120
|
+
_walk(root, root_id, tree)
|
|
121
|
+
return tree
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _walk(component: Component, node_id: str, tree: Tree) -> None:
|
|
125
|
+
props: dict[str, Any] = {}
|
|
126
|
+
for name, value in component:
|
|
127
|
+
if name in ("children", "key") or name in component.wire_exclude:
|
|
128
|
+
continue
|
|
129
|
+
if callable(value) and not isinstance(value, type):
|
|
130
|
+
if name.startswith("on_"):
|
|
131
|
+
tree.handlers[(node_id, name[3:])] = value
|
|
132
|
+
props[name] = True
|
|
133
|
+
else:
|
|
134
|
+
tree.callables[(node_id, name)] = value
|
|
135
|
+
props[name] = {"$callable": True}
|
|
136
|
+
continue
|
|
137
|
+
if isinstance(value, Component):
|
|
138
|
+
slot_id = f"{node_id}/@{name}"
|
|
139
|
+
_walk(value, slot_id, tree)
|
|
140
|
+
props[name] = {"$node": slot_id}
|
|
141
|
+
continue
|
|
142
|
+
if isinstance(value, list) and value and all(isinstance(v, Component) for v in value):
|
|
143
|
+
items: list[Any] = []
|
|
144
|
+
for index, item in enumerate(value):
|
|
145
|
+
slot_id = f"{node_id}/@{name}.{item.key or index}"
|
|
146
|
+
if slot_id in tree.nodes:
|
|
147
|
+
raise ValueError(f"duplicate key {item.key!r} in {name} of {node_id}")
|
|
148
|
+
_walk(item, slot_id, tree)
|
|
149
|
+
items.append({"$node": slot_id})
|
|
150
|
+
props[name] = items
|
|
151
|
+
continue
|
|
152
|
+
if value is None and name not in component.model_fields_set:
|
|
153
|
+
# Absent on the wire: a prop the page never set. An explicit ``value=None``
|
|
154
|
+
# is sent as null and means "controlled, and empty".
|
|
155
|
+
continue
|
|
156
|
+
props[name] = wire_value(value)
|
|
157
|
+
for name, value in component.wire_extra().items():
|
|
158
|
+
if isinstance(value, Component):
|
|
159
|
+
slot_id = f"{node_id}/@{name}"
|
|
160
|
+
_walk(value, slot_id, tree)
|
|
161
|
+
props[name] = {"$node": slot_id}
|
|
162
|
+
elif (
|
|
163
|
+
isinstance(value, dict)
|
|
164
|
+
and value
|
|
165
|
+
and all(isinstance(v, Component) for v in value.values())
|
|
166
|
+
):
|
|
167
|
+
slots: dict[str, Any] = {}
|
|
168
|
+
for key, child in value.items():
|
|
169
|
+
slot_id = f"{node_id}/@{name}.{key}"
|
|
170
|
+
_walk(child, slot_id, tree)
|
|
171
|
+
slots[str(key)] = {"$node": slot_id}
|
|
172
|
+
props[name] = slots
|
|
173
|
+
else:
|
|
174
|
+
props[name] = wire_value(value)
|
|
175
|
+
for event, handler in getattr(component, "events", {}).items():
|
|
176
|
+
tree.handlers[(node_id, event)] = handler
|
|
177
|
+
props[f"on_{event}"] = True
|
|
178
|
+
if props.get("hidden") is False:
|
|
179
|
+
del props["hidden"]
|
|
180
|
+
children_ids: list[str] = []
|
|
181
|
+
for index, child in enumerate(component.children):
|
|
182
|
+
if child is None:
|
|
183
|
+
continue
|
|
184
|
+
if isinstance(child, str):
|
|
185
|
+
child = Text(child)
|
|
186
|
+
if not isinstance(child, Component):
|
|
187
|
+
raise TypeError(
|
|
188
|
+
f"{type(component).__name__} child {index} is {type(child).__name__}, "
|
|
189
|
+
"not a component or a string"
|
|
190
|
+
)
|
|
191
|
+
child_id = (
|
|
192
|
+
f"{node_id}/{child.key}" if child.key else f"{node_id}/{index}:{child.wire_type()}"
|
|
193
|
+
)
|
|
194
|
+
if child_id in tree.nodes:
|
|
195
|
+
raise ValueError(f"duplicate key {child.key!r} under {node_id}")
|
|
196
|
+
children_ids.append(child_id)
|
|
197
|
+
_walk(child, child_id, tree)
|
|
198
|
+
_check_keys(component, node_id, tree)
|
|
199
|
+
tree.nodes[node_id] = Node(
|
|
200
|
+
id=node_id, type=component.wire_type(), props=props, children=children_ids
|
|
201
|
+
)
|
|
202
|
+
tree.components[node_id] = component
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def wire_value(value: Any) -> Any:
|
|
206
|
+
"""Turn a prop value into JSON-ready data."""
|
|
207
|
+
if value is None or isinstance(value, bool | int | float | str):
|
|
208
|
+
return value
|
|
209
|
+
if isinstance(value, Wireable):
|
|
210
|
+
return value.__millwork_wire__()
|
|
211
|
+
if isinstance(value, BaseModel):
|
|
212
|
+
return value.model_dump(mode="json")
|
|
213
|
+
if isinstance(value, enum.Enum):
|
|
214
|
+
return value.value
|
|
215
|
+
if isinstance(value, dt.datetime | dt.date | dt.time):
|
|
216
|
+
return value.isoformat()
|
|
217
|
+
if isinstance(value, PurePath):
|
|
218
|
+
return str(value)
|
|
219
|
+
if isinstance(value, Mapping):
|
|
220
|
+
return {str(k): wire_value(v) for k, v in value.items()}
|
|
221
|
+
if isinstance(value, Iterable) and not isinstance(value, bytes | bytearray):
|
|
222
|
+
return [wire_value(v) for v in value]
|
|
223
|
+
try:
|
|
224
|
+
return to_jsonable_python(value)
|
|
225
|
+
except Exception as exc:
|
|
226
|
+
raise TypeError(f"cannot send a {type(value).__name__} as a prop") from exc
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def diff(old: Tree | None, new: Tree) -> list[PatchOp]:
|
|
230
|
+
"""Patch ops that take the client from ``old`` to ``new``.
|
|
231
|
+
|
|
232
|
+
Adds come first so references resolve, then updates, then removes.
|
|
233
|
+
"""
|
|
234
|
+
if old is None:
|
|
235
|
+
raise ValueError("diff needs a previous tree; use a mount for the first render")
|
|
236
|
+
ops: list[PatchOp] = []
|
|
237
|
+
for node_id, node in new.nodes.items():
|
|
238
|
+
if node_id not in old.nodes:
|
|
239
|
+
ops.append(AddOp(node=node))
|
|
240
|
+
for node_id, node in new.nodes.items():
|
|
241
|
+
before = old.nodes.get(node_id)
|
|
242
|
+
if before is None:
|
|
243
|
+
continue
|
|
244
|
+
if before.type != node.type:
|
|
245
|
+
ops.append(RemoveOp(id=node_id))
|
|
246
|
+
ops.append(AddOp(node=node))
|
|
247
|
+
continue
|
|
248
|
+
changed = {k: v for k, v in node.props.items() if before.props.get(k, _MISSING) != v}
|
|
249
|
+
if changed:
|
|
250
|
+
ops.append(SetOp(id=node_id, props=changed))
|
|
251
|
+
gone = [k for k in before.props if k not in node.props]
|
|
252
|
+
if gone:
|
|
253
|
+
ops.append(UnsetOp(id=node_id, keys=gone))
|
|
254
|
+
if before.children != node.children:
|
|
255
|
+
ops.append(ChildrenOp(id=node_id, children=node.children))
|
|
256
|
+
for node_id in old.nodes:
|
|
257
|
+
if node_id not in new.nodes:
|
|
258
|
+
ops.append(RemoveOp(id=node_id))
|
|
259
|
+
return ops
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def apply(nodes: dict[str, Node], ops: list[PatchOp]) -> dict[str, Node]:
|
|
263
|
+
"""Apply patch ops to a node table, the way the bundle does. For tests."""
|
|
264
|
+
out = {k: v.model_copy(deep=True) for k, v in nodes.items()}
|
|
265
|
+
for op in ops:
|
|
266
|
+
if isinstance(op, AddOp):
|
|
267
|
+
out[op.node.id] = op.node.model_copy(deep=True)
|
|
268
|
+
elif isinstance(op, RemoveOp):
|
|
269
|
+
out.pop(op.id, None)
|
|
270
|
+
elif isinstance(op, SetOp):
|
|
271
|
+
out[op.id].props.update(op.props)
|
|
272
|
+
elif isinstance(op, UnsetOp):
|
|
273
|
+
for k in op.keys:
|
|
274
|
+
out[op.id].props.pop(k, None)
|
|
275
|
+
elif isinstance(op, ChildrenOp):
|
|
276
|
+
out[op.id].children = list(op.children)
|
|
277
|
+
return out
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
_MISSING = object()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# A run of this many keyless siblings of one type may be a list built from data,
|
|
284
|
+
# where a reorder would re-render every row instead of moving them. It is one
|
|
285
|
+
# only when its length changes between renders; a static layout never does.
|
|
286
|
+
KEYLESS_RUN = 4
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _check_keys(component: Component, node_id: str, tree: Tree) -> None:
|
|
290
|
+
run: list[Component] = []
|
|
291
|
+
for child in component.children:
|
|
292
|
+
if isinstance(child, Component) and child.key is None:
|
|
293
|
+
if run and type(child) is not type(run[-1]):
|
|
294
|
+
run = []
|
|
295
|
+
run.append(child)
|
|
296
|
+
if len(run) == KEYLESS_RUN:
|
|
297
|
+
tree.keyless_runs[node_id] = child.wire_type()
|
|
298
|
+
return
|
|
299
|
+
else:
|
|
300
|
+
run = []
|