Skip to content

Commit b238859

Browse files
committed
Working on app.local
1 parent 41ab3bf commit b238859

File tree

6 files changed

+671
-199
lines changed

6 files changed

+671
-199
lines changed

examples/reactivity.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
from dataclasses import dataclass, field
2+
from violetear.app import App
3+
from violetear.markup import Document
4+
from violetear import StyleSheet, Style
5+
from violetear.dom import Event
6+
7+
# --- 1. Instantiate the App ---
8+
app = App(title="Reactive Engine Demo")
9+
10+
# --- 2. Define the Reactive State ---
11+
@app.local
12+
@dataclass
13+
class UiState:
14+
count: int = 0
15+
username: str = "Guest"
16+
theme: str = "light"
17+
18+
# --- 3. Define Client-Side Logic ---
19+
@app.client.callback
20+
async def increment(event: Event):
21+
UiState.count += 1
22+
23+
@app.client.callback
24+
async def toggle_theme(event: Event):
25+
if UiState.theme == "light":
26+
UiState.theme = "dark"
27+
else:
28+
UiState.theme = "light"
29+
30+
@app.client.callback
31+
async def update_name(event: Event):
32+
UiState.username = event.target.value
33+
34+
@app.client.callback
35+
async def reset_all(event: Event):
36+
UiState.count = 0
37+
UiState.username = "Guest"
38+
UiState.theme = "light"
39+
40+
# --- 4. Define the View ---
41+
@app.view("/")
42+
def home():
43+
doc = Document(title="Reactive Demo")
44+
45+
# --- Define Styles using the Violetear StyleSheet API ---
46+
sheet = StyleSheet()
47+
48+
# Define Theme Variables
49+
# We use .rule() explicitly for CSS variables since python kwargs don't support dashes
50+
sheet.select(".light").rule("--bg", "#ffffff").rule("--fg", "#333333")
51+
sheet.select(".dark").rule("--bg", "#333333").rule("--fg", "#ffffff")
52+
53+
# Define Global Body Styles
54+
sheet.select("body").font(family="sans-serif").margin(0).padding(0)
55+
56+
# Attach the stylesheet to the document head
57+
doc.style(sheet=sheet, inline=True)
58+
59+
# --- Build the UI ---
60+
with doc.body as b:
61+
62+
# Container with inline styles using the Style() fluent API
63+
container_style = (
64+
Style()
65+
.padding("20px")
66+
.rule("transition", "all 0.3s ease")
67+
.background("var(--bg)")
68+
.color("var(--fg)")
69+
.height(min="100vh")
70+
)
71+
72+
# We chain .style() BEFORE the context manager
73+
with b.div(class_name=UiState.theme, id="app-container").style(container_style) as container:
74+
75+
container.h1("Reactive Engine Demo")
76+
77+
# Card Style
78+
card_style = (
79+
Style()
80+
.border(width="1px", color="#ccc")
81+
.padding("20px")
82+
.rounded("8px")
83+
)
84+
85+
with container.div().style(card_style) as card:
86+
# Text Binding
87+
card.p().text("Current Count: ").add(
88+
card.span()
89+
.text(UiState.count)
90+
.style(Style().font(weight="bold", size="20px"))
91+
)
92+
93+
card.button("Increment").on("click", increment).style(
94+
Style().padding("8px 16px").rule("cursor", "pointer")
95+
)
96+
97+
# Input Section
98+
with container.div().style(Style().margin(top="20px")) as form:
99+
form.label("Enter your name: ")
100+
101+
# Value Binding
102+
form.input(type="text").on("input", update_name).value(UiState.username).style(
103+
Style().padding("8px").margin(left="10px")
104+
)
105+
106+
form.p("Hello, ").add(
107+
form.span()
108+
.text(UiState.username)
109+
.style(Style().font(weight="bold"))
110+
)
111+
112+
# Controls Section
113+
with container.div().style(Style().margin(top="20px")) as controls:
114+
btn_style = Style().padding("8px 16px").rule("cursor", "pointer")
115+
116+
controls.button("Toggle Theme").on("click", toggle_theme).style(btn_style)
117+
118+
controls.button("Reset").on("click", reset_all).style(
119+
# Extend the base button style with margin
120+
Style().apply(btn_style).margin(left="10px")
121+
)
122+
123+
return doc
124+
125+
if __name__ == "__main__":
126+
app.run(port=8000)

