Skip to content

Commit a7d77d7

Browse files
committed
Client side works!
1 parent b7a510e commit a7d77d7

File tree

5 files changed

+233
-21
lines changed

5 files changed

+233
-21
lines changed

examples/hello_world.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,4 @@ def index():
3838

3939
# 4. Run the Server
4040
# Ensure you have installed server deps: uv add --extra server "fastapi[standard]"
41-
app.run(port=8000)
41+
app.run()

examples/simple_client.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from violetear import App
2+
from violetear.markup import Document, Element
3+
4+
# 1. Initialize the App
5+
app = App(title="Interactive Python Demo")
6+
7+
8+
# 2. Define Client-Side Code
9+
# This function is compiled into the bundle and sent to the browser.
10+
@app.client
11+
def on_button_click(event):
12+
# This print shows up in the Browser DevTools Console!
13+
print("Hello from Client-Side Python!")
14+
15+
# You can even use the Pyodide/JS bridge
16+
from js import alert
17+
alert("It works! Python is running in your browser.")
18+
19+
20+
# 3. Define Server-Side Route
21+
@app.route("/")
22+
def home():
23+
doc = Document(title="Client-Side Demo")
24+
25+
# 4. Build the UI
26+
# We create a button and attach the Python function directly.
27+
btn = (
28+
Element("button", text="Click Me!")
29+
.on("click", on_button_click) # <--- The Magic Link
30+
)
31+
32+
doc.body.extend(
33+
Element("h1", text="Violetear Full-Stack Demo"),
34+
Element("p", text="Open your browser console (F12) and click the button."),
35+
btn
36+
)
37+
38+
return doc
39+
40+
41+
app.run()

violetear/app.py

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import os
22
import inspect
3+
from pathlib import Path
4+
from textwrap import dedent
35
from typing import Any, Callable, Dict, List, Union
46

57

@@ -43,7 +45,20 @@ def __init__(self, title: str = "Violetear App"):
4345
# Registry of served styles to prevent duplicate route registration
4446
self.served_styles: Dict[str, StyleSheet] = {}
4547

46-
def add_style(self, path: str, sheet: StyleSheet):
48+
# Registry for client-side functions
49+
self.client_functions: Dict[str, Callable] = {}
50+
51+
# Register the Bundle Route (Dynamic Python file)
52+
@self.api.get("/_violetear/bundle.py")
53+
def get_bundle():
54+
return Response(content=self._generate_bundle(), media_type="text/x-python")
55+
56+
def client(self, func: Callable):
57+
"""Decorator to mark a function to be compiled to the client."""
58+
self.client_functions[func.__name__] = func
59+
return func
60+
61+
def style(self, path: str, sheet: StyleSheet):
4762
"""
4863
Registers a stylesheet to be served by the app at a specific path.
4964
@@ -70,7 +85,50 @@ def _register_document_styles(self, doc: Document):
7085
if isinstance(resource, StyleResource):
7186
# If it has a sheet object AND a URL, it needs to be served
7287
if resource.sheet and resource.href and not resource.inline:
73-
self.add_style(resource.href, resource.sheet)
88+
self.style(resource.href, resource.sheet)
89+
90+
def _generate_bundle(self) -> str:
91+
"""
92+
Generates the Python bundle to run in the browser.
93+
It combines the runtime logic + user functions.
94+
"""
95+
# 1. Mock the 'app' object so decorators don't fail in the browser
96+
header = "class MockApp:\n def client(self, f): return f\n\napp = MockApp()\n\n"
97+
98+
# 2. Read the Client Runtime (Hydration logic)
99+
runtime_path = Path(__file__).parent / "client.py"
100+
101+
with open(runtime_path, "r") as f:
102+
runtime_code = f.read()
103+
104+
# 3. Extract User Functions
105+
user_code = []
106+
107+
for name, func in self.client_functions.items():
108+
user_code.append(inspect.getsource(func))
109+
110+
# 4. Initialization
111+
init_code = "\n\n# --- Init ---\n\nhydrate(globals())\n"
112+
113+
return header + runtime_code + "\n\n" + "\n".join(user_code) + init_code
114+
115+
def _inject_client_side(self, doc: Document):
116+
"""Injects Pyodide and the Bundle bootstrapper."""
117+
# 1. Load Pyodide
118+
doc.script(src="https://cdn.jsdelivr.net/pyodide/v0.29.0/full/pyodide.js")
119+
120+
# 2. Bootstrap Script
121+
bootstrap = dedent("""
122+
async function main() {
123+
let pyodide = await loadPyodide();
124+
let response = await fetch("/_violetear/bundle.py");
125+
let code = await response.text();
126+
await pyodide.runPythonAsync(code);
127+
}
128+
main();
129+
""")
130+
131+
doc.script(content=bootstrap)
74132

75133
def route(self, path: str, methods: List[str] = ["GET"]):
76134
"""
@@ -97,9 +155,13 @@ async def wrapper(request: Request):
97155

