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,280 @@
1
+ ---
2
+ title: Chat
3
+ order: 9
4
+ ---
5
+
6
+ # Chat
7
+
8
+ `ui.Chat` is a conversation: messages, a composer with attachments and
9
+ commands, and a streamed reply from Python. It needs a `key`, which names
10
+ the thread the session keeps for it, and an `on_message` handler that gets a
11
+ `mw.ChatTurn` and replies through it. The chat fills whatever height it is
12
+ given, so give it one with `cls` inside a card. `attachments=ui.Accept(...)`
13
+ says what a person may attach, of which types, how many and how large;
14
+ without it the composer takes no files. `max_chars` caps a message and
15
+ `max_messages` the thread, as the section below says.
16
+ `tools=[...]` puts your own buttons in the composer; the section below
17
+ and the Custom chapter show what goes there.
18
+
19
+ ## A streamed reply
20
+
21
+ `turn.text`, `turn.files` and `turn.command` are what the user sent. Inside
22
+ `async with turn.reply() as reply`, `reply.text(chunk)` streams words,
23
+ `reply.reasoning()` opens a thinking block, `reply.step(label)` a tool
24
+ step, `reply.status(text)` a transient marker, and `reply.suggestions([...])`
25
+ follow-up chips. The whole turn is a task: the stop button cancels it.
26
+
27
+ ```python live
28
+ import asyncio
29
+
30
+
31
+ async def answer(s, turn):
32
+ text = turn.text.strip() or "nothing"
33
+ if turn.command == "shout":
34
+ text = text.upper()
35
+ async with turn.reply() as reply:
36
+ async with reply.reasoning() as r:
37
+ await r.text("Deciding what to say about ")
38
+ await asyncio.sleep(0.3)
39
+ await r.text(f"“{text}”.")
40
+ async with reply.step("Looking it up") as step:
41
+ await asyncio.sleep(0.4)
42
+ await step.output({"hits": 3})
43
+ for word in f"You said {text}, and I heard every word of it.".split(" "):
44
+ await reply.text(word + " ")
45
+ await asyncio.sleep(0.05)
46
+ for f in turn.files:
47
+ await reply.file(f)
48
+ await reply.suggestions(["Say it again", "Try /shout"])
49
+
50
+
51
+ ui.Card(
52
+ ui.Chat(
53
+ key="demo",
54
+ welcome="Ask anything. The reply streams back from Python.",
55
+ suggestions=["What can you do?", "Write a haiku"],
56
+ placeholder="Type a message",
57
+ attachments=ui.Accept(accept=[".txt", ".md"], max_files=2),
58
+ commands=[
59
+ ui.Command(id="shout", label="Shout", icon="volume-2", description="Reply in capitals")
60
+ ],
61
+ on_message=answer,
62
+ cls="h-[28rem]",
63
+ ),
64
+ padding=False,
65
+ )
66
+ ```
67
+
68
+ ## The thread
69
+
70
+ `s.chat(key)` is the thread: `messages` holds what was said, `clear()`
71
+ empties it, and `load(messages)` restores a stored conversation. `busy` is
72
+ true while a reply is streaming. The session keeps the last `max_messages`
73
+ of the Chat, 200 by default, each at most `max_chars` long, 16,000 by
74
+ default, so a thread holds a known amount whatever a visitor sends.
75
+ `s.drop_chat(key)` forgets a thread altogether, for an app that keeps its
76
+ threads in a store and only the open one in the session.
77
+
78
+ ```python live
79
+ ui.Row(
80
+ ui.Button(
81
+ "Clear the chat above",
82
+ variant="outline",
83
+ icon="trash-2",
84
+ on_click=lambda s, _: s.chat("demo").clear(),
85
+ ),
86
+ ui.Button(
87
+ "Count messages",
88
+ variant="outline",
89
+ on_click=lambda s, _: s.toast(f"{len(s.chat('demo').messages)} messages"),
90
+ ),
91
+ gap=2,
92
+ )
93
+ ```
94
+
95
+ ## Parts a reply can carry
96
+
97
+ | Call | Shows as |
98
+ | --- | --- |
99
+ | `await reply.text(chunk)` | streamed Markdown |
100
+ | `async with reply.reasoning() as r` | a folded thinking block, `r.text(...)` inside |
101
+ | `async with reply.step(label) as step` | a tool step with `step.output(...)` |
102
+ | `await reply.source(title, url, snippet)` | a source chip |
103
+ | `await reply.file(ref)` and `reply.image(ref)` | an attachment, an inline image |
104
+ | `await reply.status(text)` | a marker that disappears when the reply ends |
105
+ | `await reply.component(component)` | any catalogue component inside the message |
106
+ | `await reply.suggestions([...])` | follow-up chips |
107
+ | `await reply.error(text)` | an error line |
108
+
109
+ ## Reading aids
110
+
111
+ Four things are off until asked for, since a chat is often clean enough
112
+ without them: `avatar` puts a picture beside the assistant's messages,
113
+ `names=True` and `timestamps=True` write who said it and when above each
114
+ message (on hover, always on a touch screen; `assistant_name` sets the
115
+ name), and `durations=True` shows how long the reply took and how long
116
+ each step ran. The times are kept on the messages, so a loaded thread
117
+ shows them too.
118
+
119
+ ```python live
120
+ import asyncio
121
+
122
+
123
+ async def answer(s, turn):
124
+ async with turn.reply() as reply:
125
+ async with reply.step("Checking the archive") as step:
126
+ await asyncio.sleep(0.6)
127
+ await step.output({"found": 2})
128
+ await reply.text(f"Two notes mention {turn.text.strip() or 'nothing'}.")
129
+
130
+
131
+ ui.Card(
132
+ ui.Chat(
133
+ key="aids",
134
+ welcome="Hover a message for its name and time.",
135
+ avatar="/public/mark.svg",
136
+ assistant_name="Archivist",
137
+ names=True,
138
+ timestamps=True,
139
+ durations=True,
140
+ on_message=answer,
141
+ cls="h-[20rem]",
142
+ ),
143
+ padding=False,
144
+ )
145
+ ```
146
+
147
+ ## Tools in the composer
148
+
149
+ `tools=[...]` renders components left of Send. A `ui.Button` there is an
150
+ ordinary button with a handler in Python, and nothing says it must touch
151
+ the conversation: this gear opens a `ui.Dialog` that lives next to the
152
+ chat, the dialog changes state, and the next reply reads it. Nothing is
153
+ sent and no message appears. A custom component in the slot gets a `chat`
154
+ prop to upload and send with; the Custom chapter builds a voice note
155
+ button on it.
156
+
157
+ ```python live
158
+ from pydantic import BaseModel
159
+
160
+
161
+ class Settings(BaseModel):
162
+ open: bool = False
163
+ tone: str = "plain"
164
+ sign: bool = False
165
+
166
+
167
+ async def answer(s, turn):
168
+ st = state(Settings)
169
+ async with turn.reply() as reply:
170
+ text = turn.text.upper() if st.tone == "shout" else turn.text
171
+ await reply.text(f"You said: {text}" + (" (sent from the manual)" if st.sign else ""))
172
+
173
+
174
+ def example(s):
175
+ st = state(Settings)
176
+
177
+ def opened(value):
178
+ def handler(s, _):
179
+ st.open = value
180
+
181
+ return handler
182
+
183
+ return ui.Stack(
184
+ ui.Card(
185
+ ui.Chat(
186
+ key="settings-demo",
187
+ welcome="The gear in the composer opens the settings.",
188
+ tools=[
189
+ ui.Button(
190
+ key="gear",
191
+ icon="settings",
192
+ variant="ghost",
193
+ size="sm",
194
+ tooltip="Settings",
195
+ on_click=opened(True),
196
+ )
197
+ ],
198
+ on_message=answer,
199
+ cls="h-[20rem]",
200
+ ),
201
+ padding=False,
202
+ ),
203
+ ui.Dialog(
204
+ ui.Field(
205
+ ui.Select(
206
+ key="tone",
207
+ options=["plain", "shout"],
208
+ value=st.tone,
209
+ on_change=lambda s, v: setattr(st, "tone", v or "plain"),
210
+ ),
211
+ label="Tone",
212
+ ),
213
+ ui.Switch(
214
+ key="sign",
215
+ label="Sign the replies",
216
+ checked=st.sign,
217
+ on_change=lambda s, v: setattr(st, "sign", bool(v)),
218
+ ),
219
+ key="settings",
220
+ open=st.open,
221
+ title="Chat settings",
222
+ on_close=opened(False),
223
+ footer=ui.Button("Done", on_click=opened(False)),
224
+ ),
225
+ )
226
+ ```
227
+
228
+ ## Under a message
229
+
230
+ Hover an answer and a row of buttons appears: copy, thumbs up and down
231
+ when the chat has an `on_feedback` handler, regenerate on the latest
232
+ answer, and any `actions` of the app's own. A thumbs down asks for a
233
+ comment. The choice stays on the message, so it is there after a reload,
234
+ and millwork keeps nothing else: `on_feedback` gets the message, the value
235
+ and the comment, and the app stores them where it wants. A user message
236
+ has an edit button; the edited message is answered again and what followed
237
+ it goes. `regenerate=False`, `edit=False` and `copyable=False` switch each
238
+ off.
239
+
240
+ ```python live
241
+ from pydantic import BaseModel
242
+
243
+
244
+ class Feedback(BaseModel):
245
+ notes: list[str] = []
246
+
247
+
248
+ async def echo(s, turn):
249
+ async with turn.reply() as reply:
250
+ await reply.text(f"You said: {turn.text}")
251
+
252
+
253
+ async def rated(s, message, value, comment):
254
+ what = "cleared" if value is None else value
255
+ note = f"{what}: {message.text[:30]}" + (f" ({comment})" if comment else "")
256
+ state(Feedback).notes.append(note)
257
+
258
+
259
+ async def act(s, message, action):
260
+ s.toast(f"{action}: {message.text}")
261
+
262
+
263
+ def example(s):
264
+ notes = state(Feedback).notes
265
+ return ui.Stack(
266
+ ui.Card(
267
+ ui.Chat(
268
+ key="rated",
269
+ welcome="Say something, then rate the answer or edit your message.",
270
+ on_message=echo,
271
+ on_feedback=rated,
272
+ on_action=act,
273
+ actions=[ui.Action(id="save", label="Save", icon="bookmark")],
274
+ cls="h-[22rem]",
275
+ ),
276
+ padding=False,
277
+ ),
278
+ ui.Text("Feedback so far: " + ("; ".join(notes) or "none")),
279
+ )
280
+ ```
@@ -0,0 +1,148 @@
1
+ ---
2
+ title: Custom components
3
+ order: 10
4
+ ---
5
+
6
+ # Custom components
7
+
8
+ When the catalogue has no component for something, an app writes one in
9
+ JSX. The file lives in the app's `components/` folder, is compiled in the
10
+ browser, and composes the same primitives the catalogue is built from.
11
+ Python reaches it by name with `ui.Custom`.
12
+
13
+ ## The contract
14
+
15
+ A component is a default export that receives `props` from Python, `emit`
16
+ to send an event back, `request` to ask one of its handlers and get the
17
+ answer as a promise, `fileUrl` to show a session file, and `children`
18
+ rendered from Python.
19
+
20
+ ```jsx
21
+ import { useState } from "react";
22
+ import { Button } from "@/components/ui/button";
23
+ import { Badge } from "@/components/ui/badge";
24
+ import { Sparkles } from "lucide-react";
25
+
26
+ export default function Hello({ props, emit, children }) {
27
+ const [clicks, setClicks] = useState(0);
28
+ return (
29
+ <div className="flex flex-col gap-2 rounded-xl border border-dashed border-primary/40 p-4">
30
+ <div className="flex items-center gap-2">
31
+ <Sparkles className="size-4 text-primary" />
32
+ <span className="font-medium">Hello, {props.who}.</span>
33
+ <Badge variant="secondary">server says {props.count}</Badge>
34
+ <Badge variant="outline">local clicks {clicks}</Badge>
35
+ </div>
36
+ <div className="flex gap-2">
37
+ <Button size="sm" variant="outline" onClick={() => setClicks((c) => c + 1)}>Local +1</Button>
38
+ <Button size="sm" onClick={() => emit("wave", clicks)}>Wave to the server</Button>
39
+ </div>
40
+ {children}
41
+ </div>
42
+ );
43
+ }
44
+ ```
45
+
46
+ ## Using it from Python
47
+
48
+ `ui.Custom(name, props=, events=)` renders `components/<name>.jsx`. Events
49
+ the component emits reach the handler named in `events`, or `on_event` as
50
+ `(name, payload)`.
51
+
52
+ ```python live
53
+ from pydantic import BaseModel
54
+
55
+
56
+ class Waves(BaseModel):
57
+ count: int = 0
58
+
59
+
60
+ def example(s):
61
+ st = state(Waves)
62
+
63
+ def waved(s, clicks):
64
+ st.count += 1
65
+ s.toast(f"Waved after {clicks} local clicks.")
66
+
67
+ return ui.Custom(
68
+ "Hello",
69
+ ui.Text("Children from Python render inside the custom component.", variant="muted"),
70
+ props={"who": "Ada", "count": st.count},
71
+ events={"wave": waved},
72
+ )
73
+ ```
74
+
75
+ The import map a custom component can draw on is React, lucide icons, and
76
+ every `@/components/ui/*` module the catalogue uses. Tailwind classes work;
77
+ ones the bundle never saw are compiled in the browser on first use.
78
+
79
+ ## In a chat's composer
80
+
81
+ `ui.Chat(tools=[...])` puts components in the composer, left of Send: a
82
+ `ui.Button` with a handler in Python, or a custom component, which then
83
+ receives a `chat` prop with `upload(file)`, `send(text, files)` and
84
+ `busy`. That is enough for anything the browser can capture. This voice
85
+ note button records with the browser's own recorder, uploads the clip
86
+ under the chat's `attachments` limits and sends it; the handler gets it as
87
+ `turn.files[0]`. `paperclip=False` keeps the attach button away when the
88
+ tool is the only way files come in, and `capture="environment"` on
89
+ `Accept` makes the paperclip open the camera on a phone.
90
+
91
+ ```python live
92
+ async def heard(s, turn):
93
+ async with turn.reply() as reply:
94
+ if turn.files:
95
+ clip = turn.files[0]
96
+ await reply.text(f"A voice note of {clip.size // 1024} KB. Here it is back:")
97
+ await reply.file(clip)
98
+ else:
99
+ await reply.text("Press the microphone and say something.")
100
+
101
+
102
+ ui.Card(
103
+ ui.Chat(
104
+ key="voice",
105
+ welcome="Record a voice note with the microphone button.",
106
+ attachments=ui.Accept(accept=[".webm", ".ogg", ".mp4", ".m4a"], paperclip=False),
107
+ tools=[ui.Custom("Voice")],
108
+ on_message=heard,
109
+ cls="h-[20rem]",
110
+ ),
111
+ padding=False,
112
+ )
113
+ ```
114
+
115
+ ```jsx
116
+ // components/Voice.jsx
117
+ import { useRef, useState } from "react";
118
+ import { Button } from "@/components/ui/button";
119
+ import { Mic, Square } from "lucide-react";
120
+
121
+ export default function Voice({ chat }) {
122
+ const [recorder, setRecorder] = useState(null);
123
+ const chunks = useRef([]);
124
+ if (!chat) return null;
125
+
126
+ async function start() {
127
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
128
+ const r = new MediaRecorder(stream);
129
+ chunks.current = [];
130
+ r.ondataavailable = (e) => chunks.current.push(e.data);
131
+ r.onstop = async () => {
132
+ stream.getTracks().forEach((t) => t.stop());
133
+ const blob = new Blob(chunks.current, { type: r.mimeType || "audio/webm" });
134
+ const ref = await chat.upload(new File([blob], "voice.webm", { type: blob.type }));
135
+ chat.send("", [ref]);
136
+ };
137
+ r.start();
138
+ setRecorder(r);
139
+ }
140
+
141
+ return (
142
+ <Button type="button" size="icon-sm" variant={recorder ? "destructive" : "ghost"}
143
+ onClick={recorder ? () => { recorder.stop(); setRecorder(null); } : start}>
144
+ {recorder ? <Square /> : <Mic />}
145
+ </Button>
146
+ );
147
+ }
148
+ ```
@@ -0,0 +1,112 @@
1
+ ---
2
+ title: Data
3
+ order: 4
4
+ ---
5
+
6
+ # Data
7
+
8
+ `ui.Table` shows rows: dicts, Pydantic models or dataclasses. Columns come
9
+ from the rows unless `columns` names them, with a `header`, a `format`, a
10
+ `width`, an `align`, and whether they are `sortable` or `filterable`.
11
+
12
+ ## A plain table
13
+
14
+ Sorting is on by default and happens in the browser. `row_key` names the
15
+ field that identifies a row, `id` unless told otherwise.
16
+
17
+ ```python live
18
+ ROWS = [
19
+ {"id": 1, "name": "Ada", "role": "Engineer", "score": 0.92, "joined": "2024-03-01", "active": True},
20
+ {"id": 2, "name": "Grace", "role": "Admiral", "score": 0.88, "joined": "2023-11-15", "active": True},
21
+ {"id": 3, "name": "Edsger", "role": "Professor", "score": 0.75, "joined": "2022-06-30", "active": False},
22
+ {"id": 4, "name": "Barbara", "role": "Architect", "score": 0.81, "joined": "2024-08-12", "active": True},
23
+ ]
24
+
25
+ ui.Table(rows=ROWS)
26
+ ```
27
+
28
+ ## Columns and formats
29
+
30
+ `format` renders a value as `text`, `number`, `integer`, `percent`,
31
+ `currency`, `date`, `datetime`, `bool`, `badge`, `link` or `code`.
32
+
33
+ ```python live
34
+ ui.Table(
35
+ rows=ROWS,
36
+ columns=[
37
+ ui.Column(key="name", header="Name"),
38
+ ui.Column(key="role", header="Role", format="badge"),
39
+ ui.Column(key="score", header="Score", format="percent", align="right"),
40
+ ui.Column(key="joined", header="Joined", format="date"),
41
+ ui.Column(key="active", header="Active", format="bool", align="center"),
42
+ ],
43
+ default_sort=[("score", "desc")],
44
+ )
45
+ ```
46
+
47
+ ## Search, filters and pages
48
+
49
+ `search=True` adds a box that matches any column; `filters=True` adds a
50
+ filter row whose kind follows each column's values; `page_size` pages the
51
+ rows. `summary` is the count line under the table, or custom text.
52
+
53
+ ```python live
54
+ ui.Table(rows=ROWS, search=True, filters=True, page_size=3, summary="People in the pilot")
55
+ ```
56
+
57
+ ## Selection and row actions
58
+
59
+ `selection="row"` or `"rows"` lets the user pick; `on_select` receives the
60
+ keys. `row_actions` adds a menu per row and `on_action` receives
61
+ `(action id, row key)`. `on_row_click` receives the key of the clicked row.
62
+
63
+ ```python live
64
+ from pydantic import BaseModel
65
+
66
+
67
+ class Picked(BaseModel):
68
+ keys: list = []
69
+ last: str = ""
70
+
71
+
72
+ def example(s):
73
+ st = state(Picked)
74
+ return ui.Stack(
75
+ ui.Table(
76
+ rows=ROWS,
77
+ columns=[ui.Column(key="name"), ui.Column(key="role")],
78
+ selection="rows",
79
+ selected=st.keys,
80
+ on_select=lambda s, keys: setattr(st, "keys", keys),
81
+ row_actions=[
82
+ ui.MenuItem(id="edit", label="Edit", icon="pencil"),
83
+ ui.MenuItem(id="remove", label="Remove", icon="trash-2", destructive=True),
84
+ ],
85
+ on_action=lambda s, a: setattr(st, "last", f"{a[0]} on row {a[1]}"),
86
+ on_row_click=lambda s, key: setattr(st, "last", f"clicked row {key}"),
87
+ ),
88
+ ui.Text(f"Selected: {st.keys}. Last: {st.last or 'nothing'}", variant="muted"),
89
+ gap=3,
90
+ )
91
+ ```
92
+
93
+ ## Tones
94
+
95
+ `tones` colours rows: a function of the row returning a tone, or a mapping
96
+ from column key to a function of the row and the cell.
97
+
98
+ ```python live
99
+ ui.Table(
100
+ rows=ROWS,
101
+ columns=[ui.Column(key="name"), ui.Column(key="score", format="percent")],
102
+ tones=lambda r: "success" if r["score"] > 0.85 else ("warning" if r["score"] < 0.8 else None),
103
+ )
104
+ ```
105
+
106
+ ## Empty
107
+
108
+ `empty` is the text shown when there are no rows.
109
+
110
+ ```python live
111
+ ui.Table(rows=[], columns=[ui.Column(key="name"), ui.Column(key="role")], empty="No people match.")
112
+ ```