violetear/app.py

Lines changed: 100 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@
77
import json
88
from pathlib import Path
99
from textwrap import dedent
10-
from typing import Any, Callable, Dict, List, Set, Union
10+
from typing import Any, Callable, Dict, List, Set, Union, cast
1111
import uuid
1212

1313
from .stylesheet import StyleSheet
14-
from .markup import Document, StyleResource
14+
from .markup import Document
1515
from .pwa import Manifest, ServiceWorker
16+
from .state import ReactiveProxy, local
1617

1718

1819
# --- Optional Server Dependencies ---
@@ -161,10 +162,15 @@ class ClientRegistry:
161162
def __init__(self, app: "App"):
162163
self.app = app
163164
self.code_functions: Dict[str, Callable] = {}
165+
self.state_classes: Dict[str, type] = {}
164166
self.callback_names: Set[str] = set()
165167
self.realtime_functions: Dict[str, Callable] = {}
166168
self.event_handlers: Dict[str, List[str]] = {}
167169

170+
def register_state(self, cls: type):
171+
"""Registers a class to be transpiled to the client."""
172+
self.state_classes[cls.__name__] = cls
173+
168174
def _register(self, func: Callable):
169175
"""Helper to register the raw function source."""
170176
# Unwrap if it's already a stub (in case of decorator stacking)
@@ -397,6 +403,16 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str):
397403
self.client = ClientRegistry(self)
398404
self.server = ServerRegistry(self)
399405

