|
| 1 | +"""Tier 5 canonical example — a multi-user chat room with live presence. |
| 2 | +
|
| 3 | +WebSocket + Pyodide bundle. Demonstrates the full realtime API: |
| 4 | +`@app.server.on("connect")` / `("disconnect")` lifecycle handlers, |
| 5 | +`@app.server.realtime` server-side fire-and-forget endpoints, |
| 6 | +`@app.client.realtime` server-pushable client functions (both the |
| 7 | +`.broadcast(...)` fan-out path and the `.invoke(client_id, ...)` targeted |
| 8 | +path for the initial history push), and `@app.client.on("connect")` to |
| 9 | +trigger the history request as soon as the WS opens. |
| 10 | +
|
| 11 | +Run: |
| 12 | +
|
| 13 | + python examples/05_realtime.py |
| 14 | +
|
| 15 | +Then open http://localhost:8000 in two browser tabs. Each tab gets a |
| 16 | +random `anon-XXXXXX` name and is shown in the sidebar of the other. |
| 17 | +Type a message and hit Send — it appears in both tabs. Change your |
| 18 | +display name and the sidebar updates everywhere. Messages and the |
| 19 | +user list reset when the server restarts (intentional simplicity — |
| 20 | +this example demonstrates the wire protocol, not durable storage). |
| 21 | +""" |
| 22 | + |
| 23 | +from violetear import App, Document, StyleSheet |
| 24 | +from violetear.color import Colors |
| 25 | +from violetear.dom import DOM, Event |
| 26 | +from violetear.units import px, rem |
| 27 | + |
| 28 | + |
| 29 | +app = App(title="Chat Room") |
| 30 | + |
| 31 | + |
| 32 | +# --------------------------------------------------------------------------- |
| 33 | +# Server-side shared state |
| 34 | +# --------------------------------------------------------------------------- |
| 35 | +# |
| 36 | +# In-memory only — every restart wipes the room. A real chat app would |
| 37 | +# put this behind a database (see @app.shared once it ships — roadmap |
| 38 | +# Phase 4 — for the framework-managed-distribution version). |
| 39 | + |
| 40 | +messages: list[dict] = [] # {from_id, from_name, text} |
| 41 | +users: dict[str, str] = {} # client_id -> display_name |
| 42 | + |
| 43 | + |
| 44 | +def _system_message(text: str) -> dict: |
| 45 | + return {"from_id": "system", "from_name": "system", "text": text} |
| 46 | + |
| 47 | + |
| 48 | +# --------------------------------------------------------------------------- |
| 49 | +# Server-side: lifecycle handlers + realtime endpoints |
| 50 | +# --------------------------------------------------------------------------- |
| 51 | + |
| 52 | + |
| 53 | +@app.server.on("connect") |
| 54 | +async def on_join(client_id: str): |
| 55 | + # Assign a default name. The client's first action after socket-open |
| 56 | + # is to call request_history, which will push the full state back. |
| 57 | + default_name = f"anon-{client_id[:6]}" |
| 58 | + users[client_id] = default_name |
| 59 | + msg = _system_message(f"{default_name} joined") |
| 60 | + messages.append(msg) |
| 61 | + await receive_message.broadcast(msg=msg) |
| 62 | + await set_user_list.broadcast(users=users) |
| 63 | + |
| 64 | + |
| 65 | +@app.server.on("disconnect") |
| 66 | +async def on_leave(client_id: str): |
| 67 | + name = users.pop(client_id, None) |
| 68 | + if name is None: |
| 69 | + return |
| 70 | + msg = _system_message(f"{name} left") |
| 71 | + messages.append(msg) |
| 72 | + await receive_message.broadcast(msg=msg) |
| 73 | + await set_user_list.broadcast(users=users) |
| 74 | + |
| 75 | + |
| 76 | +@app.server.realtime |
| 77 | +async def post_message(client_id: str, text: str): |
| 78 | + text = text.strip() |
| 79 | + if not text: |
| 80 | + return |
| 81 | + name = users.get(client_id, "unknown") |
| 82 | + msg = {"from_id": client_id, "from_name": name, "text": text} |
| 83 | + messages.append(msg) |
| 84 | + await receive_message.broadcast(msg=msg) |
| 85 | + |
| 86 | + |
| 87 | +@app.server.realtime |
| 88 | +async def set_name(client_id: str, new_name: str): |
| 89 | + new_name = new_name.strip() |
| 90 | + if not new_name or client_id not in users: |
| 91 | + return |
| 92 | + users[client_id] = new_name |
| 93 | + await set_user_list.broadcast(users=users) |
| 94 | + |
| 95 | + |
| 96 | +@app.server.realtime |
| 97 | +async def request_history(client_id: str): |
| 98 | + # Targeted invoke — only the requesting client gets the initial state. |
| 99 | + # Different code path from the broadcast above (one connection, not all). |
| 100 | + await receive_history.invoke(client_id, messages=messages, users=users) |
| 101 | + |
| 102 | + |
| 103 | +# --------------------------------------------------------------------------- |
| 104 | +# Client-side: realtime handlers + DOM mutation |
| 105 | +# --------------------------------------------------------------------------- |
| 106 | + |
| 107 | + |
| 108 | +@app.client.realtime |
| 109 | +async def receive_message(msg: dict): |
| 110 | + container = DOM.find("chat-log") |
| 111 | + row = DOM.create("div").add("chat-row") |
| 112 | + if msg["from_name"] == "system": |
| 113 | + row.add("chat-row-system") |
| 114 | + name_el = DOM.create("span").add("chat-from") |
| 115 | + name_el.text = msg["from_name"] + ":" |
| 116 | + text_el = DOM.create("span").add("chat-text") |
| 117 | + text_el.text = msg["text"] |
| 118 | + row.append(name_el) |
| 119 | + row.append(text_el) |
| 120 | + container.append(row) |
| 121 | + |
| 122 | + |
| 123 | +@app.client.realtime |
| 124 | +async def set_user_list(users: dict): |
| 125 | + container = DOM.find("user-list") |
| 126 | + container.html("") |
| 127 | + for _client_id, name in users.items(): |
| 128 | + row = DOM.create("div").add("user-row") |
| 129 | + row.text = name |
| 130 | + container.append(row) |
| 131 | + |
| 132 | + |
| 133 | +@app.client.realtime |
| 134 | +async def receive_history(messages: list, users: dict): |
| 135 | + # Initial state push from request_history (targeted invoke). |
| 136 | + log = DOM.find("chat-log") |
| 137 | + log.html("") |
| 138 | + for msg in messages: |
| 139 | + row = DOM.create("div").add("chat-row") |
| 140 | + if msg["from_name"] == "system": |
| 141 | + row.add("chat-row-system") |
| 142 | + name_el = DOM.create("span").add("chat-from") |
| 143 | + name_el.text = msg["from_name"] + ":" |
| 144 | + text_el = DOM.create("span").add("chat-text") |
| 145 | + text_el.text = msg["text"] |
| 146 | + row.append(name_el) |
| 147 | + row.append(text_el) |
| 148 | + log.append(row) |
| 149 | + user_container = DOM.find("user-list") |
| 150 | + user_container.html("") |
| 151 | + for _cid, name in users.items(): |
| 152 | + row = DOM.create("div").add("user-row") |
| 153 | + row.text = name |
| 154 | + user_container.append(row) |
| 155 | + |
| 156 | + |
| 157 | +@app.client.on("connect") |
| 158 | +async def on_socket_connect(): |
| 159 | + # WS is now live — ask the server to push our initial state. The server |
| 160 | + # responds via receive_history.invoke(client_id=...) so only this client |
| 161 | + # gets it (proves the targeted-invoke wire path). |
| 162 | + # |
| 163 | + # `str(get_client_id())` because on second call `get_client_id()` returns |
| 164 | + # a `Thing` proxy (read through session storage) rather than a bare str, |
| 165 | + # and `_call_realtime` json.dumps the kwargs — Thing isn't serializable |
| 166 | + # (see issues/7.8). str() coerces via Thing.__repr__ → the wrapped value. |
| 167 | + from violetear.client import get_client_id |
| 168 | + |
| 169 | + await request_history(client_id=str(get_client_id())) |
| 170 | + |
| 171 | + |
| 172 | +@app.client.on("ready") |
| 173 | +async def on_ready(): |
| 174 | + # Surface the default name in the rename field so the user can edit it. |
| 175 | + from violetear.client import get_client_id |
| 176 | + |
| 177 | + my_id = str(get_client_id()) |
| 178 | + name_input = DOM.find("name-input") |
| 179 | + name_input.value = f"anon-{my_id[:6]}" |
| 180 | + |
| 181 | + |
| 182 | +@app.client.callback |
| 183 | +async def on_send_click(event: Event): |
| 184 | + from violetear.client import get_client_id |
| 185 | + |
| 186 | + text_el = DOM.find("message-input") |
| 187 | + text = str(text_el.value or "") |
| 188 | + if not text.strip(): |
| 189 | + return |
| 190 | + await post_message(client_id=str(get_client_id()), text=text) |
| 191 | + text_el.value = "" |
| 192 | + |
| 193 | + |
| 194 | +@app.client.callback |
| 195 | +async def on_rename_click(event: Event): |
| 196 | + from violetear.client import get_client_id |
| 197 | + |
| 198 | + name_el = DOM.find("name-input") |
| 199 | + new_name = str(name_el.value or "") |
| 200 | + if not new_name.strip(): |
| 201 | + return |
| 202 | + await set_name(client_id=str(get_client_id()), new_name=new_name) |
| 203 | + |
| 204 | + |
| 205 | +# --------------------------------------------------------------------------- |
| 206 | +# Stylesheet — flat class selectors only (gap 7.1) |
| 207 | +# --------------------------------------------------------------------------- |
| 208 | + |
| 209 | + |
| 210 | +sheet = StyleSheet(normalize=True) |
| 211 | + |
| 212 | +sheet.select("body").font( |
| 213 | + size=rem(1.0), family="system-ui, -apple-system, 'Segoe UI', sans-serif" |
| 214 | +).color(Colors.DarkSlateGray).background(Colors.WhiteSmoke).padding(rem(1.5)) |
| 215 | + |
| 216 | +sheet.select(".page").rules(max_width="900px").margin("auto").background( |
| 217 | + Colors.White |
| 218 | +).padding(rem(1.5)).rounded(px(8)).border(px(1), Colors.Gainsboro) |
| 219 | + |
| 220 | +sheet.select(".page-title").font(size=rem(1.5), weight=700).color(Colors.Indigo).margin( |
| 221 | + bottom=rem(1.0) |
| 222 | +) |
| 223 | + |
| 224 | +sheet.select(".layout").flexbox(direction="row", gap=px(16)).rules(min_height="420px") |
| 225 | + |
| 226 | +# Sidebar |
| 227 | +sheet.select(".sidebar").rules(min_width="180px", flex="0 0 180px").padding( |
| 228 | + rem(0.75) |
| 229 | +).background(Colors.WhiteSmoke).rounded(px(6)).border(px(1), Colors.Gainsboro) |
| 230 | +sheet.select(".sidebar-heading").font(size=rem(0.875), weight=700).color( |
| 231 | + Colors.SlateGray |
| 232 | +).rules(text_transform="uppercase", letter_spacing="0.05em").margin(bottom=rem(0.5)) |
| 233 | +sheet.select(".user-list").flexbox(direction="column", gap=px(4)) |
| 234 | +sheet.select(".user-row").font(size=rem(0.875)).color(Colors.DarkSlateGray).rules( |
| 235 | + padding="4px 6px" |
| 236 | +).background(Colors.White).rounded(px(4)) |
| 237 | + |
| 238 | +# Main column |
| 239 | +sheet.select(".main").flexbox(direction="column", gap=px(12)).rules(flex="1") |
| 240 | +sheet.select(".chat-log").flexbox(direction="column", gap=px(6)).rules( |
| 241 | + flex="1", overflow_y="auto", padding="8px" |
| 242 | +).background(Colors.WhiteSmoke).rounded(px(6)).border(px(1), Colors.Gainsboro) |
| 243 | +sheet.select(".chat-row").flexbox(direction="row", gap=px(8), align="baseline").rules( |
| 244 | + padding="4px 6px" |
| 245 | +) |
| 246 | +sheet.select(".chat-row-system").color(Colors.SlateGray).rules(font_style="italic") |
| 247 | +sheet.select(".chat-from").font(size=rem(0.875), weight=700).color(Colors.Indigo) |
| 248 | +sheet.select(".chat-text").font(size=rem(0.875)).color(Colors.DarkSlateGray) |
| 249 | + |
| 250 | +# Compose bar |
| 251 | +sheet.select(".compose").flexbox(direction="row", gap=px(8)) |
| 252 | +sheet.select(".compose-input").rules( |
| 253 | + padding="8px 10px", |
| 254 | + border=f"1px solid {Colors.Gainsboro}", |
| 255 | + font_size="1rem", |
| 256 | + flex="1", |
| 257 | +).rounded(px(4)) |
| 258 | +sheet.select(".compose-button").rules( |
| 259 | + padding="8px 14px", |
| 260 | + border="none", |
| 261 | + cursor="pointer", |
| 262 | + font_size="0.875rem", |
| 263 | + font_weight=600, |
| 264 | +).background(Colors.Indigo).color(Colors.White).rounded(px(4)) |
| 265 | + |
| 266 | +# Rename bar |
| 267 | +sheet.select(".rename-row").flexbox(direction="row", gap=px(8), align="center").margin( |
| 268 | + top=rem(0.5) |
| 269 | +) |
| 270 | +sheet.select(".rename-label").font(size=rem(0.875), weight=600).color(Colors.SlateGray) |
| 271 | +sheet.select(".rename-input").rules( |
| 272 | + padding="6px 8px", |
| 273 | + border=f"1px solid {Colors.Gainsboro}", |
| 274 | + font_size="0.875rem", |
| 275 | +).rounded(px(4)) |
| 276 | +sheet.select(".rename-button").rules( |
| 277 | + padding="6px 10px", |
| 278 | + border=f"1px solid {Colors.Gainsboro}", |
| 279 | + cursor="pointer", |
| 280 | + font_size="0.875rem", |
| 281 | + font_weight=600, |
| 282 | +).background(Colors.White).color(Colors.DarkSlateGray).rounded(px(4)) |
| 283 | + |
| 284 | + |
| 285 | +# --------------------------------------------------------------------------- |
| 286 | +# View |
| 287 | +# --------------------------------------------------------------------------- |
| 288 | + |
| 289 | + |
| 290 | +@app.view("/") |
| 291 | +def index(): |
| 292 | + doc = Document(title="Chat Room") |
| 293 | + doc.style(href="/style.css", sheet=sheet) |
| 294 | + |
| 295 | + with doc.body as body: |
| 296 | + with body.div(classes="page") as page: |
| 297 | + page.h1(text="Chat Room").classes("page-title") |
| 298 | + |
| 299 | + with page.div(classes="layout") as layout: |
| 300 | + # Sidebar — live user list, filled by receive_history / |
| 301 | + # set_user_list pushes. |
| 302 | + with layout.div(classes="sidebar") as sidebar: |
| 303 | + sidebar.div(text="Online", classes="sidebar-heading") |
| 304 | + sidebar.div(classes="user-list").attrs(id="user-list") |
| 305 | + |
| 306 | + with layout.div(classes="main") as main: |
| 307 | + # Chat log — filled by receive_message + receive_history. |
| 308 | + main.div(classes="chat-log").attrs(id="chat-log") |
| 309 | + |
| 310 | + # Compose bar. |
| 311 | + with main.div(classes="compose") as compose: |
| 312 | + compose.input(classes="compose-input").attrs( |
| 313 | + id="message-input", |
| 314 | + type="text", |
| 315 | + placeholder="Type a message…", |
| 316 | + ) |
| 317 | + compose.button(text="Send", classes="compose-button").attrs( |
| 318 | + type="button" |
| 319 | + ).on("click", on_send_click) |
| 320 | + |
| 321 | + # Rename bar. |
| 322 | + with main.div(classes="rename-row") as rename: |
| 323 | + rename.span(text="Display name:", classes="rename-label") |
| 324 | + rename.input(classes="rename-input").attrs( |
| 325 | + id="name-input", type="text" |
| 326 | + ) |
| 327 | + rename.button(text="Rename", classes="rename-button").attrs( |
| 328 | + type="button" |
| 329 | + ).on("click", on_rename_click) |
| 330 | + |
| 331 | + return doc |
| 332 | + |
| 333 | + |
| 334 | +if __name__ == "__main__": |
| 335 | + app.run() |
0 commit comments