98156
# 2. Handle Document Rendering
99157
if isinstance(response, Document):
100-
# JIT: Check if this doc uses any new stylesheets we need to serve
158+
# Check if this doc uses any new stylesheets we need to serve
101159
self._register_document_styles(response)
102160

161+
# Check if this document contains Python bindings
162+
if response.body.has_bindings():
163+
self._inject_client_side(response)
164+
103165
# Render the HTML (which will contain <link href="..."> tags)
104166
return HTMLResponse(response.render())
105167

violetear/client.py

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,66 @@
11
"""
22
Violetear Client Runtime.
3-
This code is intended to run inside the browser via Pyodide.
3+
This code runs inside the browser (Pyodide) to bring the static HTML to life.
44
"""
55

66
import sys
77

8-
# Simple check to ensure we don't accidentally run this on the server
8+
# We define IS_BROWSER to avoid import errors if this is imported on the server
99
IS_BROWSER = "pyodide" in sys.modules or "emscripten" in sys.platform
1010

1111

1212
def hydrate(namespace: dict):
1313
"""
14-
Scans the DOM for data-py-on-* attributes and binds the
15-
corresponding functions from the provided namespace.
14+
Scans the DOM for Violetear interactive elements and binds them to
15+
Python functions found in the provided namespace.
16+
17+
Args:
18+
namespace: A dictionary mapping function names to callables.
19+
Typically, you pass `globals()` here from your client script.
1620
"""
1721
if not IS_BROWSER:
18-
return
22+
raise TypeError("Hydration called outside of browser environment. Skipping.")
23+
24+
from js import document
25+
from pyodide.ffi import create_proxy
26+
27+
# We explicitly scan for common events.
28+
# In the future, we could inspect the DOM more aggressively or use a MutationObserver.
29+
supported_events = [
30+
"click", "change", "input", "submit",
31+
"keydown", "keyup", "mouseenter", "mouseleave"
32+
]
33+
34+
bound_count = 0
35+
36+
for event_name in supported_events:
37+
# The markup generates attributes like: data-py-on-click="my_func"
38+
attr = f"data-on-{event_name}"
39+
selector = f"[{attr}]"
40+
41+
elements = document.querySelectorAll(selector)
42+
43+
for element in elements:
44+
handler_name = element.getAttribute(attr)
45+
46+
if handler_name in namespace:
47+
handler_func = namespace[handler_name]
48+
49+
# 1. Create a Pyodide Proxy
50+
# We wrap the python function so JS can call it safely.
51+
# 'create_proxy' ensures the function isn't garbage collected immediately.
52+
proxy = create_proxy(handler_func)
53+
54+
# 2. Bind the Listener
55+
# We attach the Python proxy directly to the JS event listener
56+
element.addEventListener(event_name, proxy)
57+
58+
# 3. Cleanup (Optional)
59+
# We remove the data attribute so we don't double-bind if hydrate is called again
60+
element.removeAttribute(attr)
1961

20-
from js import document # type: ignore
62+
bound_count += 1
63+
else:
64+
print(f"[Violetear] Warning: Function '{handler_name}' not found for event '{event_name}'")
2165

22-
# Phase 3 Implementation placeholder:
23-
# 1. Query Selector for [data-py-on-*]
24-
# 2. Extract event name and function name
25-
# 3. Look up function in `namespace`
26-
# 4. element.addEventListener(...)
27-
pass
66+
print(f"[Violetear] Hydrated {bound_count} interactive elements.")