406+
# UPDATED: self.local wrapper
407+
# This intercepts the decorator to register the class before passing it
408+
# to the logic in state.py
409+
def local[T](self, cls: type[T]) -> T:
410+
# 1. Register source code for the bundle
411+
self.client.register_state(cls)
412+
413+
# 2. Return the server-side proxy
414+
return local(cls)
415+
400416
def _register_rpc_route(self, func: Callable):
401417
"""
402418
Decorator to expose a function as a server-side RPC endpoint.
@@ -514,29 +530,57 @@ def _generate_bundle(self) -> str:
514530
# 1. Mock the 'app' object
515531
header = "class Event: pass"
516532

517-
# 2. Inject violetear.dom module
518-
# This allows 'from violetear.dom import Document' to work in the browser
533+
# 2. Inject violetear.state module
534+
state_path = Path(__file__).parent / "state.py"
535+
with open(state_path, "r") as f:
536+
state_source = f.read()
537+
538+
state_injection = dedent(
539+
f"""
540+
import sys, types
541+
542+
# 1. Create or Get 'violetear' parent package
543+
if "violetear" not in sys.modules:
544+
m_violetear = types.ModuleType("violetear")
545+
sys.modules["violetear"] = m_violetear
546+
else:
547+
m_violetear = sys.modules["violetear"]
548+
549+
# 2. Create 'violetear.state'
550+
m_state = types.ModuleType("violetear.state")
551+
sys.modules["violetear.state"] = m_state
552+
553+
# 3. Link child to parent (Critical for imports to work)
554+
m_violetear.state = m_state
555+
556+
# 4. Execute source
557+
exec({repr(state_source)}, m_state.__dict__)
558+
559+
# 5. Global Import (Makes 'violetear' available in this script)
560+
import violetear.state
561+
"""
562+
)
563+
564+
# 3. Inject violetear.dom module
519565
dom_path = Path(__file__).parent / "dom.py"
520566
with open(dom_path, "r") as f:
521567
dom_source = f.read()
522568

523569
dom_injection = dedent(
524570
f"""
525-
import sys, types
526-
# Create virtual module 'violetear'
527-
m_violetear = types.ModuleType("violetear")
528-
sys.modules["violetear"] = m_violetear
529-
530-
# Create virtual module 'violetear.dom'
531571
m_dom = types.ModuleType("violetear.dom")
532572
sys.modules["violetear.dom"] = m_dom
533573
534-
# Execute source
574+
# Link to parent
575+
sys.modules["violetear"].dom = m_dom
576+
535577
exec({repr(dom_source)}, m_dom.__dict__)
578+
579+
import violetear.dom
536580
"""
537581
)
538582

539-
# Inject Storage
583+
# 4. Inject Storage
540584
storage_path = Path(__file__).parent / "storage.py"
541585
with open(storage_path, "r") as f:
542586
storage_source = f.read()
@@ -545,61 +589,89 @@ def _generate_bundle(self) -> str:
545589
f"""
546590
m_storage = types.ModuleType("violetear.storage")
547591
sys.modules["violetear.storage"] = m_storage
592+
593+
# Link to parent
594+
sys.modules["violetear"].storage = m_storage
595+
548596
exec({repr(storage_source)}, m_storage.__dict__)
597+
598+
import violetear.storage
549599
"""
550600
)
551601

552-
# 3. Read the Client Runtime (Hydration logic)
602+
# 5. Read the Client Runtime
553603
runtime_path = Path(__file__).parent / "client.py"
554604
with open(runtime_path, "r") as f:
555605
runtime_code = f.read()
556606

557-
# 4. Extract User Functions
607+
# 6. Generate State Classes (with dataclass re-application)
608+
state_code = []
609+
for name, cls in self.client.state_classes.items():
610+
source = inspect.getsource(cls)
611+
lines = source.split("\n")
612+
613+
# Check for dataclass
614+
is_dataclass = "@dataclass" in source
615+
616+
# Strip decorators to avoid 'app' definition errors
617+
clean_lines = [l for l in lines if not l.strip().startswith("@")]
618+
619+
# Reconstruct class
620+
state_code.append("\n".join(clean_lines))
621+
622+
# Re-apply dataclass
623+
if is_dataclass:
624+
state_code.append(f"{name} = dataclass({name})")
625+
626+
# Apply Reactive Proxy
627+
# Now safe because we imported violetear.state above
628+
state_code.append(f"{name} = violetear.state.local({name})")
629+
630+
# 7. Extract User Functions
558631
user_code = []
559632
for name, func in self.client.code_functions.items():
560633
code = inspect.getsource(func).split("\n")
561-
code = [c for c in code if not c.startswith("@")] # remove decorators
634+
code = [c for c in code if not c.startswith("@")]
562635
user_code.append("\n".join(code))
563636

564-
# --- SAFETY INJECTION START ---
565-
# We attach a dummy .broadcast() method to client functions running in the browser.
566-
# This prevents confusion if a user tries to call await my_func.broadcast() in client code.
637+
# --- Safety Checks & Server Stubs ---
567638
safety_checks = []
568639
safety_checks.append(
569640
dedent(
570641
"""
571642
def _server_only_broadcast(*args, **kwargs):
572-
raise RuntimeError("❌ .broadcast() cannot be called from the Client (Browser).\\nIt must be called from the Server to trigger client updates.")
643+
raise RuntimeError("❌ .broadcast() cannot be called from the Client.")
573644
def _server_only_invoke(*args, **kwargs):
574-
raise RuntimeError("❌ .invoke() cannot be called from the Client (Browser).\\nIt must be called from the Server to trigger client updates.")
645+
raise RuntimeError("❌ .invoke() cannot be called from the Client.")
575646
"""
576647
)
577648
)
578-
579649
for name in self.client.realtime_functions.keys():
580650
safety_checks.append(f"{name}.broadcast = _server_only_broadcast")
581651
safety_checks.append(f"{name}.invoke = _server_only_invoke")
582652

583653
safety_code = "\n".join(safety_checks)
584-
# --- SAFETY INJECTION END ---
585-
586-
# 5. Generate Server Stubs
587654
server_stubs = self._generate_server_stubs()
588655

589-
# 6. Initialization
656+
# 8. Initialization
590657
init_code = "# --- Init ---\nhydrate(globals())"
591-
592-
# Run startup functions
593658
for func_name in self.client.event_handlers.get("ready", []):
594659
init_code += f"\nawait {func_name}()"
595660

661+
# Global Imports for the Bundle
662+
# Ensure standard library + violetear components are ready
663+
imports = "from dataclasses import dataclass, field\nimport datetime\nimport json"
664+
596665
return "\n\n".join(
597666
[
667+
imports,
598668
header,
669+
state_injection,
599670
dom_injection,
600671
storage_injection,
601672
runtime_code,
602-
"\n\n".join(user_code),
673+
"\n".join(state_code),
674+
"\n".join(user_code),
603675
safety_code,
604676
server_stubs,
605677
init_code,

0 commit comments

Comments
 (0)