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,151 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: State and handlers
|
|
3
|
+
order: 1
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# State and handlers
|
|
7
|
+
|
|
8
|
+
A page is a function of the session's state. A handler changes that state;
|
|
9
|
+
the page renders again; the browser gets the difference. There is nothing
|
|
10
|
+
reactive to subscribe to and nothing to invalidate: whatever a page shows
|
|
11
|
+
comes from `s.state`, and the only place where things change is a handler.
|
|
12
|
+
|
|
13
|
+
## The loop
|
|
14
|
+
|
|
15
|
+
`s.state` is an instance of the model named on the page, or on the app for
|
|
16
|
+
every page. A handler is any callable bound to an `on_*` prop. It receives
|
|
17
|
+
the session and the event's payload: the new value for `on_change`, the
|
|
18
|
+
validated model for a form's `on_submit`, nothing of use for `on_click`.
|
|
19
|
+
It may be sync or async. When it returns, the page renders once, under a
|
|
20
|
+
lock, so a render sees state as a handler left it.
|
|
21
|
+
|
|
22
|
+
```python live
|
|
23
|
+
from pydantic import BaseModel
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Counter(BaseModel):
|
|
27
|
+
n: int = 0
|
|
28
|
+
note: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def bump(s, _):
|
|
32
|
+
state(Counter).n += 1
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def remember(s, text):
|
|
36
|
+
state(Counter).note = text
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def example(s):
|
|
40
|
+
st = state(Counter)
|
|
41
|
+
return ui.Stack(
|
|
42
|
+
ui.Row(
|
|
43
|
+
ui.Button("Count", on_click=bump),
|
|
44
|
+
ui.Text(f"{st.n} so far", variant="muted"),
|
|
45
|
+
gap=3,
|
|
46
|
+
),
|
|
47
|
+
ui.Input(value=st.note, placeholder="Type, then look right", on_change=remember),
|
|
48
|
+
ui.Text(f"Python holds {st.note!r}", variant="muted"),
|
|
49
|
+
gap=3,
|
|
50
|
+
)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
A handler that raises shows "Something went wrong." as a toast and the log
|
|
54
|
+
has the traceback; the page stays as it was. A handler that needs to ask
|
|
55
|
+
first uses `s.confirm` or `s.ask`, in the Feedback chapter.
|
|
56
|
+
|
|
57
|
+
## Keys
|
|
58
|
+
|
|
59
|
+
Every component takes a `key`. A key is a component's identity across
|
|
60
|
+
renders: a keyed node keeps its browser-side state (what is typed, which
|
|
61
|
+
tab is open, where a list is scrolled) when the tree around it changes,
|
|
62
|
+
and a list of keyed rows that is reordered moves the rows instead of
|
|
63
|
+
redrawing them. Siblings without keys are told apart by position, which is
|
|
64
|
+
right for a fixed layout and wrong for a list built from data.
|
|
65
|
+
|
|
66
|
+
```python live
|
|
67
|
+
from pydantic import BaseModel
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Todo(BaseModel):
|
|
71
|
+
items: list[str] = ["Read the manual", "Build a page", "Ship it"]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def drop(s, item):
|
|
75
|
+
state(Todo).items.remove(item)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def example(s):
|
|
79
|
+
st = state(Todo)
|
|
80
|
+
return ui.Stack(
|
|
81
|
+
*(
|
|
82
|
+
ui.Row(
|
|
83
|
+
ui.Text(item),
|
|
84
|
+
ui.Button(icon="x", size="sm", variant="ghost", on_click=lambda s, _, i=item: drop(s, i)),
|
|
85
|
+
justify="between",
|
|
86
|
+
key=item,
|
|
87
|
+
)
|
|
88
|
+
for item in st.items
|
|
89
|
+
),
|
|
90
|
+
ui.Button(
|
|
91
|
+
"Put them back",
|
|
92
|
+
size="sm",
|
|
93
|
+
variant="outline",
|
|
94
|
+
on_click=lambda s, _: setattr(st, "items", Todo().items),
|
|
95
|
+
),
|
|
96
|
+
gap=2,
|
|
97
|
+
)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
A key is also how `s.reset(key)` returns a browser-owned control to its
|
|
101
|
+
`default`, and how `ui.Button(form=key)` finds its form. `hidden=True`
|
|
102
|
+
keeps a component in the tree without showing it, so its browser-side
|
|
103
|
+
state survives; `cls` adds Tailwind classes for a spacing or sizing tweak.
|
|
104
|
+
|
|
105
|
+
## Who owns a value
|
|
106
|
+
|
|
107
|
+
A control shows either what Python says (`value=`, with every change
|
|
108
|
+
reported through `on_change`) or what the browser holds (`default=`, with
|
|
109
|
+
changes reported but not required), one or the other. The Inputs chapter shows
|
|
110
|
+
the two side by side. Inside a `ui.Form` every control is browser-owned
|
|
111
|
+
draft state until the form is submitted.
|
|
112
|
+
|
|
113
|
+
## Reaching the session from anywhere
|
|
114
|
+
|
|
115
|
+
Handlers get the session as their first argument. Code further down, a
|
|
116
|
+
helper or a library that wants to show a toast, calls `mw.session()`: it
|
|
117
|
+
returns the session of the handler or task that is running, on the loop
|
|
118
|
+
or in the thread of a sync task.
|
|
119
|
+
|
|
120
|
+
```python live
|
|
121
|
+
import millwork as mw
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def notify(what: str) -> None:
|
|
125
|
+
mw.session().toast(what, tone="success")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def example(s):
|
|
129
|
+
return ui.Button("Call a helper", variant="outline", on_click=lambda s, _: notify("The helper found the session."))
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`s.refresh()` asks for a render from code that is not a handler, such as
|
|
133
|
+
work spawned on the loop that changes state on its own. Many calls in one
|
|
134
|
+
tick are one render.
|
|
135
|
+
|
|
136
|
+
## What the framework notices
|
|
137
|
+
|
|
138
|
+
Some mistakes the framework can see, and says so: once per session, as a
|
|
139
|
+
log line with the page's path, and in debug mode as a toast as well.
|
|
140
|
+
|
|
141
|
+
- **A slow page function**, over 20 ms. The page function builds the tree;
|
|
142
|
+
I/O belongs in `connect` or a task.
|
|
143
|
+
- **A list without keys**: four or more siblings of one type without a
|
|
144
|
+
key whose number changed between renders.
|
|
145
|
+
- **A handler that changed nothing**: no state, no command, no task.
|
|
146
|
+
Usually a forgotten `await`.
|
|
147
|
+
- **A sync task that changed state from its thread.** A task reports
|
|
148
|
+
through `progress()` and returns a result; the handler that awaits it
|
|
149
|
+
puts the result in state.
|
|
150
|
+
|
|
151
|
+
The Running chapter says how to turn debug mode on.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Testing
|
|
3
|
+
order: 4
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Testing
|
|
7
|
+
|
|
8
|
+
A page is a function of state, handlers change state, and the wire is a
|
|
9
|
+
list of messages. So a test runs the real session, dispatcher, tasks and
|
|
10
|
+
render with no socket and no browser: `millwork.testing.TestApp` opens a
|
|
11
|
+
page, a test clicks and types through the real event path, and reads the
|
|
12
|
+
state and the messages a tab would have received.
|
|
13
|
+
|
|
14
|
+
## A test
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from millwork import ui
|
|
18
|
+
from millwork.testing import TestApp
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def test_a_click_counts(app):
|
|
22
|
+
async with TestApp(app).open("/") as page:
|
|
23
|
+
assert page.find("count").props["content"] == "count 0"
|
|
24
|
+
await page.click("more")
|
|
25
|
+
assert page.state.n == 1
|
|
26
|
+
assert page.find("count").props["content"] == "count 1"
|
|
27
|
+
assert page.last_patch is not None
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Tests are `async def`, run by pytest-asyncio in auto mode. `open(path)` runs the page's `connect` and the first render and
|
|
31
|
+
yields a `Page`; leaving the block closes the session, `close` hook
|
|
32
|
+
included. `open(path, user=User(...))` signs a person in; without one a
|
|
33
|
+
gated page raises `Forbidden`, which is a test of its own. `open(path,
|
|
34
|
+
state=State(...))` starts from a prepared state and skips `connect`.
|
|
35
|
+
|
|
36
|
+
## Finding things
|
|
37
|
+
|
|
38
|
+
`page.find(ui.Table)` is the first node of a type, `page.find("more")` the
|
|
39
|
+
node with that key, `find_all` every one of them. A found node has `id`,
|
|
40
|
+
`type`, `props` as they would go to the tab, `children` and, on a table,
|
|
41
|
+
`rows`. `page.text()` is every string on the page, for a quick "is it
|
|
42
|
+
there". `page.state` is the live state and `page.session` the session
|
|
43
|
+
itself.
|
|
44
|
+
|
|
45
|
+
## Acting
|
|
46
|
+
|
|
47
|
+
Each action is an event through the real dispatcher, followed by the real
|
|
48
|
+
render: `click(node)`, `change(node, value)`, `submit(form, draft)` with a
|
|
49
|
+
dict or a model instance, `select(table, keys)`, `action(node, id, key)`
|
|
50
|
+
for a row or menu action, `upload(node, path, field=)` which runs every
|
|
51
|
+
check an upload gets and returns the `FileRef`, `send(chat, text,
|
|
52
|
+
files=)`, `stop(chat)`, `navigate(path)`, and `event(node, name, payload)`
|
|
53
|
+
for anything else. A draft that fails validation produces the same
|
|
54
|
+
`form_issues` command the tab would get, at the same pointers.
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
async def test_the_form_rejects_a_short_name(app):
|
|
58
|
+
async with TestApp(app).open("/") as page:
|
|
59
|
+
form = page.find(ui.Form)
|
|
60
|
+
await page.submit(form, {"project_name": "x"})
|
|
61
|
+
assert page.commands[-1].name == "form_issues"
|
|
62
|
+
assert page.commands[-1].args["issues"][0]["pointer"] == "/project_name"
|
|
63
|
+
assert page.state.settings is None
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Questions and tasks
|
|
67
|
+
|
|
68
|
+
A handler that calls `s.confirm` or `s.ask` stops and waits; the test sees
|
|
69
|
+
the dialog on the page and answers it with `page.answer(value)`, `False`
|
|
70
|
+
or `None` for a dismissal. A task started by a handler has run by the time
|
|
71
|
+
the action returns: `page.session.tasks[-1]` has its `ok`, `result` and
|
|
72
|
+
`progress`. A chat turn's chunks are in `page.stream`, in order.
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
async def test_wiping_asks_first(app):
|
|
76
|
+
async with TestApp(app).open("/") as page:
|
|
77
|
+
await page.click("wipe")
|
|
78
|
+
assert page.find(ui.Dialog).props["title"] == "Wipe?"
|
|
79
|
+
await page.answer(False)
|
|
80
|
+
assert page.state.count == 0
|
|
81
|
+
await page.click("wipe")
|
|
82
|
+
await page.answer(True)
|
|
83
|
+
assert page.state.count == -1
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## What the tab would have seen
|
|
87
|
+
|
|
88
|
+
`page.messages` is every message the server sent: `patches`, `last_patch`,
|
|
89
|
+
`commands` (toasts, downloads, navigations, form issues), `toasts` as
|
|
90
|
+
plain text, `stream` and `errors` are views on it. `settle()` waits until
|
|
91
|
+
the session has nothing left to do: running tasks and spawned work are
|
|
92
|
+
awaited, the coalesced render runs at once, and a handler waiting on an
|
|
93
|
+
open question is left for `answer`. Every action settles before it
|
|
94
|
+
returns, so a test reads the result on the next line.
|
|
95
|
+
|
|
96
|
+
## Running one here
|
|
97
|
+
|
|
98
|
+
The manual can run a test on itself. The button below builds a small app,
|
|
99
|
+
opens it through `TestApp`, clicks, and reports what the assertions said.
|
|
100
|
+
|
|
101
|
+
```python live
|
|
102
|
+
import millwork as mw
|
|
103
|
+
from pydantic import BaseModel
|
|
104
|
+
from millwork.testing import TestApp
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Count(BaseModel):
|
|
108
|
+
n: int = 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def tiny_app() -> mw.App:
|
|
112
|
+
app = mw.App("tiny", state=Count, public=None)
|
|
113
|
+
|
|
114
|
+
@app.page("/")
|
|
115
|
+
def home(s):
|
|
116
|
+
return ui.Page(
|
|
117
|
+
ui.Text(f"count {s.state.n}", key="count"),
|
|
118
|
+
ui.Button("more", key="more", on_click=lambda s, _: setattr(s.state, "n", s.state.n + 1)),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return app
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Outcome(BaseModel):
|
|
125
|
+
text: str = "Not run yet."
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def run_test(s, _):
|
|
129
|
+
try:
|
|
130
|
+
async with TestApp(tiny_app()).open("/") as page:
|
|
131
|
+
assert page.find("count").props["content"] == "count 0"
|
|
132
|
+
await page.click("more")
|
|
133
|
+
assert page.state.n == 1
|
|
134
|
+
assert page.find("count").props["content"] == "count 1"
|
|
135
|
+
ops = [op.op for op in page.last_patch.ops]
|
|
136
|
+
state(Outcome).text = f"Passed. The click produced a patch with {ops}."
|
|
137
|
+
except AssertionError as exc:
|
|
138
|
+
state(Outcome).text = f"Failed: {exc}"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def example(s):
|
|
142
|
+
return ui.Row(
|
|
143
|
+
ui.Button("Run the test", icon="play", on_click=run_test),
|
|
144
|
+
ui.Text(state(Outcome).text, variant="muted"),
|
|
145
|
+
gap=4,
|
|
146
|
+
)
|
|
147
|
+
```
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Theming
|
|
3
|
+
order: 3
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Theming
|
|
7
|
+
|
|
8
|
+
An app's look is one JSON file: `public/theme.json`, found on its own, or
|
|
9
|
+
any path given as `App(theme=...)`. It holds the colour tokens for light
|
|
10
|
+
and dark, the fonts, the logo and a few options. The server renders it
|
|
11
|
+
into the page before the bundle loads, so there is no flash of the default
|
|
12
|
+
look. Without a file the app looks like stock shadcn.
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"mode": "system",
|
|
17
|
+
"fonts": [{"family": "Inter", "src": "/public/fonts/InterVariable.woff2", "weights": "100 900"}],
|
|
18
|
+
"tokens": {
|
|
19
|
+
"light": {"primary": "#0a7ea4", "primary-foreground": "#ffffff", "radius": "0.5rem",
|
|
20
|
+
"font-sans": "'Inter', ui-sans-serif, system-ui, sans-serif"},
|
|
21
|
+
"dark": {"primary": "#2a9ac4"}
|
|
22
|
+
},
|
|
23
|
+
"logo": {"light": "/public/logo.svg", "dark": "/public/logo-dark.svg"},
|
|
24
|
+
"favicon": "/public/favicon.svg",
|
|
25
|
+
"login_image": "/public/login.jpg",
|
|
26
|
+
"density": "comfortable"
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Tokens
|
|
31
|
+
|
|
32
|
+
The tokens are shadcn's: `background`, `foreground`, `card`, `popover`,
|
|
33
|
+
`primary`, `secondary`, `muted`, `accent`, `destructive`, each with its
|
|
34
|
+
`-foreground`, then `border`, `input`, `ring`, `chart-1` to `chart-5`, the
|
|
35
|
+
`sidebar-*` group, `radius`, `font-sans`, `font-mono` and `font-heading`.
|
|
36
|
+
Three are millwork's own, with their `-foreground`: `success` and
|
|
37
|
+
`warning`, which the catalogue's `tone` vocabulary needs, and `info`, which
|
|
38
|
+
a custom component may use as `bg-info text-info-foreground`. A value is
|
|
39
|
+
one CSS value, hex, `oklch(...)`, a length or a font stack. A token left
|
|
40
|
+
out keeps the default, so a brand is four or five values. An unknown
|
|
41
|
+
token, or a value CSS could misuse, is refused when the app starts.
|
|
42
|
+
|
|
43
|
+
Light and dark are both always there: `mode` is `light`, `dark` or
|
|
44
|
+
`system`, and the user menu in the header lets a person choose, kept in
|
|
45
|
+
their browser. The tones below take their colours from this manual's
|
|
46
|
+
theme file, in whichever scheme you are reading.
|
|
47
|
+
|
|
48
|
+
```python live
|
|
49
|
+
ui.Stack(
|
|
50
|
+
ui.Row(
|
|
51
|
+
ui.Badge("primary", tone="primary"),
|
|
52
|
+
ui.Badge("success", tone="success", icon="check"),
|
|
53
|
+
ui.Badge("warning", tone="warning"),
|
|
54
|
+
ui.Badge("destructive", tone="destructive"),
|
|
55
|
+
ui.Badge("accent", tone="accent"),
|
|
56
|
+
gap=2,
|
|
57
|
+
wrap=True,
|
|
58
|
+
),
|
|
59
|
+
ui.Alert(title="Saved", description="A success alert takes the success tokens.", tone="success"),
|
|
60
|
+
gap=3,
|
|
61
|
+
)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The five chart tokens are read in order by the charts. Pick them as a set
|
|
65
|
+
that stays apart for colour-blind readers in both schemes.
|
|
66
|
+
|
|
67
|
+
## Fonts
|
|
68
|
+
|
|
69
|
+
A font is a file under `public/`, served by the app: `family`, `src`,
|
|
70
|
+
`weights` (one weight or a range) and `style`. It becomes an `@font-face`
|
|
71
|
+
rule, and the `font-sans` token names it. A `url` to a font service is
|
|
72
|
+
accepted instead of `src` and logged once at start, as a reminder that
|
|
73
|
+
every page view then calls a third party.
|
|
74
|
+
|
|
75
|
+
## Logo, favicon and login image
|
|
76
|
+
|
|
77
|
+
`logo` is one path, or `light` and `dark` paths; the `ui.Header` shows it
|
|
78
|
+
unless `logo=False`, and `ui.Logo` puts it on a page of the app's own, an
|
|
79
|
+
error page for instance. `favicon` and `login_image` are paths under
|
|
80
|
+
`/public/` too. The login page and the framework's error pages show the
|
|
81
|
+
logo and the app's name. A theme sets colours, fonts and these images;
|
|
82
|
+
layout belongs to the components.
|
|
83
|
+
|
|
84
|
+
## Options
|
|
85
|
+
|
|
86
|
+
`density` is `comfortable` or `compact`; compact tightens tables, forms,
|
|
87
|
+
stacks and cards for data tools. `scrollbars` is `thin` (the default, in
|
|
88
|
+
the page's own ink) or `auto` for the platform's. `locale` sets the page
|
|
89
|
+
language and how dates and numbers are formatted.
|
|
90
|
+
|
|
91
|
+
## Strings
|
|
92
|
+
|
|
93
|
+
Interface copy comes from the app's own Python, in its own language. The
|
|
94
|
+
framework has strings of its own: the bundle's controls (`send`, `stop`,
|
|
95
|
+
`attach`, `search`, `no_results`, `clear`, `loading`, `reconnecting`,
|
|
96
|
+
`sign_in`, `username`, `password`) and the messages the server sends on
|
|
97
|
+
its own account (`wait_for_reply`, `message_too_long`, `something_went_wrong`,
|
|
98
|
+
`too_many_sessions`, `signed_out` and the rest). They are English; an app in
|
|
99
|
+
another language sets them in the theme's `strings` section, key by key,
|
|
100
|
+
and a key the framework does not know is refused. Every key is in the
|
|
101
|
+
table below, with its English.
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{"locale": "nl", "strings": {"send": "Verstuur", "stop": "Stop", "search": "Zoeken"}}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```python live
|
|
108
|
+
from millwork.theme import STRINGS
|
|
109
|
+
|
|
110
|
+
ui.Table(
|
|
111
|
+
rows=[{"key": k, "english": v} for k, v in sorted(STRINGS.items())],
|
|
112
|
+
row_key="key",
|
|
113
|
+
height=320,
|
|
114
|
+
)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## While designing
|
|
118
|
+
|
|
119
|
+
In debug mode the theme file is watched: a save reaches every open tab as
|
|
120
|
+
a command, without a reload. `millwork docs --theme path/to/theme.json`
|
|
121
|
+
shows this whole manual in a theme, which is the quickest way to see a
|
|
122
|
+
token set on every component in both schemes.
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Charts
|
|
3
|
+
order: 5
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Charts
|
|
7
|
+
|
|
8
|
+
A chart takes rows, like the Table does. `x` names the category or time
|
|
9
|
+
column and `y` the value column, or several of them. Each series takes the
|
|
10
|
+
next of the theme's five chart colours, in the order given, and keeps it
|
|
11
|
+
when a neighbour disappears. Five series is the limit: a sixth has no colour
|
|
12
|
+
of its own, so fold it into another or draw a second chart. Every chart has
|
|
13
|
+
a tooltip; a legend appears from two series up.
|
|
14
|
+
|
|
15
|
+
## Bar chart
|
|
16
|
+
|
|
17
|
+
`ui.BarChart` compares magnitudes by category. `labels=True` writes the
|
|
18
|
+
value at the end of each bar; `format` chooses `number`, `integer`,
|
|
19
|
+
`percent` or `currency`.
|
|
20
|
+
|
|
21
|
+
```python live
|
|
22
|
+
MONTHS = [
|
|
23
|
+
{"month": "Jan", "sales": 42, "costs": 31, "visits": 1200},
|
|
24
|
+
{"month": "Feb", "sales": 48, "costs": 30, "visits": 1350},
|
|
25
|
+
{"month": "Mar", "sales": 55, "costs": 34, "visits": 1600},
|
|
26
|
+
{"month": "Apr", "sales": 51, "costs": 35, "visits": 1500},
|
|
27
|
+
{"month": "May", "sales": 63, "costs": 38, "visits": 1900},
|
|
28
|
+
{"month": "Jun", "sales": 70, "costs": 41, "visits": 2100},
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
ui.BarChart(MONTHS, x="month", y={"sales": "Sales", "costs": "Costs"}, labels=True, height=220)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Stacked, percent and horizontal
|
|
35
|
+
|
|
36
|
+
`stacked=True` adds the series up; `stacked="percent"` draws every bar full
|
|
37
|
+
and shows each series' share. `horizontal=True` turns the chart on its side,
|
|
38
|
+
which suits long category names.
|
|
39
|
+
|
|
40
|
+
```python live
|
|
41
|
+
SHARE = [
|
|
42
|
+
{"channel": "Direct", "new": 120, "returning": 340},
|
|
43
|
+
{"channel": "Search engines", "new": 410, "returning": 190},
|
|
44
|
+
{"channel": "Social media", "new": 230, "returning": 80},
|
|
45
|
+
{"channel": "Newsletter", "new": 40, "returning": 260},
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
ui.Grid(
|
|
49
|
+
ui.BarChart(SHARE, x="channel", y=["new", "returning"], stacked=True, height=200),
|
|
50
|
+
ui.BarChart(
|
|
51
|
+
SHARE, x="channel", y=["new", "returning"], stacked="percent", horizontal=True, height=200
|
|
52
|
+
),
|
|
53
|
+
cols={"sm": 1, "md": 2},
|
|
54
|
+
)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Long rows
|
|
58
|
+
|
|
59
|
+
Data often arrives with one row per series and category. `by` names the
|
|
60
|
+
column that holds the series name, and `y` the one column with the values;
|
|
61
|
+
Python pivots the rows before they leave.
|
|
62
|
+
|
|
63
|
+
```python live
|
|
64
|
+
REGIONS = [
|
|
65
|
+
{"quarter": "Q1", "region": "North", "orders": 120},
|
|
66
|
+
{"quarter": "Q1", "region": "South", "orders": 90},
|
|
67
|
+
{"quarter": "Q2", "region": "North", "orders": 140},
|
|
68
|
+
{"quarter": "Q2", "region": "South", "orders": 110},
|
|
69
|
+
{"quarter": "Q3", "region": "North", "orders": 135},
|
|
70
|
+
{"quarter": "Q3", "region": "South", "orders": 150},
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
ui.BarChart(REGIONS, x="quarter", y="orders", by="region", height=200)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## A diverging scale
|
|
77
|
+
|
|
78
|
+
When the series are steps of one ordered scale, `diverging=True` draws them
|
|
79
|
+
to either side of the midpoint, coloured from one hue through grey to
|
|
80
|
+
another instead of five unrelated colours. The middle step, when there is
|
|
81
|
+
one, straddles the axis.
|
|
82
|
+
|
|
83
|
+
```python live
|
|
84
|
+
WEATHER = [
|
|
85
|
+
{"month": "Mar", "freezing": 4, "cold": 12, "mild": 10, "warm": 5, "hot": 0},
|
|
86
|
+
{"month": "Apr", "freezing": 1, "cold": 8, "mild": 14, "warm": 7, "hot": 0},
|
|
87
|
+
{"month": "May", "freezing": 0, "cold": 3, "mild": 12, "warm": 13, "hot": 3},
|
|
88
|
+
{"month": "Jun", "freezing": 0, "cold": 1, "mild": 8, "warm": 15, "hot": 6},
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
ui.BarChart(
|
|
92
|
+
WEATHER,
|
|
93
|
+
x="month",
|
|
94
|
+
y=["freezing", "cold", "mild", "warm", "hot"],
|
|
95
|
+
diverging=True,
|
|
96
|
+
horizontal=True,
|
|
97
|
+
height=200,
|
|
98
|
+
)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Emphasis
|
|
102
|
+
|
|
103
|
+
Often one series is the point and the rest are context. `emphasis` names
|
|
104
|
+
the one that keeps its colour; the others recede into grey.
|
|
105
|
+
|
|
106
|
+
```python live
|
|
107
|
+
ui.LineChart(
|
|
108
|
+
MONTHS,
|
|
109
|
+
x="month",
|
|
110
|
+
y={"sales": "Sales", "costs": "Costs"},
|
|
111
|
+
emphasis="sales",
|
|
112
|
+
labels=True,
|
|
113
|
+
height=200,
|
|
114
|
+
)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Line and area
|
|
118
|
+
|
|
119
|
+
`ui.LineChart` draws a trend over an ordered x. `curve=True` smooths it,
|
|
120
|
+
`dots=True` marks the points, and `labels=True` names each line at its end
|
|
121
|
+
rather than numbering every point. `ui.AreaChart` fills under the line and
|
|
122
|
+
`stacked=True` adds the series up.
|
|
123
|
+
|
|
124
|
+
```python live
|
|
125
|
+
ui.Grid(
|
|
126
|
+
ui.LineChart(MONTHS, x="month", y="visits", curve=True, dots=True, height=200),
|
|
127
|
+
ui.AreaChart(
|
|
128
|
+
MONTHS, x="month", y={"sales": "Sales", "costs": "Costs"}, stacked=True, height=200
|
|
129
|
+
),
|
|
130
|
+
cols={"sm": 1, "md": 2},
|
|
131
|
+
)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Clicks
|
|
135
|
+
|
|
136
|
+
`on_click` receives the point: its `x`, the `series` key and `value` for a
|
|
137
|
+
bar, and `values` for every series at that x. On a line or area chart the
|
|
138
|
+
click lands on the nearest x.
|
|
139
|
+
|
|
140
|
+
```python live
|
|
141
|
+
from pydantic import BaseModel
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class Picked(BaseModel):
|
|
145
|
+
text: str = "Click a bar."
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def picked(s, point):
|
|
149
|
+
state(Picked).text = f"{point['series']} in {point['x']}: {point['value']}"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def example(s):
|
|
153
|
+
return ui.Stack(
|
|
154
|
+
ui.BarChart(MONTHS, x="month", y=["sales", "costs"], on_click=picked, height=180),
|
|
155
|
+
ui.Text(state(Picked).text, variant="muted"),
|
|
156
|
+
gap=2,
|
|
157
|
+
)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Stat
|
|
161
|
+
|
|
162
|
+
`ui.Stat` is the headline number. `delta` is the change, drawn with its
|
|
163
|
+
direction; `good="down"` says a fall is the welcome move, for costs or
|
|
164
|
+
errors. `trend` draws a sparkline whose last point is the current one.
|
|
165
|
+
|
|
166
|
+
```python live
|
|
167
|
+
ui.Grid(
|
|
168
|
+
ui.Stat(label="Revenue", value="€ 70k", delta=0.11, trend=[42, 48, 55, 51, 63, 70]),
|
|
169
|
+
ui.Stat(label="Costs", value="€ 41k", delta=0.08, good="down", trend=[31, 30, 34, 35, 38, 41]),
|
|
170
|
+
ui.Stat(label="Open tickets", value="12", delta=-3, delta_format="number", good="down"),
|
|
171
|
+
cols={"sm": 1, "md": 3},
|
|
172
|
+
)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Plotly and other libraries
|
|
176
|
+
|
|
177
|
+
The catalogue draws the forms most pages need. For anything else, an app
|
|
178
|
+
brings its own library through a custom component. Plotly is the common
|
|
179
|
+
case; the same shape works for any library that draws into a div.
|
|
180
|
+
|
|
181
|
+
The page may only run scripts from its own origin, so the library is served
|
|
182
|
+
by the app itself: put `plotly.min.js` in the app's `public/` folder. The
|
|
183
|
+
custom component loads it once, then hands the figure to Plotly whenever
|
|
184
|
+
the props change.
|
|
185
|
+
|
|
186
|
+
```jsx
|
|
187
|
+
// components/Plotly.jsx
|
|
188
|
+
import { useEffect, useRef } from "react";
|
|
189
|
+
|
|
190
|
+
let loading = null;
|
|
191
|
+
|
|
192
|
+
function loadPlotly() {
|
|
193
|
+
if (window.Plotly) return Promise.resolve(window.Plotly);
|
|
194
|
+
loading ??= new Promise((resolve, reject) => {
|
|
195
|
+
const script = document.createElement("script");
|
|
196
|
+
script.src = `${window.__mw.base || ""}/public/plotly.min.js`;
|
|
197
|
+
script.onload = () => resolve(window.Plotly);
|
|
198
|
+
script.onerror = () => reject(new Error("plotly.min.js did not load"));
|
|
199
|
+
document.head.appendChild(script);
|
|
200
|
+
});
|
|
201
|
+
return loading;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export default function Plotly({ props }) {
|
|
205
|
+
const ref = useRef(null);
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
let alive = true;
|
|
208
|
+
loadPlotly().then((Plotly) => {
|
|
209
|
+
if (!alive || !ref.current) return;
|
|
210
|
+
const { data, layout } = props.figure;
|
|
211
|
+
Plotly.react(ref.current, data, layout, { responsive: true, displaylogo: false });
|
|
212
|
+
});
|
|
213
|
+
return () => {
|
|
214
|
+
alive = false;
|
|
215
|
+
};
|
|
216
|
+
}, [props.figure]);
|
|
217
|
+
useEffect(() => () => window.Plotly?.purge(ref.current), []);
|
|
218
|
+
return <div ref={ref} style={{ width: "100%", height: props.height ?? 320 }} />;
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
On the Python side a figure is built as usual and sent as JSON. A
|
|
223
|
+
transparent background lets the page's own colours through in both
|
|
224
|
+
schemes; the figure's own colours are Plotly's, not the theme's. Plotly
|
|
225
|
+
Express wants pandas installed next to it; graph objects need nothing.
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
import json
|
|
229
|
+
|
|
230
|
+
import plotly.express as px
|
|
231
|
+
|
|
232
|
+
def example(s):
|
|
233
|
+
fig = px.line(MONTHS, x="month", y="visits", markers=True)
|
|
234
|
+
fig.update_layout(
|
|
235
|
+
paper_bgcolor="rgba(0,0,0,0)",
|
|
236
|
+
plot_bgcolor="rgba(0,0,0,0)",
|
|
237
|
+
margin={"l": 0, "r": 0, "t": 0, "b": 0},
|
|
238
|
+
)
|
|
239
|
+
return ui.Custom("Plotly", props={"figure": json.loads(fig.to_json()), "height": 300})
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
A click on a bar or a point can come back the same way as in the Hello
|
|
243
|
+
component: register a Plotly event on the div and call `emit`.
|
|
244
|
+
|
|
245
|
+
## Images from Python
|
|
246
|
+
|
|
247
|
+
Any library that renders to SVG or PNG works without a component at all:
|
|
248
|
+
the session keeps the bytes and `ui.Image` shows them. No tooltip, no theme
|
|
249
|
+
awareness, no script in the browser.
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
import io
|
|
253
|
+
|
|
254
|
+
import matplotlib.pyplot as plt
|
|
255
|
+
|
|
256
|
+
def example(s):
|
|
257
|
+
fig, ax = plt.subplots(figsize=(6, 3))
|
|
258
|
+
ax.plot([m["month"] for m in MONTHS], [m["visits"] for m in MONTHS])
|
|
259
|
+
out = io.BytesIO()
|
|
260
|
+
fig.savefig(out, format="svg", transparent=True)
|
|
261
|
+
plt.close(fig)
|
|
262
|
+
return ui.Image(src=s.files.add(out.getvalue(), name="visits.svg"), alt="Visits per month")
|
|
263
|
+
```
|