violetear/markup.py

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,32 @@ def attrs(self, **attrs) -> Element:
111111
self._attrs.update(attrs)
112112
return self
113113

114+
def on(self, event: str, handler: Callable) -> Self:
115+
"""
116+
Binds a python function to a DOM event.
117+
Serializes the function name to a data attribute.
118+
"""
119+
if not hasattr(handler, "__name__"):
120+
raise ValueError("Event handler must be a named function")
121+
122+
# We store it as a special attribute for the client runtime to find
123+
self._attrs[f"data-on-{event}"] = handler.__name__
124+
return self
125+
126+
def has_bindings(self) -> bool:
127+
"""Checks if this element or any child has a Python event binding."""
128+
# Check self
129+
for key in self._attrs:
130+
if key.startswith("data-on-"):
131+
return True
132+
133+
# Check children
134+
for child in self._content:
135+
if isinstance(child, Element) and child.has_bindings():
136+
return True
137+
138+
return False
139+
114140
def _render(self, fp, indent: int):
115141
parts = [self._tag]
116142

@@ -243,6 +269,18 @@ class StyleResource:
243269
inline: bool = False
244270

245271

272+
@dataclass
273+
class ScriptResource:
274+
"""
275+
Represents a JS script resource.
276+
"""
277+
278+
src: str | None = None
279+
content: str | None = None
280+
defer: bool = False
281+
module: bool = False
282+
283+
246284
class Document(Markup):
247285
def __init__(self, lang: str = "en", **head_kwargs) -> None:
248286
self.lang = lang
@@ -274,6 +312,20 @@ def style(
274312

275313
return self
276314

315+
def script(
316+
self,
317+
src: str | None = None,
318+
content: str | None = None,
319+
defer: bool = False,
320+
module: bool = False,
321+
) -> Document:
322+
self.head.scripts.append(
323+
ScriptResource(
324+
src, textwrap.dedent(content) if content else None, defer, module
325+
)
326+
)
327+
return self
328+
277329
def _render(self, fp, indent: int):
278330
self._write_line(fp, "<!DOCTYPE html>")
279331
self._write_line(fp, f'<html lang="{self.lang}">')
@@ -287,6 +339,7 @@ def __init__(self, charset: str = "UTF-8", title: str = "") -> None:
287339
self.charset = charset
288340
self.title = title
289341
self.styles: list[StyleResource] = []
342+
self.scripts: list[ScriptResource] = []
290343

291344
def _render(self, fp, indent: int):
292345
self._write_line(fp, "<head>", indent)
@@ -301,19 +354,36 @@ def _render(self, fp, indent: int):
301354
)
302355
self._write_line(fp, f"<title>{self.title}</title>", indent + 1)
303356

304-
for resource in self.styles:
305-
if resource.inline and resource.sheet:
357+
for style in self.styles:
358+
if style.inline and style.sheet:
306359
# Render Inline
307360
self._write_line(fp, "<style>", indent + 1)
308-
resource.sheet.render(fp)
361+
self._write_line(fp, style.sheet.render(), indent + 1)
309362
self._write_line(fp, "</style>", indent + 1)
310363

311-
elif resource.href:
364+
elif style.href:
312365
# Render Link
313366
self._write_line(
314-
fp, f'<link rel="stylesheet" href="{resource.href}">', indent + 1
367+
fp, f'<link rel="stylesheet" href="{style.href}">', indent + 1
315368
)
316369

370+
for script in self.scripts:
371+
if script.src:
372+
# External script
373+
attrs = f'src="{script.src}"'
374+
if script.defer:
375+
attrs += " defer"
376+
if script.module:
377+
attrs += ' type="module"'
378+
self._write_line(fp, f"<script {attrs}></script>", indent + 1)
379+
380+
elif script.content:
381+
# Inline script
382+
attrs = ' type="module"' if script.module else ""
383+
self._write_line(fp, f"<script{attrs}>", indent + 1)
384+
self._write_line(fp, script.content, indent + 1)
385+
self._write_line(fp, "</script>", indent + 1)
386+
317387
self._write_line(fp, "</head>", indent)
318388

319389

0 commit comments

Comments
 (0)