+

-PyWry is a blazingly fast rendering library for generating and managing native desktop windows, iFrames, and Jupyter widgets - with full bidirectional Python ↔ JavaScript communication. Get started in minutes, not hours.
+**Blazingly fast rendering library for native desktop windows, Jupyter widgets, and browser tabs.**
+
+Full bidirectional Python ↔ JavaScript communication. Get started in minutes, not hours.
+
+[](https://pypi.org/project/pywry/)
+[](https://pypi.org/project/pywry/)
+[](LICENSE)
+[](https://deeleeramone.github.io/PyWry/)
+
+
-Unlike other similar libraries, PyWry is **not** a web dashboard framework. It is a **rendering engine** that targets three output paths from one API:
+---
+PyWry is **not** a web dashboard framework. It is a **rendering engine** that targets three output paths from one unified API:
| Mode | Where It Runs | Backend |
|------|---------------|---------|
@@ -11,45 +23,38 @@ Unlike other similar libraries, PyWry is **not** a web dashboard framework. It i
| `NOTEBOOK` | Jupyter / VS Code / Colab | anywidget or IFrame + FastAPI + WebSocket |
| `BROWSER` | System browser tab | FastAPI server + WebSocket + Redis |
-It uses declarative Pydantic components that automatically wrap content in a nested structure that can be targeted with CSS selectors:
-
-HEADER → LEFT | TOP → CONTENT + INSIDE → BOTTOM | RIGHT → FOOTER
-
-Built on [PyTauri](https://pypi.org/project/pytauri/) (which uses Rust's [Tauri](https://tauri.app/) framework), it leverages the OS webview instead of bundling a browser engine — a few MBs versus Electron's 150MB+ overhead.
+Built on [PyTauri](https://pypi.org/project/pytauri/) (Rust's [Tauri](https://tauri.app/) framework), it uses the OS webview instead of bundling a browser engine — a few MBs versus Electron's 150MB+ overhead.
-Its unified API lets you build fast and use anywhere. Batteries included.
-
-Features at a Glance
| Feature | What It Does |
|---------|--------------|
| **Native Windows** | Lightweight OS webview windows (not Electron) |
-| **Jupyter Widgets** | Works in notebooks with anywidget |
+| **Jupyter Widgets** | Works in notebooks via anywidget with traitlet sync |
| **Browser Mode** | Deploy to web with FastAPI + WebSocket |
-| **Toolbar System** | 18 declarative Pydantic components with 7 layout positions — automatic nested flexbox structure |
-| **Two-Way Events** | Python ↔ JavaScript communication with pre-wired Plotly/AgGrid events and utility events for DOM manipulation |
-| **Marquee Ticker** | Scrolling text/content with dynamic updates |
-| **AgGrid Tables** | Best-in-class Pandas → AgGrid conversion, with pre-wired grid events, context menus, and practical default gridOptions |
-| **Plotly Charts** | Plotly rendering with pre-wired plot events for Dash-like functionality when combined with Toolbar components |
-| **Toast Notifications** | Built-in alert system with positioning |
-| **Theming & CSS** | Light/dark modes, 60+ CSS variables, component ID targeting, and dynamic styling via events (`pywry:set-style`, `pywry:inject-css`) |
-| **Secrets Handling** | Secure password inputs — values stored server-side, never rendered in HTML, configurable getter and setter methods |
-| **Security** | Scoped token auth enabled by default, CSP headers, internal API protection, production presets available |
-| **Configuration** | TOML files, env vars, security presets |
-| **Hot Reload** | Live CSS/JS updates during development |
-| **Deploy Mode** | Redis backend for horizontal scaling |
+| **Toolbar System** | 18 declarative Pydantic components with 7 layout positions |
+| **Two-Way Events** | Python ↔ JavaScript with pre-wired Plotly/AgGrid events |
+| **Modals** | Overlay dialogs with toolbar components inside |
+| **AgGrid Tables** | Pandas → AgGrid conversion with pre-wired grid events |
+| **Plotly Charts** | Plotly rendering with custom modebar buttons and plot events |
+| **Toast Notifications** | Built-in alert system with configurable positioning |
+| **Theming & CSS** | Light/dark modes, 60+ CSS variables, hot reload |
+| **Secrets Handling** | Server-side password storage, never rendered in HTML |
+| **Security** | Token auth, CSP headers, production presets |
+| **Configuration** | Layered TOML files, env vars, security presets |
+| **Hot Reload** | Live CSS injection and JS updates during development |
+| **Deploy Mode** | Redis state backend for horizontal scaling |
+| **MCP Server** | AI agent integration via Model Context Protocol |
## Installation
-Install in a virtual environment with a version of Python between 3.10 and 3.14.
-
-### Linux
+Requires Python 3.10–3.14. Install in a virtual environment.
-Linux requires WebKitGTK and GTK3 development libraries:
+
+Linux Prerequisites
```bash
# Ubuntu/Debian
@@ -59,32 +64,25 @@ sudo apt-get install libwebkit2gtk-4.1-dev libgtk-3-dev libglib2.0-dev \
libxcb-shape0 libgl1 libegl1
```
-### Basic Installation
+
```bash
pip install pywry
```
-### Optional Extras
-
| Extra | Command | Description |
|-------|---------|-------------|
-| **notebook** | `pip install 'pywry[notebook]'` | anywidget for Jupyter integration (recommended) |
+| **notebook** | `pip install 'pywry[notebook]'` | anywidget for Jupyter integration |
| **mcp** | `pip install 'pywry[mcp]'` | Model Context Protocol server for AI agents |
| **all** | `pip install 'pywry[all]'` | All optional dependencies |
-| **dev** | `pip install 'pywry[dev]'` | Development and testing tools |
-
-For AI agent integration with MCP:
-```bash
-pip install 'pywry[mcp]'
-```
+> See [Installation Guide](https://deeleeramone.github.io/PyWry/getting-started/installation/) for full details.
---
## Quick Start
-### Hello World!
+### Hello World
```python
from pywry import PyWry
@@ -94,35 +92,31 @@ app = PyWry()
app.show("Hello World!")
app.block() # block the main thread until the window closes
-
-app.show("Hello again, World!")
```
-### Button Updates Content
+### Interactive Toolbar
```python
from pywry import PyWry, Toolbar, Button
-# Create instance
app = PyWry()
def on_click(data, event_type, label):
- """Update the heading text and inject custom CSS."""
app.emit("pywry:set-content", {"selector": "h1", "text": "Toolbar Works!"}, label)
-# Display HTML with an interactive toolbar
toolbar = Toolbar(
position="top",
items=[Button(label="Update Text", event="app:click")]
)
+
handle = app.show(
- "
")
-widget.display() # Show IFrame in notebook
-
-# Widget properties
-print(widget.label) # Widget ID
-print(widget.widget_id) # Same as label
-print(widget.url) # http://localhost:8765/widget/{id}
-
-# Open in browser
-widget.open_in_browser()
-
-# Toast notifications
-widget.alert("Success!", alert_type="success")
-```
-
-
-
-**Properties:**
-
-| Property | Type | Description |
-|----------|------|-------------|
-| `widget_id` | `str` | Unique widget identifier |
-| `label` | `str` | Alias for `widget_id` (BaseWidget protocol) |
-| `url` | `str` | Full URL to access widget |
-| `output` | `Output` | IPython Output widget for callback messages |
-
-**Methods:**
-
-| Method | Description |
-|--------|-------------|
-| `emit(event_type, data)` | Send event to JavaScript |
-| `send(event_type, data)` | Alias for `emit()` |
-| `on(event_type, callback)` | Register event callback |
-| `update(html)` | Update HTML content |
-| `update_html(html)` | Alias for `update()` |
-| `display()` | Display IFrame and output widget in notebook |
-| `open_in_browser()` | Open widget URL in system browser |
-| `alert(message, ...)` | Show toast notification |
-
-
-Toast Notifications
-
-```python
-# Basic alerts
-widget.alert("Operation complete", alert_type="success")
-widget.alert("Something went wrong", alert_type="error")
-widget.alert("Please confirm", alert_type="warning")
-
-# With options
-widget.alert(
- message="File saved successfully",
- alert_type="success",
- title="Save Complete",
- duration=5000, # Auto-dismiss after 5 seconds
- position="bottom-right", # top-right, top-left, bottom-right, bottom-left
-)
-
-# Confirmation dialog
-widget.alert(
- message="Delete this item?",
- alert_type="confirm",
- callback_event="confirm:delete", # Emits event with user response
-)
-```
-
-
-
-
-Constructor Parameters
-
-```python
-InlineWidget(
- html: str, # HTML content to render
- callbacks: dict | None = None, # Event callbacks
- width: str = "100%", # Widget width
- height: int = 500, # Widget height in pixels
- port: int | None = None, # Server port (default from settings)
- widget_id: str | None = None, # Custom widget ID
- headers: dict | None = None, # Custom HTTP headers
- auth: Any | None = None, # Authentication config
- browser_only: bool = False, # Skip IPython requirement
- token: str | None = None, # Widget access token
-)
-```
-
-
-
-Used as fallback when anywidget unavailable, or for browser mode deployment.
-
-### Window Management
-
-> **See also:** [Managing Multiple Windows/Widgets](#managing-multiple-windowswidgets) for comprehensive coverage of both native windows and notebook widgets, including return types, widget properties, and lifecycle management.
-
-PyWry provides fine-grained control over window visibility and lifecycle through [NativeWindowHandle](#nativewindowhandle) methods and `PyWry` app methods. Windows can be shown, hidden, and closed independently.
-
-**App-Level Methods (operate on labels):**
-
-| Method | Description |
-|--------|-------------|
-| `show_window(label)` | Show a hidden window (brings to front) |
-| `hide_window(label)` | Hide a window (keeps it alive, not destroyed) |
-| `close(label)` | Close/destroy a specific window permanently |
-| `close()` | Close/destroy all windows |
-| `get_labels()` | Get list of currently visible window labels |
-| `block(label=None)` | Block until specific window or all windows close |
-
-**Handle-Level Methods (via NativeWindowHandle):**
-
-You can also control windows directly through the [NativeWindowHandle](#nativewindowhandle) returned by `show_*()`:
-
-```python
-handle = app.show("
Hello
")
-handle.hide() # Same as app.hide_window(handle.label)
-handle.show_window() # Same as app.show_window(handle.label)
-handle.close() # Same as app.close(handle.label)
-```
-
-See [NativeWindowHandle](#nativewindowhandle) for the full API including `maximize()`, `minimize()`, `center()`, `set_title()`, and `proxy` access.
-
-**Window Lifecycle:**
-
-```
-Created → Visible → Hidden → Visible → Closed
- ↑ │ ↑
- └─────────┘ │
- (show_window) (close)
-```
-
-**X Button Behavior by Mode:**
-
-| Mode | X Button | Behavior |
-|------|----------|----------|
-| `SINGLE_WINDOW` | Always hides | Window reused on next `show()` |
-| `NEW_WINDOW` | Always closes | Window destroyed, cannot recover |
-| `MULTI_WINDOW` | Configurable | Uses `on_window_close` setting |
-
-For `MULTI_WINDOW`, use `on_window_close` to control whether clicking X hides or destroys windows:
-
-```python
-from pywry import PyWry, PyWrySettings, WindowMode
-
-# X button hides windows (default) - can recover with show_window()
-app = PyWry(mode=WindowMode.MULTI_WINDOW)
-
-# X button destroys windows - cannot recover
-settings = PyWrySettings()
-settings.window.on_window_close = "close"
-app = PyWry(mode=WindowMode.MULTI_WINDOW, settings=settings)
-```
-
-**Example: Managing Multiple Windows**
-
-```python
-from pywry import PyWry, WindowMode
-
-app = PyWry(mode=WindowMode.MULTI_WINDOW)
-
-# Create windows with labels
-app.show("
Dashboard
", label="dashboard")
-app.show("
Settings
", label="settings")
-app.show("
Help
", label="help")
-
-# Check visible windows
-print(app.get_labels()) # ['dashboard', 'settings', 'help']
-
-# Hide the settings window
-app.hide_window("settings")
-print(app.get_labels()) # ['dashboard', 'help']
-
-# Bring it back
-app.show_window("settings")
-print(app.get_labels()) # ['dashboard', 'settings', 'help']
-
-# Close the help window permanently
-app.close("help")
-print(app.get_labels()) # ['dashboard', 'settings']
-
-# Block until specific window closes
-app.block("dashboard") # Waits only for dashboard to close
-
-print(app.get_labels())
-# Or block until all windows close
-app.block() # Waits for all remaining windows
-```
-
-**SINGLE_WINDOW Mode Behavior:**
-
-In `SINGLE_WINDOW` mode, calling `show()` multiple times reuses the same window without destroying it. If the window was hidden, it will be shown again with the new content:
-
-```python
-from pywry import PyWry, WindowMode
-
-app = PyWry(mode=WindowMode.SINGLE_WINDOW)
-
-# First show - creates window
-app.show("
Page 1
")
-
-# User hides window (clicks X)...
-
-# Second show - reuses window, shows it again
-app.show("
", watch=True)
-```
-
-### Manual CSS Reload
-
-```python
-# Reload CSS for all windows
-pywry.refresh_css()
-
-# Reload CSS for specific window
-pywry.refresh_css(label="main-window")
-```
-
-### Configuration
-
-```toml
-[hot_reload]
-enabled = true
-debounce_ms = 100 # Wait before reloading (milliseconds)
-css_reload = "inject" # "inject" or "refresh"
-preserve_scroll = true # Keep scroll position on JS refresh
-watch_directories = ["./src", "./styles"]
-```
-
-
-
----
-
-## Event System
-
-
-
-
-Click to expand
-
-**In this section:** [What is an Event?](#what-is-an-event) · [Event Naming](#event-naming-format) · [Reserved Namespaces](#reserved-namespaces) · [Handler Signature](#handler-signature) · [Registering Handlers](#registering-handlers) · [Wildcard Handlers](#wildcard-handlers)
-
----
-
-PyWry provides bidirectional communication between Python and JavaScript through a **namespace-based event system**. This allows your Python code to respond to user interactions in the browser (clicks, selections, form inputs) and to send updates back to the browser UI.
-
-### What is an Event?
-
-An **event** is a message with a name and optional data. Events flow in two directions:
-
-1. **JS → Python**: User does something in the browser (clicks a chart, selects a row) → JavaScript sends an event → Python callback is triggered
-2. **Python → JS**: Your Python code wants to update the UI → Python sends an event → JavaScript handler updates the display
-
-### Event Naming Format
-
-All events follow the pattern: `namespace:event-name`
-
-| Part | Rules | Examples |
-|------|-------|----------|
-| **namespace** | Starts with letter, alphanumeric only | `app`, `plotly`, `grid`, `myapp` |
-| **event-name** | Starts with letter, alphanumeric + hyphens + underscores | `click`, `row-select`, `update_data` |
-
-**Valid examples:** `app:save`, `plotly:click`, `grid:row-select`, `myapp:refresh`
-
-**Invalid examples:** `save` (no namespace), `:click` (empty namespace), `123:event` (starts with number)
-
-> **Note:** JavaScript validation is stricter (lowercase only) than Python validation. For maximum compatibility, use **lowercase with hyphens** (e.g., `app:my-action`).
-
-#### Compound Event IDs (Advanced)
-
-Events can optionally include a third component for targeting specific widgets: `namespace:event-name:component-id`
-
-```python
-# Handle clicks from ANY chart
-app.on("plotly:click", handle_all_charts)
-
-# Handle clicks from a SPECIFIC chart
-app.on("plotly:click:sales-chart", handle_sales_chart)
-```
-
-### Reserved Namespaces
-
-These namespaces are used by PyWry internally. **Do not use them for custom events:**
-
-| Namespace | Purpose |
-|-----------|---------|
-| `pywry:*` | System events (initialization, results) |
-| `plotly:*` | Plotly chart events |
-| `grid:*` | AgGrid table events |
-
-> **Tip:** Use a namespace that makes sense for your app (e.g., `app:`, `data:`, `view:`, `myapp:`).
-
-### Handler Signature
-
-All event handlers receive three arguments:
-
-```python
-def handler(data: dict, event_type: str, label: str) -> None:
- """
- Parameters
- ----------
- data : dict
- Event payload from JavaScript (e.g., clicked point, selected rows)
- event_type : str
- The event that was triggered (e.g., "plotly:click", "app:export")
- label : str
- Window/widget identifier (e.g., "main", "chart-1")
- """
- pass
-```
-
-PyWry inspects your function signature and supports shorter versions for convenience:
-
-```python
-from pywry import PyWry, Toolbar, Button
-
-app = PyWry()
-
-# Full signature (recommended) - access all context
-def on_action1(data, event_type, label):
- app.emit("pywry:set-content", {"id": "log", "text": f"[{label}] {event_type}: {data}"}, label)
-
-# Two parameters - when you don't need the widget label
-def on_action2(data, event_type):
- print(f"{event_type} received")
-
-# One parameter - simplest, just the data
-def on_action3(data):
- print(str(data))
-
-handle = app.show(
- '
Click a button...
',
- toolbars=[Toolbar(position="top", items=[
- Button(label="Action 1", event="app:action1"),
- Button(label="Action 2", event="app:action2"),
- Button(label="Action 3", event="app:action3"),
- ])],
- callbacks={
- "app:action1": on_action1,
- "app:action2": on_action2,
- "app:action3": on_action3,
- }
-)
-```
-
-### Registering Handlers
-
-**All modes support the `callbacks={}` parameter in `show()` methods.** This is the most portable approach.
-
-| Mode | `callbacks={}` | `app.on()` | `widget.on()` | Returns |
-|------|----------------|------------|---------------|---------|
-| Native Window | ✅ | ✅ | ✅ | `NativeWindowHandle` |
-| Notebook (anywidget) | ✅ | ❌ | ✅ | `PyWryWidget` |
-| Browser Mode | ✅ | ❌ | ✅ | `InlineWidget` |
-
-#### Option 1: `callbacks={}` in show() — Works Everywhere
-
-```python
-from pywry import PyWry, Toolbar, Button
-import plotly.express as px
-
-app = PyWry()
-
-# Create a Plotly figure
-fig = px.scatter(px.data.iris(), x="sepal_width", y="sepal_length", color="species")
-
-def on_click(data, event_type, label):
- """When user clicks a point, print the coordinates."""
- point = data["points"][0]
- print(f"Clicked: ({point['x']:.2f}, {point['y']:.2f})")
-
-def on_export(data, event_type, label):
- """When export is clicked, save CSV."""
- csv_content = px.data.iris().to_csv(index=False)
- print(f"Exporting {len(csv_content)} bytes...")
-
-# Works in ALL modes (native returns NativeWindowHandle, notebook returns widget)
-handle = app.show_plotly(
- fig,
- toolbars=[Toolbar(position="top", items=[Button(label="Export CSV", event="app:export")])],
- callbacks={
- "plotly:click": on_click,
- "app:export": on_export,
- }
-)
-```
-
-#### Option 2: `app.on()` — Native Windows Only
-
-For native desktop windows, you can register handlers separately using `app.on()`. **Important:** You must use the same `label` when registering handlers and showing content:
-
-```python
-from pywry import PyWry, Toolbar, Button
-import plotly.express as px
-
-app = PyWry()
-
-fig = px.scatter(px.data.iris(), x="sepal_width", y="sepal_length", color="species")
-
-def on_click(data, event_type, label):
- """When user clicks a point, update the chart title."""
- point = data["points"][0]
- app.emit("plotly:update-layout", {
- "layout": {"title": f"Selected: ({point['x']:.2f}, {point['y']:.2f})"}
- }, label)
-
-def on_zoom_reset(data, event_type, label):
- """Reset chart zoom when button is clicked."""
- app.emit("plotly:reset-zoom", {}, label)
-
-# Register handlers with explicit label BEFORE calling show()
-app.on("plotly:click", on_click, label="my-chart")
-app.on("app:zoom-reset", on_zoom_reset, label="my-chart")
-
-# Use the SAME label when showing content
-handle = app.show_plotly(
- fig,
- title="Click points to select them",
- toolbars=[Toolbar(position="top", items=[Button(label="Reset Zoom", event="app:zoom-reset")])],
- label="my-chart", # Must match the label used in app.on()
-)
-```
-
-> **Note:** If you omit the `label` parameter, `show()` generates a random label and `app.on()` registers to `"main"`, causing a mismatch. For simpler code, prefer `callbacks={}` in `show()` (Option 1).
-```
-
-#### Option 3: `widget.on()` — All Modes
-
-All widgets (native and notebook) support chaining handlers after `show()`. This is useful for adding handlers dynamically:
-
-```python
-from pywry import PyWry, Toolbar, Button
-import plotly.express as px
-
-app = PyWry()
-
-fig = px.scatter(px.data.iris(), x="sepal_width", y="sepal_length", color="species")
-
-def on_click(data, event_type, label):
- """When user clicks a point, update the chart title."""
- point = data["points"][0]
- app.emit("plotly:update-layout", {
- "layout": {"title": f"Last clicked: ({point['x']:.2f}, {point['y']:.2f})"}
- }, label)
-
-def on_theme_toggle(data, event_type, label):
- """Toggle to dark theme."""
- app.emit("pywry:update-theme", {"theme": "plotly_dark"}, label)
-
-# Using callbacks={} is recommended for most cases
-handle = app.show_plotly(
- fig,
- title="Click points to annotate",
- toolbars=[Toolbar(position="top", items=[Button(label="Dark Mode", event="app:theme")])],
- callbacks={
- "plotly:click": on_click,
- "app:theme": on_theme_toggle,
- }
-)
-
-# handle.on() can add additional handlers after show()
-# handle.on("plotly:hover", on_hover)
-```
-
-### Wildcard Handlers
-
-Wildcard handlers receive ALL events — useful for debugging during development:
-
-```python
-from pywry import PyWry, Toolbar, Button
-
-app = PyWry()
-
-def log_event(data, event_type, label):
- """Log the event to console."""
- print(f"[{event_type}] {data}")
-
-# Show content with toolbar buttons
-handle = app.show(
- '
Click a button above...
',
- toolbars=[
- Toolbar(position="top", items=[
- Button(label="Action 1", event="app:action1"),
- Button(label="Action 2", event="app:action2"),
- ])
- ],
- # Use callbacks for each event - no race condition
- callbacks={
- "app:action1": log_event,
- "app:action2": log_event,
- }
-)
-```
-
-> **Note:** For true wildcard logging in native mode, use `app.on("*", handler)` but be aware that events firing before `handle` is assigned (like `window:ready`) will need a guard check.
-
----
-
-## Pre-Registered Events (Built-in)
-
-PyWry automatically hooks into Plotly and AgGrid event systems. These **pre-registered events** are emitted automatically when users interact with charts and grids — **no JavaScript required**.
-
-**In this section:** [What "Pre-Registered" Means](#what-pre-registered-means) · [Understanding IDs](#understanding-ids-label-vs-chartidgrididcomponentid) · [System Events](#system-events-pywry) · [Toast Notifications](#toast-notifications-pywryalert) · [Plotly Events](#plotly-events-plotly) · [AgGrid Events](#aggrid-events-grid) · [Toolbar Events](#toolbar-events-toolbar)
-
-### What "Pre-Registered" Means
-
-When you create a Plotly chart or AgGrid table, PyWry injects JavaScript that:
-1. Listens for native library events (e.g., Plotly's `plotly_click`)
-2. Transforms the raw event data into a standardized payload
-3. Emits a PyWry event (e.g., `plotly:click`) that triggers your Python callback
-
-**You just register a Python handler; PyWry handles the JavaScript wiring.**
-
-### Understanding IDs: label vs. chartId/gridId/componentId
-
-PyWry uses a hierarchy of identifiers:
-
-| ID Type | Scope | Purpose | Example |
-|---------|-------|---------|---------|
-| `label` | Window/Widget | Identifies a window (native) or widget (notebook/browser) | `"pywry-abc123"`, `"w-def456"` |
-| `chartId` | Component | Identifies a specific Plotly chart within a window | `"sales-chart"` |
-| `gridId` | Component | Identifies a specific AgGrid table within a window | `"users-grid"` |
-| `componentId` | Toolbar Item | Identifies a specific toolbar control | `"theme-select"`, `"export-btn"` |
-
-**Event Payloads Include IDs:**
-- **Plotly events** (`plotly:click`, `plotly:hover`, etc.) include `chartId` and `widget_type: "chart"` in the payload.
-- **AgGrid events** (`grid:row-selected`, `grid:cell-click`, `grid:cell-edit`, etc.) include `gridId` and `widget_type: "grid"` in the payload.
-- **Toolbar components** always include `componentId` in their payloads.
-- **Python → JS events** support targeting via `chartId`/`gridId` when using widget methods like `widget.update_figure(fig, chart_id="my-chart")`.
-
-### System Events (`pywry:*`)
-
-These are internal events for window/widget lifecycle and utility operations.
-
-#### Lifecycle Events (JS → Python)
-
-| Event | Payload | Description |
-|-------|---------|-------------|
-| `pywry:ready` | `{}` | Window/widget has finished initializing |
-| `pywry:result` | `any` | Data sent via `window.pywry.result(data)` |
-| `pywry:content-request` | `{ widget_type, window_label, reason }` | Window requests content (initial load or reload) |
-| `pywry:disconnect` | `{}` | Widget disconnected (inline/browser mode only) |
-
-#### Utility Events (Python → JS)
-
-These events trigger built-in browser behaviors. They are handled automatically by PyWry's JavaScript bridge — **no custom JavaScript required**:
-
-| Event | Payload | Description |
-|-------|---------|-------------|
-| `pywry:update-theme` | `{ theme: str }` | Update theme dynamically (e.g., `"plotly_dark"`, `"plotly_white"`) |
-| `pywry:inject-css` | `{ css: str, id?: str }` | Inject CSS dynamically; optional `id` for replacing existing styles |
-| `pywry:remove-css` | `{ id: str }` | Remove a previously injected CSS style element by ID |
-| `pywry:set-style` | `{ id?: str, selector?: str, styles: {} }` | Update inline styles on element(s) by id or CSS selector |
-| `pywry:set-content` | `{ id?: str, selector?: str, html?: str, text?: str }` | Update innerHTML or textContent on element(s) |
-| `pywry:update-html` | `{ html: str }` | Replace entire widget/window HTML content |
-| `pywry:download` | `{ content: str, filename: str, mimeType?: str }` | Trigger a file download |
-| `pywry:download-csv` | `{ csv: str, filename: str }` | Trigger a CSV file download (Jupyter widget mode) |
-| `pywry:navigate` | `{ url: str }` | Navigate to a URL (SPA-style navigation) |
-| `pywry:alert` | `{ message, type?, title?, duration?, position? }` | Show a toast notification (info, success, warning, error, confirm) |
-| `pywry:refresh` | `{}` | Request fresh content from Python (triggers content re-send) |
-
-**Example: DOM Manipulation Without Custom JavaScript**
-
-```python
-from pywry import PyWry
-import time
-
-app = PyWry()
-
-# Show HTML with elements we'll manipulate
-html = """
-
-
Dashboard
-
- Loading...
-
-
- Row 1: Pending
-
-
- Row 2: Pending
-
-
Waiting for data...
-
-"""
-
-handle = app.show(html, title="DOM Manipulation Demo")
-
-# Update the status badge style
-handle.emit("pywry:set-style", {
- "id": "status-badge",
- "styles": {"backgroundColor": "green", "color": "white"}
-})
-
-time.sleep(1)
-# Update all rows with a highlight (by CSS selector)
-handle.emit("pywry:set-style", {
- "selector": ".data-row",
- "styles": {"borderColor": "#3b82f6", "borderWidth": "2px"} # Blue border
-})
-
-time.sleep(1)
-# Update content by ID (with HTML)
-handle.emit("pywry:set-content", {
- "id": "values-display",
- "html": "Updated! 42 items loaded"
-})
-time.sleep(1)
-# Update all status-text elements (plain text, safer)
-handle.emit("pywry:set-content", {
- "selector": ".status-text",
- "text": "Complete"
-})
-
-# Update the badge text
-handle.emit("pywry:set-content", {
- "id": "status-badge",
- "text": "Ready"
-})
-time.sleep(1)
-# Inject custom CSS dynamically
-handle.emit("pywry:inject-css", {
- "css": "#title { color: #2563eb; }",
- "id": "my-styles"
-})
-time.sleep(2)
-
-handle.close()
-```
-
-> **Note:** For notebook mode, use `widget.emit("pywry:...", {...})` on the returned widget instead.
-> `pywry:set-style` and `pywry:set-content` support either `id` (for a single element by ID) or `selector` (for multiple elements via CSS selector). If both are provided, `id` takes precedence.
-
-### Toast Notifications (`pywry:alert`)
-
-
-Alert System
-
-PyWry provides a unified toast notification system that works consistently across all rendering paths (native window, notebook, and browser). Toast notifications are non-blocking (except `confirm` type) and support multiple types with automatic dismiss behavior.
-
-#### Alert Types
-
-| Type | Icon | Default Behavior | Use Case |
-|------|------|------------------|----------|
-| `info` | ℹ️ | Auto-dismiss 5s | Status updates, general information |
-| `success` | ✅ | Auto-dismiss 3s | Completed actions, confirmations |
-| `warning` | ⚠️ | Persist until clicked | Important notices requiring attention |
-| `error` | ⛔ | Persist until clicked | Errors requiring acknowledgment |
-| `confirm` | ❓ | Blocks UI until response | User confirmation needed before action |
-
-#### Example
-
-```python
-from pywry import PyWry, Toolbar, Button
-
-app = PyWry()
-
-# Create a toolbar with buttons for each alert type
-toolbar = Toolbar(
- position="top",
- items=[
- Button(label="ℹ️ Info", event="alert:info"),
- Button(label="✅ Success", event="alert:success"),
- Button(label="⚠️ Warning", event="alert:warning"),
- Button(label="⛔ Error", event="alert:error"),
- Button(label="❓ Confirm", event="alert:confirm"),
- ]
-)
-
-def show_info(data, event_type, label):
- app.alert("Data refreshed successfully", alert_type="info", label=label)
-
-def show_success(data, event_type, label):
- app.alert("Export complete!", alert_type="success", title="Done", label=label)
-
-def show_warning(data, event_type, label):
- app.alert("No items selected.", alert_type="warning", title="Selection Required", label=label)
-
-def show_error(data, event_type, label):
- app.alert("Failed to connect to server.", alert_type="error", title="Connection Error", label=label)
-
-def show_confirm(data, event_type, label):
- app.alert(
- "Are you sure you want to delete these items?",
- alert_type="confirm",
- title="Confirm Delete",
- callback_event="alert:confirm-response",
- label=label
- )
-
-def handle_confirm_response(data, event_type, label):
- if data.get("confirmed"):
- app.alert("Items deleted successfully", alert_type="success", label=label)
- else:
- app.alert("Deletion cancelled", alert_type="info", label=label)
-
-handle = app.show(
- "
Alert Demo
Click the toolbar buttons to see different alert types.
",
- title="Toast Notifications",
- toolbars=[toolbar],
- callbacks={
- "alert:info": show_info,
- "alert:success": show_success,
- "alert:warning": show_warning,
- "alert:error": show_error,
- "alert:confirm": show_confirm,
- "alert:confirm-response": handle_confirm_response,
- }
-)
-```
-
-> **Note:** The `callback_event` parameter specifies which event to emit when the user clicks Confirm or Cancel. Register a handler for that event in your `callbacks={}` dict to process the response.
-
-#### Using emit() Directly
-
-```python
-# Same as app.alert() but with explicit event emission
-handle.emit("pywry:alert", {
- "message": "Processing complete",
- "type": "success",
- "title": "Done",
- "duration": 4000, # Override auto-dismiss time (ms)
- "position": "bottom-right" # top-right, top-left, bottom-right, bottom-left
-})
-```
-
-#### Toast Positions
-
-Toasts can be positioned in any corner of the widget container:
-
-| Position | Description |
-|----------|-------------|
-| `top-right` | Top-right corner (default) |
-| `top-left` | Top-left corner |
-| `bottom-right` | Bottom-right corner |
-| `bottom-left` | Bottom-left corner |
-
-```python
-# Position toast in different corners
-handle.emit("pywry:alert", {"message": "Top right", "position": "top-right"})
-handle.emit("pywry:alert", {"message": "Bottom left", "position": "bottom-left"})
-```
-
-#### Multiple Toasts and Stacking
-
-Multiple toasts stack vertically in their position container. New toasts appear at the top of the stack. When a toast is dismissed, remaining toasts stay in position without animation to prevent jarring visual effects.
-
-```python
-# Show multiple toasts - they stack
-app.alert("First message", alert_type="info", label=handle.label)
-app.alert("Second message", alert_type="success", label=handle.label)
-app.alert("Third message", alert_type="warning", label=handle.label)
-```
-
-#### Keyboard Shortcuts
-
-| Key | Action |
-|-----|--------|
-| `Escape` | Dismiss all visible toasts in the widget |
-
-#### JavaScript API (Advanced)
-
-The toast system exposes a global `PYWRY_TOAST` object for custom JavaScript integrations:
-
-```javascript
-// Show a toast programmatically
-window.PYWRY_TOAST.show({
- message: "Hello from JS",
- type: "info",
- title: "Custom Title",
- duration: 5000,
- position: "top-right",
- container: document.querySelector('.pywry-widget')
-});
-
-// Show a confirm dialog
-window.PYWRY_TOAST.confirm({
- message: "Are you sure?",
- title: "Confirm",
- position: "top-right",
- container: document.querySelector('.pywry-widget'),
- onConfirm: function() { console.log("Confirmed"); },
- onCancel: function() { console.log("Cancelled"); }
-});
-
-// Dismiss a specific toast by ID
-window.PYWRY_TOAST.dismiss(toastId);
-
-// Dismiss all toasts in a widget
-window.PYWRY_TOAST.dismissAllInWidget(container);
-```
-
-
-
-### Plotly Events (`plotly:*`)
-
-#### Events from JavaScript → Python (User Interactions)
-
-These events fire automatically when users interact with Plotly charts:
-
-| Event | Trigger | Payload |
-|-------|---------|---------|
-| `plotly:click` | User clicks a data point | `{ chartId, widget_type, points: [...], point_indices: [...], curve_number, event }` |
-| `plotly:hover` | User hovers over a point | `{ chartId, widget_type, points: [...], point_indices: [...], curve_number }` |
-| `plotly:selected` | User selects with box/lasso | `{ chartId, widget_type, points: [...], point_indices: [...], range, lassoPoints }` |
-| `plotly:relayout` | User zooms, pans, or resizes | `{ chartId, widget_type, relayout_data: {...} }` |
-| `plotly:state-response` | Response to state request | `{ chartId, layout: {...}, data: [...] }` |
-
-> **Note:** All Plotly events include `chartId` and `widget_type: "chart"` in the payload for identifying which chart triggered the event.
-
-**`plotly:click` payload structure:**
-```python
-{
- "chartId": "chart_abc123", # Unique chart identifier
- "widget_type": "chart", # Always "chart" for Plotly events
- "points": [
- {
- "curveNumber": 0, # Which trace (0-indexed)
- "pointNumber": 5, # Which point in the trace (0-indexed)
- "pointIndex": 5, # Same as pointNumber (Plotly alias)
- "x": 2.5, # X value
- "y": 10.3, # Y value
- "z": None, # Z value (for 3D charts)
- "text": "label", # Point text label (if any)
- "customdata": {...}, # Custom data attached to point
- "data": {...}, # Full trace data object
- "trace_name": "Series A" # Name of the trace
- }
- ],
- "point_indices": [5], # List of all pointNumber values
- "curve_number": 0, # curveNumber of first point (convenience)
- "event": {...} # Original browser event
-}
-```
-
-**Example:**
-
-```python
-from pywry import PyWry
-import plotly.express as px
-
-app = PyWry()
-
-fig = px.scatter(px.data.iris(), x="sepal_width", y="sepal_length", color="species")
-
-def on_chart_click(data, event_type, label):
- """Display clicked point coordinates in the chart title."""
- point = data["points"][0]
- app.emit("plotly:update-layout", {
- "layout": {"title": f"Clicked: ({point['x']:.2f}, {point['y']:.2f})"}
- }, label)
-
-handle = app.show_plotly(fig, title="Click any point", callbacks={"plotly:click": on_chart_click})
-```
-
-#### Events from Python → JavaScript (Update Chart)
-
-Use these to update the chart programmatically. These methods support optional `chart_id` targeting:
-
-| Event | Method | Payload |
-|-------|--------|---------|
-| `plotly:update-figure` | `widget.update_figure(fig, chart_id=...)` | `{ figure: {...}, chartId?, config?: {...}, animate?: bool }` |
-| `plotly:update-layout` | `widget.update_layout({...})` | `{ layout: {...} }` |
-| `plotly:update-traces` | `widget.update_traces({...}, indices)` | `{ update: {...}, indices: [int, ...] or null }` |
-| `plotly:reset-zoom` | `widget.reset_zoom()` | `{}` |
-| `plotly:request-state` | `widget.request_plotly_state(chart_id=...)` | `{ chartId? }` |
-| `plotly:export-data` | `app.emit("plotly:export-data", {chartId?}, label)` | `{ chartId? }` |
-
-> **Note:** `plotly:export-data` triggers `plotly:export-response` (JS → Python) with payload `{ data: [{ traceIndex, name, x, y, type }, ...] }` containing extracted trace data.
-
-### AgGrid Events (`grid:*`)
-
-#### Events from JavaScript → Python (User Interactions)
-
-These events fire automatically when users interact with AgGrid tables:
-
-| Event | Trigger | Payload |
-|-------|---------|---------|
-| `grid:row-selected` | Row selection changes | `{ gridId, widget_type, rows: [...] }` |
-| `grid:cell-click` | User clicks a cell | `{ gridId, widget_type, rowIndex, colId, value, data }` |
-| `grid:cell-edit` | User edits a cell | `{ gridId, widget_type, rowIndex, rowId, colId, oldValue, newValue, data }` |
-| `grid:filter-changed` | Filter applied | `{ gridId, widget_type, filterModel }` |
-| `grid:data-truncated` | Dataset truncated (client-side) | `{ gridId, widget_type, displayedRows, truncatedRows, message }` |
-| `grid:mode` | Grid mode info (server-side) | `{ gridId, widget_type, mode, serverSide, totalRows, blockSize, message }` |
-| `grid:request-page` | Server-side requests data block | `{ gridId, widget_type, startRow, endRow, sortModel, filterModel }` |
-| `grid:state-response` | Response to state request | `{ gridId, columnState, filterModel, sortModel, context? }` |
-
-> **Note:** All AgGrid events include `gridId` and `widget_type: "grid"` in the payload for identifying which grid triggered the event.
-
-**`grid:row-selected` payload structure:**
-```python
-{
- "gridId": "grid_def456", # Unique grid identifier
- "widget_type": "grid", # Always "grid" for AgGrid events
- "rows": [
- {"name": "Alice", "age": 30, "city": "NYC"},
- {"name": "Bob", "age": 25, "city": "LA"}
- ]
-}
-```
-
-**Example:**
-
-```python
-from pywry import PyWry
-import pandas as pd
-
-app = PyWry()
-
-df = pd.DataFrame({"name": ["Alice", "Bob", "Carol"], "age": [30, 25, 35], "city": ["NYC", "LA", "SF"]})
-
-def on_row_select(data, event_type, label):
- """Print selected row names."""
- rows = data["rows"]
- names = ", ".join(row["name"] for row in rows)
- print(f"Selected: {names}" if names else "No selection")
-
-handle = app.show_dataframe(
- df,
- callbacks={"grid:row-selected": on_row_select}
-)
-```
-
-#### Events from Python → JavaScript (Update Grid)
-
-Use these to update the grid programmatically. These methods support optional `grid_id` targeting:
-
-| Event | Method | Payload |
-|-------|--------|---------|
-| `grid:page-response` | (server-side callback) | `{ gridId, rows: [...], totalRows, isLastPage, requestId }` |
-| `grid:update-data` | `widget.update_data(rows, grid_id=...)` | `{ data: [...], gridId?, strategy? }` |
-| `grid:update-columns` | `widget.update_columns(col_defs, grid_id=...)` | `{ columnDefs: [...], gridId? }` |
-| `grid:update-cell` | `widget.update_cell(row_id, col, value, grid_id=...)` | `{ rowId, colId, value, gridId? }` |
-| `grid:update-grid` | `widget.update_grid(options, grid_id=...)` | `{ data?, columnDefs?, restoreState?, gridId? }` |
-| `grid:request-state` | `widget.request_grid_state(grid_id=...)` | `{ gridId?, context? }` |
-| `grid:restore-state` | `widget.restore_state(state, grid_id=...)` | `{ state: {...}, gridId? }` |
-| `grid:reset-state` | `widget.reset_state(grid_id=...)` | `{ gridId?, hard?: bool }` |
-| `grid:update-theme` | `widget.update_theme(theme, grid_id=...)` | `{ theme, gridId? }` |
-| `grid:show-notification` | (internal) | `{ message, duration?, gridId? }` |
-
-### Toolbar Events (`toolbar:*`)
-
-Toolbar events are used for state management (querying and setting component values).
-
-#### System Toolbar Events (Automatic)
-
-These events are emitted automatically when users interact with toolbar chrome:
-
-| Event | Direction | Payload | Description |
-|-------|-----------|---------|-------------|
-| `toolbar:collapse` | JS → Python | `{ componentId, collapsed: true }` | User collapsed a toolbar |
-| `toolbar:expand` | JS → Python | `{ componentId, collapsed: false }` | User expanded a toolbar |
-| `toolbar:resize` | JS → Python | `{ componentId, position, width, height }` | User resized a toolbar |
-
-#### State Management Events
-
-| Event | Direction | Payload | Description |
-|-------|-----------|---------|-------------|
-| `toolbar:state-response` | JS → Python | `{ toolbars, components, timestamp, context? }` | Response to state request |
-| `toolbar:request-state` | Python → JS | `{ toolbarId?, componentId?, context? }` | Request current state |
-| `toolbar:set-value` | Python → JS | `{ componentId, value?, label?, disabled?, ...attrs }` | Set component value and/or attributes |
-| `toolbar:set-values` | Python → JS | `{ values: { id: value, ... }, toolbarId? }` | Set multiple component values |
-
-#### Marquee Events
-
-| Event | Direction | Payload | Description |
-|-------|-----------|---------|-------------|
-| `toolbar:marquee-set-content` | Python → JS | `{ id, text?, html?, speed?, paused?, separator? }` | Update marquee content or settings |
-| `toolbar:marquee-set-item` | Python → JS | `{ ticker, text?, html?, styles?, class_add?, class_remove? }` | Update individual ticker item by `data-ticker` |
-
-> **Note:** Toolbar *components* (Button, Select, etc.) emit their own custom events that you define via the `event` parameter. All component events automatically include `componentId` in their payload. See the Toolbar System section.
-
----
-
-## Custom Events
-
-Custom events are events **you define** for your application. Unlike pre-registered events, custom events require you to either:
-
-1. Use toolbar components (which emit events automatically), or
-2. Write JavaScript that calls `window.pywry.emit()`
-
-**In this section:** [Event Direction Overview](#event-direction-overview) · [JS → Python](#js--python-receiving-events-from-javascript) · [Python → JS](#python--js-sending-events-to-javascript) · [Two-Way Communication](#complete-two-way-communication-example)
-
-### Event Direction Overview
-
-| Direction | How to Send | How to Receive | Use Case |
-|-----------|-------------|----------------|----------|
-| **JS → Python** | `window.pywry.emit(event, data)` | `callbacks={}`, `app.on()`, `widget.on()` | User interactions |
-| **Python → JS** | `app.emit(event, data, label=...)` or `widget.emit(event, data)` | `window.pywry.on(event, handler)` | Update UI |
-
-### JS → Python: Receiving Events from JavaScript
-
-#### Toolbar Component Events (Easiest)
-
-Toolbar components automatically emit events — you just specify the event name:
-
-```python
-from pywry import PyWry, Toolbar, Button, Select, Option
-import pandas as pd
-
-app = PyWry()
-
-# Sample data to export
-data = pd.DataFrame({"name": ["Alice", "Bob"], "score": [95, 87]})
-
-toolbar = Toolbar(
- position="top",
- items=[
- Button(label="Export CSV", event="app:export-csv"),
- Button(label="Refresh", event="app:refresh"),
- Select(
- label="Theme:",
- event="app:theme-change",
- options=[Option(label="Light", value="light"), Option(label="Dark", value="dark")],
- selected="dark"
- ),
- ]
-)
-
-def on_export(data_evt, event_type, label):
- """Trigger a CSV download when export button is clicked."""
- app.emit("pywry:download", {
- "content": data.to_csv(index=False),
- "filename": "export.csv",
- "mimeType": "text/csv"
- }, label)
-
-def on_theme_change(data_evt, event_type, label):
- """Apply the selected theme to the chart."""
- theme = "plotly_dark" if data_evt["value"] == "dark" else "plotly_white"
- app.emit("pywry:update-theme", {"theme": theme}, label)
-
-handle = app.show(
- "
',
- toolbars=[Toolbar(position="top", items=[
- Button(label="Click Me", event="app:click", component_id="my-btn")
- ])],
- callbacks={"app:click": on_click}
-)
-```
-
-
-
----
-
-## JavaScript Bridge
-
-
-Click to expand
-
-**In this section:** [Available Methods](#available-methods) · [Injected Globals](#injected-globals) · [AgGrid API](#accessing-aggrid-api) · [Plotly API](#accessing-plotly-api) · [Toolbar API](#accessing-toolbar-api) · [Toast Notifications](#using-toast-notifications) · [Two-Way Communication](#example-two-way-communication) · [System Event Handlers](#built-in-system-event-handlers)
-
----
-
-PyWry injects a `window.pywry` object for JavaScript ↔ Python communication.
-
-### Available Methods
-
-```javascript
-// Send result back to Python
-window.pywry.result(data);
-
-// Emit event to Python
-window.pywry.emit("app:action", { key: "value" });
-
-// Register JS event handler
-window.pywry.on("app:update", function(data) {
- console.log("Received:", data);
-});
-
-// Remove event handler
-window.pywry.off("app:update", handler);
-
-// Open file with system default application
-window.pywry.openFile("/path/to/file.pdf");
-
-// Get current theme
-console.log(window.pywry.theme); // "dark" or "light"
-```
-
-### Injected Globals
-
-PyWry injects several globals into the browser context:
-
-```javascript
-// ─────────────────────────────────────────────────────────────────────────────
-// CORE GLOBALS (always present)
-// ─────────────────────────────────────────────────────────────────────────────
-
-window.__PYWRY_LABEL__ // Window label, e.g., "main-window"
-window.json_data // JSON data from Python (via HtmlContent.json_data)
-
-// ─────────────────────────────────────────────────────────────────────────────
-// PLOTLY GLOBALS (when include_plotly=True)
-// ─────────────────────────────────────────────────────────────────────────────
-
-window.__PYWRY_PLOTLY_DIV__ // Reference to main Plotly chart container
-window.__PYWRY_CHARTS__ // Registry of all Plotly charts by ID
-window.PYWRY_PLOTLY_TEMPLATES // Bundled templates: ggplot2, seaborn, simple_white,
- // plotly, plotly_white, plotly_dark, presentation,
- // xgridoff, ygridoff, gridon
-
-// ─────────────────────────────────────────────────────────────────────────────
-// AGGRID GLOBALS (when include_aggrid=True)
-// ─────────────────────────────────────────────────────────────────────────────
-
-window.__PYWRY_GRID_API__ // Main AgGrid API for the primary grid
-window.__PYWRY_GRIDS__ // Registry of all grid instances by ID
-window.PYWRY_SHOW_NOTIFICATION // Helper: PYWRY_SHOW_NOTIFICATION(msg, duration, container)
-
-// ─────────────────────────────────────────────────────────────────────────────
-// TOOLBAR GLOBALS (when toolbar is rendered)
-// ─────────────────────────────────────────────────────────────────────────────
-
-window.__PYWRY_TOOLBAR__ // Toolbar API: { getState(), getValue(id), setValue(id, val) }
-
-// ─────────────────────────────────────────────────────────────────────────────
-// TOAST NOTIFICATION SYSTEM
-// ─────────────────────────────────────────────────────────────────────────────
-
-window.PYWRY_TOAST // Toast API: show(type, message, options), dismiss(id), clear()
- // Types: 'info', 'success', 'warning', 'error', 'confirm'
-```
-
-### Accessing AgGrid API
-
-```javascript
-// ── Primary Grid API ──────────────────────────────────────────────────────────
-// Get selected rows
-const rows = window.__PYWRY_GRID_API__.getSelectedRows();
-
-// Update data
-window.__PYWRY_GRID_API__.setGridOption('rowData', newData);
-
-// Apply transactions
-window.__PYWRY_GRID_API__.applyTransaction({ update: [row1, row2] });
-
-// Export to CSV
-window.__PYWRY_GRID_API__.exportDataAsCsv();
-
-// ── Multi-Grid Access ─────────────────────────────────────────────────────────
-// Access any grid by its ID
-const grid = window.__PYWRY_GRIDS__['my-grid-id'];
-if (grid) {
- grid.api.getSelectedRows();
-}
-
-// Show a notification toast
-window.PYWRY_SHOW_NOTIFICATION('Data updated!', 3000);
-```
-
-### Accessing Plotly API
-
-```javascript
-// ── Primary Chart API ─────────────────────────────────────────────────────────
-// Update chart layout
-Plotly.relayout(window.__PYWRY_PLOTLY_DIV__, { title: 'New Title' });
-
-// Update chart data
-Plotly.react(window.__PYWRY_PLOTLY_DIV__, newData, newLayout);
-
-// Apply template
-Plotly.update(window.__PYWRY_PLOTLY_DIV__, {}, {
- template: window.PYWRY_PLOTLY_TEMPLATES['seaborn']
-});
-
-// ── Multi-Chart Access ────────────────────────────────────────────────────────
-// Access any chart by its ID
-const chart = window.__PYWRY_CHARTS__['my-chart-id'];
-if (chart) {
- Plotly.relayout(chart, { title: 'Updated' });
-}
-```
-
-### Accessing Toolbar API
-
-```javascript
-// Get current state of all toolbar components
-const state = window.__PYWRY_TOOLBAR__.getState();
-// { "theme-select": "dark", "zoom-slider": 100 }
-
-// Get value of a specific component by ID
-const theme = window.__PYWRY_TOOLBAR__.getValue('theme-select');
-
-// Set value of a component (triggers change event)
-window.__PYWRY_TOOLBAR__.setValue('zoom-slider', 150);
-```
-
-### Using Toast Notifications
-
-```javascript
-// Show different toast types
-window.PYWRY_TOAST.show('info', 'Processing your request...');
-window.PYWRY_TOAST.show('success', 'Changes saved!');
-window.PYWRY_TOAST.show('warning', 'This action cannot be undone');
-window.PYWRY_TOAST.show('error', 'Failed to connect to server');
-
-// Confirmation dialog with callback
-window.PYWRY_TOAST.show('confirm', 'Delete this item?', {
- onConfirm: function() { deleteItem(); },
- onCancel: function() { console.log('Cancelled'); }
-});
-
-// Dismiss all toasts
-window.PYWRY_TOAST.clear();
-```
-
-### Example: Two-Way Communication
-
-```python
-from pywry import PyWry
-
-app = PyWry()
-
-def handle_request(data, event_type, label):
- """Send response back to JavaScript."""
- app.emit("app:response", {"items": [1, 2, 3]}, label)
-
-handle = app.show("""
-
-
Click the button...
-
-""", callbacks={"app:request-data": handle_request})
-```
-
-### Built-in System Event Handlers
-
-PyWry pre-registers handlers for common UI manipulation events. These are handled automatically by the JavaScript bridge — **you don't need to write any JavaScript to use them**:
-
-```python
-from pywry import PyWry
-
-app = PyWry()
-
-# Create a window first
-handle = app.show("""
-
Hello
-
0
-initial
-""")
-
-# Theme switching (updates Plotly templates, AgGrid themes, and CSS classes)
-handle.emit("pywry:update-theme", {"theme": "light"})
-
-# Inject CSS dynamically (with optional id for replacement)
-handle.emit("pywry:inject-css", {"css": ".status { color: green; }", "id": "status-css"})
-
-# Update element styles by id
-handle.emit("pywry:set-style", {"id": "counter", "styles": {"fontSize": "24px", "fontWeight": "bold"}})
-
-# Update element content by id
-handle.emit("pywry:set-content", {"id": "message", "html": "Success!"})
-
-# Update element content by CSS selector
-handle.emit("pywry:set-content", {"selector": ".count", "text": "42"})
-```
-
-See [Utility Events (Python → JS)](#utility-events-python-to-js) for complete documentation.
-
-
-
----
-
-## Direct Tauri API Access
-
-
-Click to expand
-
-**In this section:** [`__TAURI__` Global](#the-__tauri__-global) · [PyTauri IPC](#pytauri-ipc-python--javascript) · [Listening to Events](#listening-to-python-events-javascript) · [Tauri Events](#tauri-internal-events-advanced) · [Custom Handler](#example-custom-tauri-handler) · [Environment Detection](#environment-detection)
-
----
-
-For advanced use cases, you can access the underlying Tauri IPC system directly. PyWry is built on [PyTauri](https://pypi.org/project/pytauri/), which provides full access to Tauri's capabilities.
-
-### The `__TAURI__` Global
-
-When running in a desktop window (not in notebook/inline mode), the `window.__TAURI__` object provides direct access to Tauri APIs:
-
-```javascript
-// Check if running in Tauri context
-if (window.__TAURI__) {
- console.log("Running in Tauri desktop mode");
-}
-
-// Available Tauri plugin APIs:
-window.__TAURI__.dialog // Native file dialogs (save, open, message, ask, confirm)
-window.__TAURI__.fs // Filesystem operations (readTextFile, writeTextFile, exists, mkdir)
-window.__TAURI__.event // Event system (listen, emit)
-window.__TAURI__.pytauri // Python IPC (pyInvoke)
-```
-
-### PyTauri IPC (Python ↔ JavaScript)
-
-Use `window.__TAURI__.pytauri.pyInvoke` to call Python-registered commands:
-
-```javascript
-// Invoke a PyWry command
-window.__TAURI__.pytauri.pyInvoke('pywry_event', {
- label: window.__PYWRY_LABEL__,
- event_type: 'custom:action',
- data: { key: 'value' }
-});
-
-// Send result to Python
-window.__TAURI__.pytauri.pyInvoke('pywry_result', {
- data: { result: 'success' },
- window_label: window.__PYWRY_LABEL__
-});
-
-// Open a file with system default app
-window.__TAURI__.pytauri.pyInvoke('open_file', { path: '/path/to/file.pdf' });
-```
-
-### Listening to Python Events (JavaScript)
-
-Use `window.pywry.on()` to receive events sent from Python:
-
-```javascript
-// Listen for custom events from Python
-window.pywry.on('app:data-update', function(data) {
- console.log('Received data:', data);
- updateUI(data);
-});
-
-// Wildcard listener for debugging
-window.pywry.on('*', function(payload) {
- console.log('Event:', payload.type, payload.data);
-});
-```
-
-### Tauri Internal Events (Advanced)
-
-> **Warning:** These are low-level internal events used by PyWry's core. Most users should use the `window.pywry` bridge instead.
-
-For advanced use cases, you can listen to raw Tauri IPC events:
-
-```javascript
-// Only works in desktop mode (not notebooks)
-if (window.__TAURI__) {
- // Listen for cleanup signal before window closes
- window.__TAURI__.event.listen('pywry:cleanup', function() {
- console.log('Window closing, save state...');
- });
-}
-```
-
-| Internal Event | Payload | Description |
-|----------------|---------|-------------|
-| `pywry:content` | `{ html, theme }` | Set window HTML content |
-| `pywry:eval` | `{ script }` | Execute JavaScript in window |
-| `pywry:event` | `{ type, data }` | Wrapper for Python → JS events |
-| `pywry:init` | `{ label }` | Window initialization |
-| `pywry:cleanup` | None | Window about to close |
-| `pywry:inject-css` | `{ id, css }` | Inject CSS dynamically |
-| `pywry:remove-css` | `{ id }` | Remove injected CSS |
-| `pywry:refresh` | None | Refresh window content |
-
-### Example: Custom Tauri Handler
-
-```python
-from pywry import PyWry
-
-app = PyWry()
-
-def on_button_click(data, event_type, label):
- """Handle button click from JavaScript and send response."""
- count = data.get("count", 0)
- print(f'Received click #{count}')
- app.emit('custom:update', {'message': f'Hello from Python! Count: {count}'}, label)
-
-handle = app.show('''
-
Click the button...
-
-
-''', callbacks={'custom:button-click': on_button_click})
-```
-
-> **Note:** The `window.pywry` bridge is automatically initialized by PyWry. Use `window.pywry.on()` to listen for events from Python, and `window.pywry.emit()` to send events to Python. Native mode returns a `NativeWindowHandle` - use `handle.emit()` or `app.emit(event, data, handle.label)`. Notebook mode returns a widget with `.emit()`.
-
-### Environment Detection
-
-Check whether you're running in desktop mode vs notebook/inline:
-
-```javascript
-// In desktop mode, __TAURI__ exists
-const isDesktop = !!window.__TAURI__;
-
-// In notebook mode, content is in an iframe
-const isNotebook = window.frameElement !== null;
-
-// Conditional logic
-if (isDesktop) {
- // Use Tauri APIs
- window.__TAURI__.pytauri.pyInvoke('pywry_event', payload);
-} else {
- // Use postMessage for iframe communication
- window.parent.postMessage({ type: 'pywry:event', ...payload }, '*');
-}
-```
-
-
-
----
-
-## Managing Multiple Windows/Widgets
-
-
-Click to expand
-
-PyWry can display content in multiple ways, and each has its own management model. This section explains how to create, control, and clean up your display contexts.
-
-**In this section:** [Window vs. Widget](#what-is-a-window-vs-a-widget) · [WindowMode Options](#windowmode-options) · [Return Types](#return-types-by-mode) · [Native Window Management](#native-window-management) · [Widget Management](#widget-management-notebookbrowser) · [Storing References](#storing-references-for-later-control) · [Non-Blocking Scripts](#non-blocking-scripts-with-block) · [Graceful Shutdown](#graceful-shutdown-with-stop_server) · [Instance Methods](#summary-pywry-instance-methods) · [Widget Methods](#summary-widget-methods-notebookbrowser)
-
-### What is a Window vs. a Widget?
-
-| Term | What It Is | When You Get It |
-|------|------------|-----------------|
-| **Window** | A native desktop window (Tauri/WRY) | `WindowMode.NEW_WINDOW`, `SINGLE_WINDOW`, `MULTI_WINDOW` |
-| **Widget** | An embedded display in a notebook cell or browser tab | `WindowMode.NOTEBOOK`, `BROWSER` |
-
-Both **windows** and **widgets** display the same content (HTML, Plotly charts, AgGrid tables). The difference is where and how they appear to the user.
-
-### WindowMode Options
-
-When you create a `PyWry` instance, you choose a mode:
-
-```python
-from pywry import PyWry, WindowMode
-
-# Choose your mode
-app = PyWry(mode=WindowMode.MULTI_WINDOW)
-```
-
-| Mode | Behavior | Use Case |
-|------|----------|----------|
-| `NEW_WINDOW` | Each `show_*()` opens a fresh native window | Simple scripts, one-off displays |
-| `SINGLE_WINDOW` | Reuses the same window, replaces content | Dashboard with tab-like navigation |
-| `MULTI_WINDOW` | Each `show_*()` opens a new window, all stay open | Multi-monitor setups, side-by-side views |
-| `NOTEBOOK` | Renders inline in Jupyter/VS Code notebooks | Interactive data exploration |
-| `BROWSER` | Renders via URL in system browser | Server/headless mode, remote access |
-
-> **Auto-Detection:** If you don't specify a mode, PyWry detects your environment:
-> - Jupyter notebook detected → `NOTEBOOK`
-> - No display available (headless) → `BROWSER`
-> - Otherwise → `NEW_WINDOW`
-
-### Return Types by Mode
-
-The `show_*()` methods return widget objects that provide a unified API:
-
-| Mode | `show_*()` Returns | Control Via |
-|------|-------------------|-------------|
-| `NEW_WINDOW`, `SINGLE_WINDOW`, `MULTI_WINDOW` | `NativeWindowHandle` | `handle.emit(...)`, `handle.close()`, or `app.emit(event, data, handle.label)` |
-| `NOTEBOOK`, `BROWSER` | `BaseWidget` (PyWryWidget/InlineWidget) | `widget.emit(...)`, `widget.on(...)`, `widget.update(...)` |
-
-**Native mode:** use `handle.emit(event, data)` or `app.emit(event, data, handle.label)` to send events to JavaScript.
-**Notebook mode:** use `widget.emit(event, data)` on the returned widget.
-
----
-
-### Native Window Management
-
-In native modes, `show_*()` returns a `NativeWindowHandle` that provides access to the window:
-
-```python
-from pywry import PyWry, WindowMode
-import plotly.express as px
-import pandas as pd
-
-app = PyWry(mode=WindowMode.MULTI_WINDOW)
-
-fig = px.scatter(px.data.iris(), x="sepal_width", y="sepal_length")
-df = pd.DataFrame({"name": ["Alice", "Bob"], "age": [30, 25]})
-
-# Each show_*() returns a NativeWindowHandle
-handle1 = app.show_plotly(fig, title="Chart 1")
-handle2 = app.show_dataframe(df, title="Data")
-handle3 = app.show("
Custom
", title="Custom")
-
-# Send built-in events using handle.emit() or app.emit() with handle.label
-handle3.emit("pywry:set-content", {"id": "heading", "text": "Updated!"})
-
-# Close a specific window
-handle1.close()
-```
-
-#### Querying Windows
-
-```python
-# Get all active window labels
-labels = app.get_labels() # ["pywry-a1b2c3", "pywry-d4e5f6", "pywry-g7h8i9"]
-
-# Check if a specific window is still open
-if app.is_open(handle1.label):
- print(f"Window {handle1.label} is still open")
-
-# Check if ANY window is open
-if app.is_open():
- print("At least one window is open")
-```
-
-#### Controlling Windows
-
-```python
-# Update content using handle.emit() (recommended)
-handle1.emit("pywry:set-content", {"id": "heading", "text": "Hello!"})
-
-# Refresh a specific window (full page reload)
-app.refresh(handle1.label)
-
-# Close a specific window
-handle1.close() # or app.close(handle1.label)
-
-# Close ALL windows
-app.close() # No label = close all
-```
-
----
-
-### Widget Management (Notebook/Browser)
-
-In notebook or browser mode, `show_*()` returns a **widget object** with methods for control:
-
-```python
-from pywry import PyWry, WindowMode
-import plotly.express as px
-
-app = PyWry(mode=WindowMode.NOTEBOOK)
-
-fig = px.scatter(px.data.iris(), x="sepal_width", y="sepal_length", color="species")
-
-# show_*() returns a widget object (not a string)
-widget = app.show_plotly(fig, title="Interactive Chart")
-
-# The widget has a label property for identification
-# widget.label # "w-abc12345"
-```
-
-#### Widget Properties
-
-| Property | Type | Description |
-|----------|------|-------------|
-| `widget.label` | `str` | Unique identifier (e.g., `"w-abc12345"`) |
-| `widget.url` | `str` | Full URL to access in a browser (e.g., `"http://localhost:8765/widget/w-abc12345"`) |
-| `widget.output` | `Output` | IPython Output widget for callback print statements |
-
-#### Registering Event Handlers
-
-```python
-def on_click(data, event_type, label):
- """Update the chart title when a point is clicked."""
- point = data["points"][0]
- widget.emit("plotly:update-layout", {
- "layout": {"title": f"Clicked: ({point['x']:.2f}, {point['y']:.2f})"}
- })
-
-def handle_action(data, event_type, label):
- """Handle custom action by updating status."""
- widget.emit("pywry:set-content", {"id": "status", "text": "Action complete!"})
-
-# Register handlers — method chaining supported
-widget.on("plotly:click", on_click)
-widget.on("custom:action", handle_action)
-
-# Chaining works too
-widget.on("plotly:click", on_click).on("plotly:hover", on_hover)
-```
-
-#### Sending Events to JavaScript
-
-```python
-# Update theme using built-in utility event
-widget.emit("pywry:update-theme", {"theme": "plotly_dark"})
-
-# Or update content
-widget.emit("pywry:set-content", {"id": "status", "text": "Updated!"})
-```
-
-#### Updating Content
-
-```python
-# Replace the widget's HTML content
-widget.update("
New content
")
-
-# For Plotly widgets: update the figure
-widget.update_figure(new_fig)
-
-# For AgGrid widgets: update the data
-widget.update_data(new_rows)
-```
-
-#### Display Methods
-
-```python
-# Display in notebook (usually automatic)
-widget.display()
-
-# Open the widget's URL in system browser (useful for BROWSER mode)
-widget.open_in_browser()
-```
-
----
-
-### Storing References for Later Control
-
-Save the return values to control windows/widgets later:
-
-```python
-from pywry import PyWry, WindowMode
-
-app = PyWry(mode=WindowMode.MULTI_WINDOW)
-
-# Store references in a dict for easy access
-windows = {}
-windows["main"] = app.show_plotly(fig, title="Main Chart")
-windows["sidebar"] = app.show_dataframe(df, title="Data Panel")
-
-# Later: send events to specific windows
-def refresh_main():
- windows["main"].emit("plotly:update-figure", {"figure": new_fig_dict})
-
-def close_sidebar():
- windows["sidebar"].close()
- del windows["sidebar"]
-```
-
----
-
-### Non-Blocking Scripts with `block()`
-
-In BROWSER mode (scripts, not notebooks), windows open but the script continues immediately. Use `block()` to wait for users to close all windows:
-
-```python
-from pywry import PyWry, WindowMode
-from pywry.inline import block
-
-app = PyWry(mode=WindowMode.BROWSER)
-widget = app.show_plotly(fig, title="Dashboard")
-widget.open_in_browser() # Opens in default browser
-
-# Script continues immediately — window is open in browser
-
-# Do other work while user interacts with the chart
-print("Chart is open, doing other work...")
-
-# When ready, block until all browser tabs are closed
-block() # Waits for all widgets to disconnect
-print("All windows closed, exiting")
-```
-
----
-
-### Graceful Shutdown with `stop_server()`
-
-For clean shutdown in long-running processes (web servers, daemons):
-
-```python
-from pywry import PyWry, WindowMode
-from pywry.inline import block, stop_server
-import signal
-
-app = PyWry(mode=WindowMode.BROWSER)
-
-def cleanup(signum, frame):
- print("Shutting down...")
- app.close() # Close all windows
- stop_server(timeout=5.0) # Stop inline server with 5s timeout (default)
- exit(0)
-
-signal.signal(signal.SIGINT, cleanup)
-signal.signal(signal.SIGTERM, cleanup)
-
-# Show windows
-widget1 = app.show_plotly(fig1)
-widget2 = app.show_plotly(fig2)
-widget1.open_in_browser()
-widget2.open_in_browser()
-
-# Keep running until interrupted
-block()
-```
-
----
-
-### Summary: PyWry Instance Methods
-
-Methods available on the `PyWry` app instance:
-
-| Method | Description |
-|--------|-------------|
-| `app.show(html, ...)` | Show HTML content, returns `NativeWindowHandle` or widget |
-| `app.show_plotly(fig, ...)` | Show Plotly figure, returns `NativeWindowHandle` or widget |
-| `app.show_dataframe(df, ...)` | Show DataFrame as AgGrid, returns `NativeWindowHandle` or widget |
-| `app.get_labels()` | Get list of all active window labels |
-| `app.is_open(label=None)` | Check if window(s) are open |
-| `app.emit(event, data, label=None)` | Send event to window(s) |
-| `app.close(label=None)` | Close specific or all windows |
-| `app.refresh(label=None)` | Refresh specific or all windows |
-| `app.refresh_css(label=None)` | Hot-reload CSS without page refresh |
-| `app.on(event, handler)` | Register global event handler |
-| `app.on_chart(event, handler)` | Register Plotly event handler (convenience) |
-| `app.on_grid(event, handler)` | Register AgGrid event handler (convenience) |
-| `app.on_toolbar(event, handler)` | Register toolbar event handler (convenience) |
-| `app.on_html(event, handler)` | Register HTML element event handler (convenience) |
-| `app.on_window(event, handler)` | Register window lifecycle event handler (convenience) |
-| `app.eval_js(script, label)` | Execute JavaScript in a window |
-
-### Summary: Widget Methods (Notebook/Browser)
-
-Methods available on widget objects returned by `show_*()` in NOTEBOOK/BROWSER modes:
-
-| Property/Method | Description |
-|-----------------|-------------|
-| `widget.label` | Unique identifier for this widget |
-| `widget.url` | Full URL to access this widget in a browser |
-| `widget.output` | IPython Output widget for callback prints |
-| `widget.on(event, handler)` | Register event handler (chainable) |
-| `widget.emit(event, data)` | Send event to JavaScript |
-| `widget.update(html)` | Replace widget HTML content |
-| `widget.display()` | Display widget in notebook cell |
-| `widget.open_in_browser()` | Open widget URL in system browser |
-
-**Plotly-specific widget methods:**
-
-| Method | Description |
-|--------|-------------|
-| `widget.update_figure(fig)` | Update the Plotly figure |
-| `widget.reset_zoom()` | Reset chart zoom to auto-range |
-| `widget.set_zoom(x_range, y_range)` | Set chart zoom to specific range |
-
-**AgGrid-specific widget methods:**
-
-| Method | Description |
-|--------|-------------|
-| `widget.update_data(rows)` | Replace grid data |
-| `widget.update_columns(col_defs)` | Replace column definitions |
-| `widget.update_cell(row_id, col, value)` | Update a single cell |
-| `widget.update_grid(data, columns, state)` | Update multiple aspects at once |
-| `widget.request_grid_state()` | Request current grid state (emits `grid:state-response`) |
-| `widget.restore_state(state)` | Restore a saved grid state |
-| `widget.reset_state()` | Reset grid to default state |
-
-**Toolbar-specific widget methods:**
-
-| Method | Description |
-|--------|-------------|
-| `widget.request_toolbar_state()` | Request current toolbar state (emits `toolbar:state-response`) |
-| `widget.get_toolbar_value(component_id)` | Request a specific component's value |
-| `widget.set_toolbar_value(id, value)` | Set a component's value |
-| `widget.set_toolbar_values(values)` | Set multiple component values at once |
-
-
-
----
-
-## Browser Mode & Server Configuration
-
-
-Click to expand
-
-**In this section:** [Getting Widget URL](#getting-the-widget-url) · [Server Configuration](#server-configuration) · [Environment Variables](#environment-variables) · [Production Deployment](#production-deployment-pattern) · [HTTPS](#https-configuration) · [Health Check](#server-health-check) · [WebSocket & API Security](#websocket--api-security)
-
----
-
-For headless environments, remote deployments, or when you want to serve dashboards via HTTP, use `BROWSER` mode with the inline FastAPI server.
-
-### Getting the Widget URL
-
-Every widget has a `.url` property that provides the direct HTTP endpoint:
-
-```python
-from pywry import PyWry, WindowMode
-
-app = PyWry(mode=WindowMode.BROWSER)
-
-# show_* returns an InlineWidget with a .url property
-widget = app.show_plotly(fig, title="Dashboard")
-
-print(widget.url) # http://127.0.0.1:8765/widget/abc123def456
-```
-
-You can share this URL with anyone who can reach the server.
-
-### Server Configuration
-
-Configure the inline server via `pywry.toml`, `pyproject.toml`, or environment variables:
-
-```toml
-# pywry.toml or [tool.pywry.server] in pyproject.toml
-[server]
-host = "0.0.0.0" # Bind to all interfaces (for remote access)
-port = 8080 # Custom port
-auto_start = true # Auto-start when first widget created
-force_notebook = false # Force notebook mode in headless environments
-
-# Uvicorn settings
-workers = 1 # Worker processes
-log_level = "info" # Uvicorn log level
-access_log = true # Enable access logging
-reload = false # Auto-reload (dev mode)
-
-# Timeouts
-timeout_keep_alive = 5 # Keep-alive timeout (seconds)
-timeout_graceful_shutdown = 30 # Graceful shutdown timeout
-
-# SSL/TLS for HTTPS
-ssl_keyfile = "/path/to/key.pem"
-ssl_certfile = "/path/to/cert.pem"
-ssl_keyfile_password = "optional-password"
-ssl_ca_certs = "/path/to/ca-bundle.crt"
-
-# CORS settings (for cross-origin requests)
-cors_origins = ["*"] # Allowed origins (use specific domains in production)
-cors_allow_credentials = true
-cors_allow_methods = ["*"]
-cors_allow_headers = ["*"]
-
-# Limits
-limit_concurrency = 100 # Max concurrent connections
-limit_max_requests = 10000 # Max requests before worker restart
-backlog = 2048 # Socket backlog size
-
-# WebSocket Security
-websocket_allowed_origins = [] # Allowed origins for WebSocket (empty = any, rely on token)
-websocket_require_token = true # Require per-widget token for WebSocket auth
-
-# Internal API Security
-internal_api_header = "X-PyWry-Token" # Header name for internal auth
-internal_api_token = "" # Auto-generated if not set
-strict_widget_auth = false # false = notebook (lenient), true = browser (strict)
-```
-
-### Environment Variables
-
-Override any server setting with `PYWRY_SERVER__*`:
-
-```bash
-# Remote deployment: bind to all interfaces
-export PYWRY_SERVER__HOST=0.0.0.0
-export PYWRY_SERVER__PORT=8080
-
-# Enable HTTPS
-export PYWRY_SERVER__SSL_CERTFILE=/etc/ssl/certs/server.crt
-export PYWRY_SERVER__SSL_KEYFILE=/etc/ssl/private/server.key
-
-# Restrict CORS for production
-export PYWRY_SERVER__CORS_ORIGINS='["https://myapp.com"]'
-
-# Enable logging
-export PYWRY_SERVER__LOG_LEVEL=info
-export PYWRY_SERVER__ACCESS_LOG=true
-
-# WebSocket Security
-export PYWRY_SERVER__WEBSOCKET_ALLOWED_ORIGINS='http://localhost:8080,https://app.example.com'
-export PYWRY_SERVER__WEBSOCKET_REQUIRE_TOKEN=true
-
-# Internal API Security
-export PYWRY_SERVER__INTERNAL_API_HEADER=X-PyWry-Token
-export PYWRY_SERVER__INTERNAL_API_TOKEN=my-secret-token # Or leave empty for auto-gen
-export PYWRY_SERVER__STRICT_WIDGET_AUTH=true # Browser mode (stricter)
-```
-
-### Production Deployment Pattern
-
-For production deployments, create **view factory functions** that generate widgets on demand. Each user request gets a fresh widget instance with a unique ID, while you maintain static, bookmarkable routes.
-
-#### Environment Variables for Production
-
-```bash
-# Set this environment variable on your server:
-export PYWRY_HEADLESS=1 # Forces InlineWidget, skips browser.open()
-```
-
-**What `PYWRY_HEADLESS=1` does:**
-- Forces `InlineWidget` (FastAPI/IFrame) instead of `anywidget` - ensuring `.url` and `.label` are always available
-- Prevents `open_in_browser()` from being called (which would fail on headless servers)
-- No code changes needed - the same API works locally and in production
-
-#### Architecture Overview
-
-```
-Static Route View Factory Widget Instance
-────────────── ────────────── ─────────────────
-GET /dashboard → create_dashboard() → /widget/{unique_id}
-GET /analytics → create_analytics() → /widget/{unique_id}
-GET /sales → create_sales() → /widget/{unique_id}
-```
-
-**How it works:**
-
-1. `pywry.inline.show_plotly()` creates an `InlineWidget` and **immediately** registers it in `_state.widgets`
-2. The server reads from `_state.widgets` when serving `/widget/{id}`
-3. The redirect happens **after** the widget is registered, so it's always available
-
-> **Important:** Set `PYWRY_HEADLESS=1` on your production server. This ensures the same code works both locally (opens browser) and on servers (no browser, just widget registration).
-
-> **Note:** Call `_start_server()` at module load time (not inside route handlers) to avoid a startup delay on the first request.
-
-#### Complete Production Example
-
-```python
-# app.py - Production PyWry server with static routes
-from fastapi import Request
-from fastapi.responses import RedirectResponse
-from pywry.inline import (
- _state,
- _start_server,
- show, # For HTML content
- show_plotly, # For Plotly figures
- show_dataframe, # For DataFrames/AgGrid
-)
-import plotly.express as px
-import pandas as pd
-
-# Start the server at module load time (before defining routes)
-_start_server()
-
-# Get PyWry's FastAPI app and add custom routes
-app = _state.app
-
-# ═══════════════════════════════════════════════════════════
-# VIEW FACTORIES - Each creates a fresh widget for each request
-# open_browser=True forces InlineWidget (required for server deployments)
-# With PYWRY_HEADLESS=1, browser.open() is automatically skipped
-# ═══════════════════════════════════════════════════════════
-
-def create_sales_dashboard(user_id: str | None = None) -> str:
- """Create a sales dashboard widget, return its label."""
- df = get_sales_data(user_id)
- fig = px.bar(df, x="month", y="revenue", title="Sales Dashboard")
-
- # open_browser=True forces InlineWidget which has .url for redirects
- # With PYWRY_HEADLESS=1, open_in_browser() is automatically skipped
- widget = show_plotly(
- fig,
- title="Sales Dashboard",
- callbacks={"chart:export": handle_export},
- open_browser=True, # Forces InlineWidget for server deployments
- )
- return widget.label # .label works on all widget types
-
-def create_inventory_view(warehouse_id: str | None = None) -> str:
- """Create an inventory grid widget."""
- df = get_inventory_data(warehouse_id)
-
- widget = show_dataframe(
- df,
- title="Inventory",
- callbacks={"grid:row-selected": handle_row_select},
- open_browser=True,
- )
- return widget.label
-
-def create_analytics_dashboard() -> str:
- """Create an analytics dashboard with multiple charts."""
- widget = show(
- generate_analytics_html(),
- title="Analytics",
- include_plotly=True,
- callbacks={
- "plotly:click": handle_chart_click,
- "toolbar:refresh": refresh_analytics,
- },
- open_browser=True,
- )
- return widget.label
-
-# ═══════════════════════════════════════════════════════════
-# EVENT HANDLERS - Shared across all widget instances
-# Use runtime.emit_event(label, event, data) when widget reference unavailable
-# ═══════════════════════════════════════════════════════════
-
-def handle_export(data, event_type, label):
- """Handle export request from any sales dashboard."""
- # Use label to target the correct widget
- from pywry import runtime
- runtime.emit_event(label, "pywry:set-content", {"id": "status", "text": "Exporting..."})
- # ... do export work ...
- runtime.emit_event(label, "pywry:set-content", {"id": "status", "text": "Export complete!"})
-
-def handle_row_select(data, event_type, label):
- """Handle row selection from any inventory grid."""
- from pywry import runtime
- selected = data.get("selected_rows", [])
- runtime.emit_event(label, "pywry:set-content", {
- "id": "selection-count",
- "text": f"Selected {len(selected)} rows"
- })
-
-def handle_chart_click(data, event_type, label):
- """Handle chart click from any analytics dashboard."""
- from pywry import runtime
- point = data.get("points", [{}])[0]
- runtime.emit_event(label, "pywry:set-content", {
- "id": "click-info",
- "text": f"Clicked: ({point.get('x')}, {point.get('y')})"
- })
-
-def refresh_analytics(data, event_type, label):
- """Refresh analytics for a specific widget."""
- from pywry import runtime
- runtime.emit_event(label, "pywry:set-content", {"id": "status", "text": "Refreshing..."})
- # ... refresh logic ...
- runtime.emit_event(label, "pywry:set-content", {"id": "status", "text": "Data refreshed!"})
-
-# ═══════════════════════════════════════════════════════════
-# FASTAPI ROUTES - Static URLs that redirect to dynamic widgets
-# ═══════════════════════════════════════════════════════════
-
-@app.get("/")
-async def index():
- """Landing page with links to dashboards."""
- return {
- "dashboards": {
- "sales": "/sales",
- "inventory": "/inventory",
- "analytics": "/analytics",
- }
- }
-
-@app.get("/sales")
-async def sales_dashboard(request: Request, user_id: str | None = None):
- """Static route that creates a fresh sales dashboard."""
- widget_id = create_sales_dashboard(user_id)
- return RedirectResponse(f"/widget/{widget_id}")
-
-@app.get("/inventory")
-async def inventory_view(warehouse_id: str | None = None):
- """Static route that creates a fresh inventory view."""
- widget_id = create_inventory_view(warehouse_id)
- return RedirectResponse(f"/widget/{widget_id}")
-
-@app.get("/analytics")
-async def analytics_dashboard():
- """Static route that creates a fresh analytics dashboard."""
- widget_id = create_analytics_dashboard()
- return RedirectResponse(f"/widget/{widget_id}")
-
-# ═══════════════════════════════════════════════════════════
-# HELPER FUNCTIONS (replace with your data sources)
-# ═══════════════════════════════════════════════════════════
-
-def get_sales_data(user_id: str | None = None) -> pd.DataFrame:
- return pd.DataFrame({
- "month": ["Jan", "Feb", "Mar", "Apr"],
- "revenue": [100, 150, 120, 180]
- })
-
-def get_inventory_data(warehouse_id: str | None = None) -> pd.DataFrame:
- return pd.DataFrame({
- "sku": ["A001", "B002", "C003"],
- "quantity": [50, 30, 100],
- "location": ["Shelf 1", "Shelf 2", "Shelf 3"]
- })
-
-def generate_analytics_html() -> str:
- return "
Analytics Dashboard
"
-
-if __name__ == "__main__":
- import uvicorn
- uvicorn.run(app, host="0.0.0.0", port=8080)
-```
-
-Run with:
-
-```bash
-python app.py
-# Or with uvicorn directly:
-# uvicorn app:app --host 0.0.0.0 --port 8080
-```
-
-Now users can access:
-- `http://yourserver:8080/sales` → Creates fresh widget, redirects to `/widget/{id}`
-- `http://yourserver:8080/inventory?warehouse_id=NYC` → Parameterized view
-- `http://yourserver:8080/analytics` → Analytics dashboard
-
-#### State Management Across Widgets
-
-Track widget instances and their state:
-
-```python
-from dataclasses import dataclass, field
-from datetime import datetime
-from typing import Any
-import threading
-from pywry import PyWry, WindowMode
-
-# Create a global app instance for browser mode
-app = PyWry(mode=WindowMode.BROWSER)
-
-@dataclass
-class WidgetSession:
- """Track a widget instance and its state."""
- widget_id: str
- view_name: str
- user_id: str | None
- created_at: datetime
- state: dict[str, Any] = field(default_factory=dict)
-
-class WidgetManager:
- """Manage all active widget sessions."""
-
- def __init__(self):
- self._sessions: dict[str, WidgetSession] = {}
- self._lock = threading.Lock()
-
- def create(self, widget_id: str, view_name: str, user_id: str | None = None) -> WidgetSession:
- session = WidgetSession(
- widget_id=widget_id,
- view_name=view_name,
- user_id=user_id,
- created_at=datetime.now(),
- )
- with self._lock:
- self._sessions[widget_id] = session
- return session
-
- def get(self, widget_id: str) -> WidgetSession | None:
- return self._sessions.get(widget_id)
-
- def update_state(self, widget_id: str, key: str, value: Any) -> None:
- session = self._sessions.get(widget_id)
- if session:
- session.state[key] = value
-
- def remove(self, widget_id: str) -> None:
- with self._lock:
- self._sessions.pop(widget_id, None)
-
- def get_by_user(self, user_id: str) -> list[WidgetSession]:
- return [s for s in self._sessions.values() if s.user_id == user_id]
-
-# Global manager
-manager = WidgetManager()
-
-# Use in view factories
-def create_sales_dashboard(user_id: str | None = None) -> str:
- widget = app.show_plotly(fig, title="Sales", open_browser=True)
-
- # Track the session
- manager.create(widget.label, "sales", user_id)
-
- return widget.label
-
-# Use in event handlers
-def handle_filter_change(data, event_type, label):
- """Update state and show feedback in the widget."""
- from pywry import runtime
-
- manager.update_state(label, "filters", data)
- session = manager.get(label)
-
- if session:
- # Show visual feedback in the widget
- runtime.emit_event(label, "pywry:set-content", {
- "id": "filter-status",
- "text": f"Filters updated for {session.user_id}"
- })
-```
-
-#### Cleanup on Disconnect
-
-Register a disconnect callback to clean up sessions:
-
-```python
-from pywry import PyWry, WindowMode
-import plotly.express as px
-from datetime import datetime
-
-app = PyWry(mode=WindowMode.BROWSER)
-fig = px.scatter(px.data.iris(), x="sepal_width", y="sepal_length", color="species")
-
-def on_disconnect(data, event_type, label):
- """Called when widget WebSocket disconnects - cleanup resources."""
- session = manager.get(label)
- if session:
- manager.remove(label)
-
-def handle_click(data, event_type, label):
- """Show clicked point info in the chart title."""
- point = data.get("points", [{}])[0]
- # In BROWSER mode, show_plotly returns a widget, but the callback
- # receives the label parameter, so we can use app.emit()
- app.emit("plotly:update-layout", {
- "layout": {"title": f"Clicked: ({point.get('x')}, {point.get('y')})"}
- }, label)
-
-widget = app.show_plotly(fig, callbacks={
- "pywry:disconnect": on_disconnect,
- "plotly:click": handle_click,
-})
-```
-
-### Simple Script Example
-
-For quick testing or simple scripts (not production):
-
-```python
-# simple.py - Quick script for local testing
-from pywry import PyWry, WindowMode
-from pywry.inline import block
-import plotly.express as px
-
-app = PyWry(mode=WindowMode.BROWSER)
-
-fig = px.scatter(px.data.iris(), x="sepal_width", y="sepal_length", color="species")
-widget = app.show_plotly(fig, title="Quick Test")
-
-print(f"Open: {widget.url}")
-block() # Keep server running
-```
-
-```bash
-PYWRY_SERVER__HOST=0.0.0.0 python simple.py
-```
-
-### Programmatic URL Access
-
-```python
-from pywry import PyWry, WindowMode
-
-app = PyWry(mode=WindowMode.BROWSER)
-widget = app.show_plotly(fig)
-
-# Get the URL
-url = widget.url # http://127.0.0.1:8765/widget/abc123
-
-# Open in system browser programmatically
-widget.open_in_browser()
-
-# Get widget ID (used in URL path)
-widget_id = widget.widget_id # "abc123"
-
-# Construct URL manually if needed
-from pywry.config import get_settings
-settings = get_settings().server
-protocol = "https" if settings.ssl_certfile else "http"
-base_url = f"{protocol}://{settings.host}:{settings.port}"
-full_url = f"{base_url}/widget/{widget_id}"
-```
-
-### HTTPS Configuration
-
-For production deployments, enable SSL/TLS:
-
-```toml
-[server]
-host = "0.0.0.0"
-port = 443
-ssl_certfile = "/etc/letsencrypt/live/myapp.com/fullchain.pem"
-ssl_keyfile = "/etc/letsencrypt/live/myapp.com/privkey.pem"
-```
-
-The widget URL will automatically use `https://`:
-
-```python
-widget = app.show_plotly(fig)
-print(widget.url) # https://0.0.0.0:443/widget/abc123
-```
-
-### Server Health Check
-
-The inline server exposes a `/health` endpoint:
-
-```bash
-curl http://localhost:8765/health
-# {"status": "ok"}
-```
-
-Use this for load balancer health checks or monitoring.
-
-> **Note:** The `/health` endpoint requires internal API authentication when accessed externally. Python code using `_make_server_request()` automatically includes the required header.
-
-### WebSocket & API Security
-
-PyWry implements a multi-layer security model for WebSocket connections and internal API endpoints.
-
-> **Security Defaults:** Per-widget token authentication and internal API protection are **enabled by default**. For production deployments, also configure HTTPS, restrict CORS origins, and consider using CSP `strict()` preset.
-
-#### Security Model Overview
-
-| Layer | Setting | Purpose |
-|-------|---------|---------|
-| **Origin Validation** | `websocket_allowed_origins` | Restrict which origins can connect via WebSocket |
-| **Per-Widget Tokens** | `websocket_require_token` | Each widget gets a unique token embedded in HTML |
-| **Internal API Auth** | `internal_api_header/token` | Protect internal endpoints from external access |
-| **Widget Auth Mode** | `strict_widget_auth` | Browser (strict) vs Notebook (lenient) security |
-
-#### Per-Widget Token Authentication
-
-Each widget instance generates a unique short-lived token that's embedded in its HTML:
-
-```python
-# Token flow (automatic, no user code needed):
-# 1. show_plotly() creates widget with unique ID
-# 2. Server generates token, stores in widget_tokens[id]
-# 3. Token embedded in HTML as window.PYWRY_TOKEN
-# 4. JavaScript sends token via Sec-WebSocket-Protocol header
-# 5. Server validates token on WebSocket upgrade
-```
-
-The token is passed during WebSocket handshake via the `Sec-WebSocket-Protocol` header, ensuring it's not exposed in URLs or browser history.
-
-#### Allowed Origins
-
-For deployments where widgets are embedded in iframes on external sites, configure allowed origins:
-
-```toml
-[server]
-# Allow specific origins (empty = allow any, rely on token auth only)
-websocket_allowed_origins = [
- "http://localhost:8080",
- "https://app.example.com",
- "https://dashboard.mycompany.com"
-]
-```
-
-> **Note:** When a widget is embedded in an iframe, the **origin** is the embedding site, not the PyWry server. Configure origins based on where your widgets will be embedded.
-
-#### Internal API Protection
-
-Internal endpoints (`/health`, `/register_widget`, `/disconnect`) are protected from external access:
-
-```toml
-[server]
-internal_api_header = "X-PyWry-Token" # Custom header name
-internal_api_token = "my-secret" # Set explicitly, or leave empty for auto-generation
-```
-
-Requests without the correct header receive `404 Not Found` (not `401`/`403`), hiding endpoint existence from attackers.
-
-#### Strict vs Lenient Widget Auth
-
-The `strict_widget_auth` setting controls how the `/widget/{id}` endpoint is protected:
-
-| Mode | `strict_widget_auth` | Behavior | Use Case |
-|------|----------------------|----------|----------|
-| **Notebook** | `false` (default) | Only checks widget ID exists | Jupyter iframes (can't send headers) |
-| **Browser** | `true` | Requires internal API header | Standalone browser mode |
-
-```toml
-[server]
-# For Jupyter/notebook deployments (default)
-strict_widget_auth = false
-
-# For standalone browser deployments
-strict_widget_auth = true
-```
-
-#### Programmatic Configuration
-
-```python
-from pywry import PyWry, PyWrySettings, ServerSettings
-
-settings = PyWrySettings(
- server=ServerSettings(
- # WebSocket security
- websocket_allowed_origins=["https://app.example.com"],
- websocket_require_token=True,
-
- # Internal API security
- internal_api_header="X-MyApp-Token",
- internal_api_token="my-secret-token", # Or None for auto-gen
-
- # Widget auth mode
- strict_widget_auth=True, # Browser mode
- )
-)
-
-app = PyWry(settings=settings)
-```
-
-#### Security Configuration Reference
-
-| Setting | Type | Default | Description |
-|---------|------|---------|-------------|
-| `websocket_allowed_origins` | `list[str]` | `[]` | Origins allowed for WebSocket. Empty = any origin (token-only auth) |
-| `websocket_require_token` | `bool` | `true` | Require per-widget token via `Sec-WebSocket-Protocol` |
-| `internal_api_header` | `str` | `"X-PyWry-Token"` | Header name for internal API auth |
-| `internal_api_token` | `str \| None` | `None` | Internal API token. `None` = auto-generate on start |
-| `strict_widget_auth` | `bool` | `false` | `true` = require header for `/widget/{id}`, `false` = check ID exists |
-
-
-
----
-
-## Deploy Mode & Scaling
-
-
-Click to expand
-
-**In this section:** [Overview](#deploy-mode-overview) · [Enable Deploy Mode](#enabling-deploy-mode) · [State Backends](#state-backends) · [Redis Configuration](#redis-configuration) · [State Stores](#state-stores) · [Multi-Worker Architecture](#multi-worker-architecture) · [Authentication & Sessions](#authentication--sessions)
-
----
-
-For production deployments with multiple workers or horizontal scaling, PyWry provides a **deploy mode** that externalizes state to Redis. This enables running PyWry behind a load balancer with multiple Uvicorn workers while maintaining consistent widget state and event routing.
-
-### Deploy Mode Overview
-
-| Feature | Single Process (Default) | Deploy Mode (Redis) |
-|---------|--------------------------|---------------------|
-| **State Storage** | In-memory (dict) | Redis with TTL |
-| **Event Bus** | In-memory queue | Redis Pub/Sub |
-| **Connection Routing** | Local tracking | Redis with worker affinity |
-| **Session Management** | N/A | Redis with RBAC support |
-| **Horizontal Scaling** | ❌ | ✅ |
-| **Worker Crash Recovery** | ❌ | ✅ |
-
-### Enabling Deploy Mode
-
-Deploy mode is activated automatically when:
-
-1. `PYWRY_DEPLOY__STATE_BACKEND=redis` is set, OR
-2. `PYWRY_DEPLOY_MODE=1` is set explicitly
-
-```bash
-# Via environment variables (recommended for production)
-export PYWRY_DEPLOY__STATE_BACKEND=redis
-export PYWRY_DEPLOY__REDIS_URL=redis://localhost:6379/0
-
-# Run with multiple workers
-uvicorn app:app --host 0.0.0.0 --port 8080 --workers 4
-```
-
-### State Backends
-
-| Backend | Use Case | Configuration |
-|---------|----------|---------------|
-| `memory` | Single process, development | Default, no configuration needed |
-| `redis` | Multi-worker, production | Requires Redis server |
-
-### Redis Configuration
-
-Configure Redis connection via `pywry.toml`, `pyproject.toml`, or environment variables:
-
-```toml
-# pywry.toml or [tool.pywry.deploy] in pyproject.toml
-[deploy]
-state_backend = "redis"
-redis_url = "redis://localhost:6379/0"
-redis_prefix = "pywry" # Key namespace
-redis_pool_size = 10 # Connection pool size per store
-
-# TTL settings (seconds)
-widget_ttl = 86400 # Widget data TTL (24 hours)
-connection_ttl = 300 # WebSocket connection TTL (5 minutes)
-session_ttl = 86400 # User session TTL (24 hours)
-
-# Worker identification
-worker_id = "" # Auto-generated if empty
-
-# Authentication (optional)
-auth_enabled = false
-auth_session_cookie = "pywry_session"
-auth_header = "Authorization"
-
-# RBAC (optional)
-default_roles = ["viewer"]
-admin_users = []
-```
-
-#### Environment Variables
-
-```bash
-# Core Redis settings
-export PYWRY_DEPLOY__STATE_BACKEND=redis
-export PYWRY_DEPLOY__REDIS_URL=redis://user:password@host:6379/0
-export PYWRY_DEPLOY__REDIS_PREFIX=myapp
-
-# TTL settings
-export PYWRY_DEPLOY__WIDGET_TTL=86400
-export PYWRY_DEPLOY__CONNECTION_TTL=300
-export PYWRY_DEPLOY__SESSION_TTL=86400
-
-# Worker ID (optional - auto-generated if not set)
-export PYWRY_DEPLOY__WORKER_ID=worker-1
-
-# Authentication
-export PYWRY_DEPLOY__AUTH_ENABLED=true
-export PYWRY_DEPLOY__AUTH_SESSION_COOKIE=pywry_session
-export PYWRY_DEPLOY__AUTH_HEADER=Authorization
-
-# RBAC
-export PYWRY_DEPLOY__DEFAULT_ROLES=viewer,editor
-export PYWRY_DEPLOY__ADMIN_USERS=admin@example.com,super@example.com
-```
-
-### State Stores
-
-Deploy mode provides four pluggable state stores:
-
-| Store | Interface | Purpose |
-|-------|-----------|---------|
-| `WidgetStore` | `get_widget_store()` | Widget HTML, tokens, metadata |
-| `EventBus` | `get_event_bus()` | Cross-worker event routing |
-| `ConnectionRouter` | `get_connection_router()` | WebSocket connection affinity |
-| `SessionStore` | `get_session_store()` | User sessions for RBAC |
-
-#### Using State Stores Programmatically
-
-```python
-from pywry.state import (
- get_widget_store,
- get_event_bus,
- get_connection_router,
- get_session_store,
- is_deploy_mode,
- get_worker_id,
-)
-
-# Check if deploy mode is active
-if is_deploy_mode():
- print(f"Running in deploy mode, worker: {get_worker_id()}")
-
-# Access stores (automatically returns Redis or Memory implementation)
-widget_store = get_widget_store()
-event_bus = get_event_bus()
-connection_router = get_connection_router()
-session_store = get_session_store()
-
-# Example: Register a widget
-await widget_store.register(
- widget_id="my-widget",
- html="
Hello
",
- token="secret-token",
- owner_worker_id=get_worker_id(),
- metadata={"title": "My Widget"},
-)
-
-# Example: Get widget data
-widget_data = await widget_store.get("my-widget")
-if widget_data:
- print(f"Widget HTML: {widget_data.html[:50]}...")
- print(f"Owner: {widget_data.owner_worker_id}")
-```
-
-#### State Store Interfaces
-
-
-WidgetStore Interface
-
-```python
-class WidgetStore(Protocol):
- """Store for widget HTML content and metadata."""
-
- async def register(
- self,
- widget_id: str,
- html: str,
- token: str | None = None,
- owner_worker_id: str | None = None,
- metadata: dict[str, Any] | None = None,
- ) -> None:
- """Register a widget with its HTML content."""
-
- async def get(self, widget_id: str) -> WidgetData | None:
- """Get complete widget data."""
-
- async def get_html(self, widget_id: str) -> str | None:
- """Get widget HTML content."""
-
- async def get_token(self, widget_id: str) -> str | None:
- """Get widget authentication token."""
-
- async def exists(self, widget_id: str) -> bool:
- """Check if a widget exists."""
-
- async def delete(self, widget_id: str) -> bool:
- """Delete a widget."""
-
- async def list_active(self) -> list[str]:
- """List all active widget IDs."""
-
- async def update_html(self, widget_id: str, html: str) -> bool:
- """Update widget HTML content."""
-```
-
-
-
-
-EventBus Interface
-
-```python
-class EventBus(Protocol):
- """Cross-worker event bus for widget events."""
-
- async def publish(self, event: EventMessage) -> None:
- """Publish an event to the bus."""
-
- async def subscribe(self, widget_id: str) -> AsyncIterator[EventMessage]:
- """Subscribe to events for a widget."""
-
- async def publish_to_worker(
- self,
- worker_id: str,
- event: EventMessage,
- ) -> None:
- """Publish an event to a specific worker."""
-```
-
-
-
-
-ConnectionRouter Interface
-
-```python
-class ConnectionRouter(Protocol):
- """Route WebSocket connections to appropriate workers."""
-
- async def register(self, connection: ConnectionInfo) -> None:
- """Register a new WebSocket connection."""
-
- async def get(self, widget_id: str) -> ConnectionInfo | None:
- """Get connection info for a widget."""
-
- async def heartbeat(self, widget_id: str) -> bool:
- """Update connection heartbeat timestamp."""
-
- async def remove(self, widget_id: str) -> bool:
- """Remove a connection registration."""
-
- async def get_worker_connections(self, worker_id: str) -> list[str]:
- """Get all widget IDs connected to a worker."""
-```
-
-
-
-
-SessionStore Interface
-
-```python
-class SessionStore(Protocol):
- """User session management for RBAC."""
-
- async def create(
- self,
- user_id: str,
- roles: list[str] | None = None,
- metadata: dict[str, Any] | None = None,
- ) -> UserSession:
- """Create a new user session."""
-
- async def get(self, session_id: str) -> UserSession | None:
- """Get a session by ID."""
-
- async def get_by_user(self, user_id: str) -> list[UserSession]:
- """Get all sessions for a user."""
-
- async def update_roles(self, session_id: str, roles: list[str]) -> bool:
- """Update session roles."""
-
- async def delete(self, session_id: str) -> bool:
- """Delete a session."""
-
- async def touch(self, session_id: str) -> bool:
- """Refresh session TTL."""
-```
-
-
-
-### Multi-Worker Architecture
-
-When running with multiple workers, PyWry uses the following architecture:
-
-```
- ┌─────────────────────────────┐
- │ Load Balancer │
- └─────────────┬───────────────┘
- │
- ┌─────────────────────────┼─────────────────────────┐
- │ │ │
- ▼ ▼ ▼
-┌───────────────┐ ┌───────────────┐ ┌───────────────┐
-│ Worker 1 │ │ Worker 2 │ │ Worker 3 │
-│ (uvicorn) │ │ (uvicorn) │ │ (uvicorn) │
-│ │ │ │ │ │
-│ Callbacks A │ │ Callbacks B │ │ Callbacks C │
-│ WebSockets │ │ WebSockets │ │ WebSockets │
-└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
- │ │ │
- └───────────────────────┼───────────────────────┘
- │
- ┌───────────▼───────────┐
- │ Redis │
- │ │
- │ • Widget Store │
- │ • Event Bus (Pub/Sub)│
- │ • Connection Router │
- │ • Session Store │
- └───────────────────────┘
-```
-
-**Key Concepts:**
-
-1. **Widget Store**: All workers can serve any widget's HTML since content is in Redis
-2. **Connection Router**: Tracks which worker owns each WebSocket connection
-3. **Event Bus**: Routes events to the correct worker for callback execution
-4. **Callbacks**: Python callbacks are executed by the worker that registered them
-
-### Authentication & Sessions
-
-When `auth_enabled=true`, PyWry provides session-based authentication:
-
-```python
-from pywry.state import get_session_store, UserSession
-
-session_store = get_session_store()
-
-# Create a session for a user
-session = await session_store.create(
- user_id="user@example.com",
- roles=["viewer", "editor"],
- metadata={"display_name": "John Doe"},
-)
-
-# Session token can be set as a cookie or header
-# session.session_id = "abc123..."
-
-# Validate a session
-session = await session_store.get(session_id)
-if session and "admin" in session.roles:
- # Allow admin action
- pass
-
-# Refresh session TTL on activity
-await session_store.touch(session.session_id)
-```
-
-#### RBAC Configuration
-
-```toml
-[deploy]
-auth_enabled = true
-default_roles = ["viewer"] # Roles for new users
-admin_users = [ # Users with admin privileges
- "admin@example.com",
- "super@example.com"
-]
-```
-
-### DeploySettings Reference
-
-| Setting | Type | Default | Description |
-|---------|------|---------|-------------|
-| `state_backend` | `"memory"` \| `"redis"` | `"memory"` | State storage backend |
-| `redis_url` | `str` | `"redis://localhost:6379/0"` | Redis connection URL |
-| `redis_prefix` | `str` | `"pywry"` | Key prefix for Redis keys |
-| `redis_pool_size` | `int` | `10` | Connection pool size |
-| `widget_ttl` | `int` | `86400` | Widget data TTL (seconds) |
-| `connection_ttl` | `int` | `300` | Connection routing TTL (seconds) |
-| `session_ttl` | `int` | `86400` | User session TTL (seconds) |
-| `worker_id` | `str \| None` | `None` | Worker ID (auto-generated if None) |
-| `auth_enabled` | `bool` | `false` | Enable authentication |
-| `auth_session_cookie` | `str` | `"pywry_session"` | Session cookie name |
-| `auth_header` | `str` | `"Authorization"` | Auth header name |
-| `default_roles` | `list[str]` | `["viewer"]` | Default roles for new users |
-| `admin_users` | `list[str]` | `[]` | User IDs with admin privileges |
-
-
-
----
-
-## CLI Commands
-
-
-Click to expand
-
-PyWry provides a CLI for **configuration management only**. Entry point: `pywry`
-
-**In this section:** [Show Configuration](#show-configuration) · [Initialize Configuration](#initialize-configuration) · [Example: Show Sources](#example-show-sources)
-
-### Show Configuration
-
-```bash
-# Human-readable format
-pywry config --show
-
-# TOML format (for creating config files)
-pywry config --toml
-
-# Environment variable format
-pywry config --env
-
-# Show configuration file sources
-pywry config --sources
-
-# Write to file
-pywry config --toml --output pywry.toml
-```
-
-### Initialize Configuration
-
-```bash
-# Create pywry.toml with defaults
-pywry init
-
-# Overwrite existing file
-pywry init --force
-
-# Custom output path
-pywry init --path my-config.toml
-```
-
-### Example: Show Sources
-
-```bash
-$ pywry config --sources
-Configuration sources (in priority order):
- 1. Built-in defaults
- 2. ~/.config/pywry/config.toml (not found)
- 3. pyproject.toml [tool.pywry] (found)
- 4. ./pywry.toml (found)
- 5. Environment variables (PYWRY_*)
-```
-
-
-
----
-
-## Debugging
-
-
-Click to expand
-
-**In this section:** [Enable Debug Logging](#enable-debug-logging) · [Standard Python Logging](#standard-python-logging) · [Environment Variable](#environment-variable)
-
-### Enable Debug Logging
-
-```python
-import pywry.log
-
-# Enable verbose debug output for all pywry modules
-pywry.log.enable_debug()
-```
-
-### Standard Python Logging
-
-```python
-import logging
-
-# Enable debug for specific modules
-logging.getLogger("pywry").setLevel(logging.DEBUG)
-logging.getLogger("pywry.runtime").setLevel(logging.DEBUG)
-```
-
-### Environment Variable
-
-```bash
-export PYWRY_LOG__LEVEL=DEBUG
-```
-
-
-
----
-
-## Building from Source
-
-
-Click to expand
-
-**In this section:** [Prerequisites](#prerequisites) · [Setup](#setup) · [Run Tests](#run-tests) · [Lint and Format](#lint-and-format) · [Project Structure](#project-structure)
-
-### Prerequisites
-
-- Python 3.10+
-- Git
-
-### Setup
-
-```bash
-# Clone repository
-git clone https://github.com/OpenBB-finance/OpenBB.git
-cd pywry
-
-# Create virtual environment
-python -m venv venv
-source venv/bin/activate # Linux/macOS
-venv\Scripts\activate # Windows
-
-# Install in development mode
-pip install -e ".[dev]"
-```
-
-### Run Tests
-
-```bash
-# Run all tests
-pytest tests/ -v
-
-# Run with coverage
-pytest tests/ --cov=pywry --cov-report=html
-```
-
-### Lint and Format
-
-```bash
-# Check code style
-ruff check pywry/ tests/
-
-# Format code
-ruff format pywry/ tests/
-
-# Type checking
-mypy pywry/
-```
-
-### Project Structure
-
-```
-pywry/
-├── pywry/
-│ ├── __init__.py # Public API exports (version: 2.0.0)
-│ ├── __main__.py # PyTauri subprocess entry point
-│ ├── app.py # Main PyWry class - user entry point
-│ ├── asset_loader.py # CSS/JS file loading with caching
-│ ├── assets.py # Bundled asset loading (Plotly.js, AgGrid, CSS)
-│ ├── callbacks.py # Event callback registry (singleton)
-│ ├── cli.py # CLI commands (pywry config, pywry init)
-│ ├── config.py # Layered configuration system (pydantic-settings)
-│ ├── grid.py # AgGrid Pydantic models (ColDef, GridOptions, etc.)
-│ ├── hot_reload.py # Hot reload manager
-│ ├── inline.py # FastAPI-based inline server + InlineWidget
-│ ├── log.py # Logging utilities
-│ ├── models.py # Pydantic models (HtmlContent, WindowConfig, ThemeMode, WindowMode)
-│ ├── notebook.py # Notebook environment detection
-│ ├── plotly_config.py # Plotly configuration models (PlotlyConfig, ModeBarButton, etc.)
-│ ├── runtime.py # PyTauri subprocess management (stdin/stdout IPC)
-│ ├── scripts.py # JavaScript bridge code injected into windows
-│ ├── state_mixins.py # Widget state management mixins (GridStateMixin, PlotlyStateMixin, ToolbarStateMixin)
-│ ├── Tauri.toml # Tauri configuration
-│ ├── templates.py # HTML template builder with CSP, themes, scripts
-│ ├── toolbar.py # Toolbar component models (Button, Select, etc.)
-│ ├── watcher.py # File system watcher (watchdog-based)
-│ ├── widget.py # anywidget-based widgets (PyWryWidget, PyWryPlotlyWidget, PyWryAgGridWidget)
-│ ├── widget_protocol.py # BaseWidget protocol and NativeWindowHandle class
-│ ├── capabilities/ # Tauri capability permissions
-│ │ └── default.toml # Default permissions (core, dialog, fs)
-│ ├── commands/ # IPC command handlers
-│ │ ├── __init__.py
-│ │ └── window_commands.py
-│ ├── frontend/ # Frontend HTML and bundled assets
-│ │ ├── assets/ # Plotly.js, AgGrid, icons
-│ │ ├── src/ # main.js, aggrid-defaults.js, plotly-widget.js, plotly-templates.js
-│ │ └── style/ # CSS files (pywry.css)
-│ ├── state/ # State management for deploy mode
-│ │ ├── __init__.py # Public state API exports
-│ │ ├── base.py # Abstract interfaces (WidgetStore, EventBus, etc.)
-│ │ ├── memory.py # In-memory implementations (default)
-│ │ ├── redis.py # Redis implementations (deploy mode)
-│ │ ├── types.py # Type definitions (WidgetData, EventMessage, etc.)
-│ │ ├── auth.py # Authentication helpers
-│ │ ├── callbacks.py # Callback registry for state
-│ │ ├── server.py # Server state management
-│ │ ├── sync_helpers.py # Async-to-sync utilities
-│ │ └── _factory.py # Factory functions for state stores
-│ ├── utils/ # Utility helpers
-│ │ ├── __init__.py
-│ │ └── async_helpers.py
-│ └── window_manager/ # Window mode implementations
-│ ├── __init__.py
-│ ├── controller.py # WindowController
-│ ├── lifecycle.py # WindowLifecycle with resource tracking
-│ └── modes/
-│ ├── __init__.py
-│ ├── base.py # Abstract WindowModeBase interface
-│ ├── browser.py # BROWSER mode - opens in system browser
-│ ├── new_window.py # NEW_WINDOW mode
-│ ├── single_window.py # SINGLE_WINDOW mode
-│ └── multi_window.py # MULTI_WINDOW mode
-├── tests/ # Unit and E2E tests
-├── examples/ # Demo notebooks
-├── build_assets.py # Asset download script
-├── build_widget.py # Widget build script
-├── pyproject.toml # Package configuration
-├── ruff.toml # Ruff linting configuration
-├── pytest.ini # Pytest configuration
-├── AGENTS.md # AI coding agent guide
-└── README.md
-```
-
-
-
----
-
-# Integrations
-
-## Plotly Integration
-
-
-Click to expand
-
-PyWry bundles Plotly.js 3.3.1 for offline charting with full event integration. Display figures with `show_plotly()` and handle chart events in Python.
-
-**In this section:** [Basic Usage](#basic-usage) · [Plotly Templates](#plotly-templates) · [Theme Coordination](#theme-coordination) · [User Templates](#user-templates) · [JavaScript Access](#javascript-access) · [PlotlyConfig](#plotlyconfig) · [ModeBarButton](#modebarbutton) · [SvgIcon](#svgicon) · [PlotlyIconName](#plotlyiconname) · [Pre-built Buttons](#pre-built-buttons) · [StandardButton](#standardbutton) · [Accessing Plotly API](#accessing-plotly-api-javascript)
-
-### Basic Usage
-
-```python
-import plotly.graph_objects as go
-from pywry import PyWry
-
-app = PyWry()
-fig = go.Figure(data=[go.Scatter(x=[1, 2, 3], y=[4, 5, 6])])
-app.show_plotly(fig)
-```
-
-### Plotly Templates
-
-PyWry bundles all official Plotly templates for consistent theming with no network dependencies.
-
-| Template | Description |
-|----------|-------------|
-| `plotly` | Default Plotly theme |
-| `plotly_white` | Light theme with white background |
-| `plotly_dark` | Dark theme with dark background |
-| `ggplot2` | ggplot2 style |
-| `seaborn` | Seaborn style |
-| `simple_white` | Minimal white theme |
-| `presentation` | High contrast for presentations |
-| `xgridoff` | No vertical grid lines |
-| `ygridoff` | No horizontal grid lines |
-| `gridon` | Grid lines enabled |
-
-### Theme Coordination
-
-The window theme determines the default Plotly template when no template is specified:
-
-| Window Theme | Default Plotly Template |
-|--------------|-------------------------|
-| `ThemeMode.DARK` | `plotly_dark` |
-| `ThemeMode.LIGHT` | `plotly_white` |
-| `ThemeMode.SYSTEM` | Follows OS preference |
-
-### User Templates
-
-When you specify a template on your figure, **it is used exactly as-is**:
-
-```python
-import plotly.graph_objects as go
-from pywry import PyWry, ThemeMode
-
-pywry = PyWry(theme=ThemeMode.DARK)
-
-# Your template is used completely - seaborn's light backgrounds included
-fig = go.Figure(data=[...])
-fig.update_layout(template='seaborn')
-
-pywry.show_plotly(fig) # Shows seaborn template exactly
-```
-
-PyWry does not modify or merge user templates. The window theme only affects charts without an explicit template.
-
-### JavaScript Access
-
-Templates are available in the browser via `window.PYWRY_PLOTLY_TEMPLATES`:
-
-```javascript
-// Access any template directly
-const darkTemplate = window.PYWRY_PLOTLY_TEMPLATES['plotly_dark'];
-
-// Apply to a chart
-Plotly.update('my-chart', {}, { template: darkTemplate });
-```
-
-### PlotlyConfig
-
-Top-level configuration object passed to `Plotly.newPlot()`. Controls responsiveness, interactivity, modebar behavior, and more.
-
-```python
-from pywry import PlotlyConfig, PlotlyIconName, ModeBarButton
-
-config = PlotlyConfig(
- responsive=True, # Resize with container (default: True)
- display_mode_bar="hover", # Show on hover (default), True, or False
- display_logo=False, # Hide Plotly logo
- scroll_zoom=True, # Enable scroll-to-zoom
- double_click="reset", # Reset on double-click ("reset+autosize", "reset", "autosize", False)
- static_plot=False, # Disable all interactivity
- editable=False, # Allow editing titles, annotations, etc.
- mode_bar_buttons_to_remove=["lasso2d", "select2d"], # Remove specific buttons
- mode_bar_buttons_to_add=[...], # Add custom buttons (see ModeBarButton)
-)
-
-# Pass to show_plotly
-app.show_plotly(fig, config=config)
-```
-
-### ModeBarButton
-
-Define custom buttons for the Plotly modebar. Buttons can emit PyWry events when clicked.
-
-| Field | Type | Description |
-|-------|------|-------------|
-| `name` | `str` | Unique identifier for the button |
-| `title` | `str` | Tooltip text shown on hover |
-| `icon` | `SvgIcon \| PlotlyIconName \| str` | Button icon (built-in or custom SVG) |
-| `event` | `str \| None` | PyWry event to emit when clicked |
-| `data` | `dict \| None` | Additional data to include in event payload |
-| `toggle` | `bool \| None` | Whether button has toggle state |
-| `click` | `str \| None` | JavaScript handler (use `event` for PyWry events instead) |
-
-```python
-from pywry import PyWry, ModeBarButton, PlotlyConfig, PlotlyIconName
-import plotly.express as px
-import pandas as pd
-
-app = PyWry()
-
-# Sample data
-df = px.data.iris()
-fig = px.scatter(df, x="sepal_width", y="sepal_length", color="species")
-
-# Custom button that emits a PyWry event
-export_button = ModeBarButton(
- name="exportData",
- title="Export Data",
- icon=PlotlyIconName.SAVE,
- event="app:export",
- data={"format": "csv"},
-)
-
-config = PlotlyConfig(
- mode_bar_buttons_to_add=[export_button],
-)
-
-def on_export(data, event_type, label):
- """Trigger a CSV download when the custom modebar button is clicked."""
- csv_content = df.to_csv(index=False)
- app.emit("pywry:download", {
- "content": csv_content,
- "filename": "iris_data.csv",
- "mimeType": "text/csv"
- }, label)
-
-handle = app.show_plotly(fig, config=config, callbacks={"app:export": on_export})
-```
-
-### SvgIcon
-
-Define custom SVG icons for modebar buttons.
-
-| Field | Type | Description |
-|-------|------|-------------|
-| `width` | `int` | SVG viewBox width (default: 500) |
-| `height` | `int` | SVG viewBox height (default: 500) |
-| `path` | `str \| None` | SVG path `d` attribute |
-| `svg` | `str \| None` | Full SVG markup (alternative to `path`) |
-| `transform` | `str \| None` | SVG transform attribute |
-
-```python
-from pywry import SvgIcon, ModeBarButton
-
-# Custom icon using SVG path
-custom_icon = SvgIcon(
- width=24,
- height=24,
- path="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5",
-)
-
-button = ModeBarButton(
- name="customAction",
- title="Custom Action",
- icon=custom_icon,
- event="app:custom",
-)
-```
-
-### PlotlyIconName
-
-Enum of built-in Plotly icon names. Use these instead of custom SVGs when possible.
-
-```python
-from pywry import PlotlyIconName
-
-# Available icons
-PlotlyIconName.CAMERA_RETRO # Download/screenshot
-PlotlyIconName.HOME # Reset/home
-PlotlyIconName.ZOOM_IN # Zoom in
-PlotlyIconName.ZOOM_OUT # Zoom out
-PlotlyIconName.PAN # Pan mode
-PlotlyIconName.LASSO # Lasso select
-PlotlyIconName.SAVE # Save
-PlotlyIconName.PENCIL # Edit
-PlotlyIconName.ERASER # Erase
-PlotlyIconName.UNDO # Undo
-# ... and more (see PlotlyIconName enum)
-```
-
-### Pre-built Buttons
-
-PyWry includes convenience button classes:
-
-```python
-from pywry.plotly_config import DownloadImageButton, ResetAxesButton, ToggleGridButton
-
-config = PlotlyConfig(
- mode_bar_buttons_to_add=[
- DownloadImageButton(),
- ResetAxesButton(),
- ToggleGridButton(), # Emits "plotly:toggle-grid" event
- ],
-)
-```
-
-### StandardButton
-
-Enum of standard Plotly modebar button names. Use with `mode_bar_buttons_to_remove`:
-
-```python
-from pywry.plotly_config import StandardButton
-
-config = PlotlyConfig(
- mode_bar_buttons_to_remove=[
- StandardButton.LASSO_2D,
- StandardButton.SELECT_2D,
- StandardButton.TOGGLE_SPIKELINES,
- ],
-)
-```
-
-### Accessing Plotly API (JavaScript)
-
-```javascript
-// Update chart layout
-Plotly.relayout(window.__PYWRY_PLOTLY_DIV__, { title: 'New Title' });
-
-// Update chart data
-Plotly.react(window.__PYWRY_PLOTLY_DIV__, newData, newLayout);
-
-// Apply template
-Plotly.update(window.__PYWRY_PLOTLY_DIV__, {}, {
- template: window.PYWRY_PLOTLY_TEMPLATES['seaborn']
-});
-```
-
-
-
-## AgGrid Integration
-
-
-Click to expand
-
-PyWry bundles AgGrid 35.0.0 for high-performance data tables. Display DataFrames with `show_dataframe()` and handle grid events in Python.
-
-**In this section:** [Basic Usage](#basic-usage-1) · [Import Grid Models](#import-grid-models) · [Column Definitions](#column-definitions) · [ColDef Properties](#coldef-properties) · [Row Selection](#row-selection) · [Grid Options](#grid-options) · [Available Grid Models](#available-grid-models) · [Accessing AgGrid API](#accessing-aggrid-api-javascript)
-
-### Basic Usage
-
-```python
-import pandas as pd
-from pywry import PyWry
-
-app = PyWry()
-df = pd.DataFrame({"name": ["Alice", "Bob"], "age": [25, 30]})
-app.show_dataframe(df)
-```
-
-### Import Grid Models
-
-```python
-from pywry.grid import ColDef, ColGroupDef, DefaultColDef, RowSelection, GridOptions, build_grid_config
-```
-
-### Column Definitions
-
-Use `ColDef` to define individual columns with all common AgGrid options:
-
-```python
-from pywry import PyWry
-from pywry.grid import ColDef
-import pandas as pd
-
-app = PyWry()
-df = pd.DataFrame({"name": ["Alice", "Bob"], "age": [25, 30], "salary": [50000, 60000]})
-
-# Define custom column configurations
-column_defs = [
- ColDef(field="name", header_name="Full Name", pinned="left", min_width=120),
- ColDef(field="age", filter="agNumberColumnFilter", sortable=True),
- ColDef(field="salary", value_formatter="'$' + value.toLocaleString()", flex=1),
-]
-
-app.show_dataframe(df, column_defs=column_defs)
-```
-
-### ColDef Properties
-
-| Property | Type | Description |
-|----------|------|-------------|
-| `field` | `str` | Column field name (matches DataFrame column) |
-| `header_name` | `str` | Display name in header |
-| `hide` | `bool` | Whether column is hidden |
-| `pinned` | `"left"` \| `"right"` | Pin column to side |
-| `width`, `min_width`, `max_width` | `int` | Column sizing |
-| `flex` | `int` | Flex sizing weight |
-| `sortable` | `bool` | Enable sorting |
-| `filter` | `bool` \| `str` | Enable/specify filter type |
-| `resizable` | `bool` | Allow column resizing |
-| `editable` | `bool` | Allow cell editing |
-| `cell_data_type` | `str` | Data type hint (`"text"`, `"number"`, `"boolean"`, `"date"`) |
-| `value_formatter` | `str` | JS expression for formatting display value |
-| `cell_renderer` | `str` | Custom cell renderer name |
-| `cell_class` | `str` \| `list` | CSS class(es) for cells |
-| `cell_style` | `dict` | Inline styles for cells |
-
-### Row Selection
-
-Configure row selection behavior:
-
-```python
-from pywry.grid import RowSelection
-
-selection = RowSelection(
- mode="multiRow", # or "singleRow"
- checkboxes=True,
- header_checkbox=True,
- enable_click_selection=True,
-)
-```
-
-### Grid Options
-
-For full control, use `GridOptions`:
-
-```python
-from pywry.grid import GridOptions, DefaultColDef
-
-grid_options = GridOptions(
- pagination=True,
- pagination_page_size=50,
- animate_rows=True,
- default_col_def=DefaultColDef(
- sortable=True,
- filter=True,
- resizable=True,
- min_width=80,
- ).to_dict(),
-)
-
-app.show_dataframe(df, grid_options=grid_options.to_dict())
-```
-
-### Available Grid Models
-
-| Model | Purpose |
-|-------|---------|
-| `ColDef` | Column definition with all common options |
-| `ColGroupDef` | Column group for MultiIndex columns |
-| `DefaultColDef` | Default settings applied to all columns |
-| `RowSelection` | Row selection configuration |
-| `GridOptions` | Complete AgGrid configuration |
-| `GridConfig` | Combined AgGrid options + PyWry context |
-| `GridData` | Normalized grid data from various inputs |
-
-### Accessing AgGrid API (JavaScript)
-
-```javascript
-// Get selected rows
-const rows = window.__PYWRY_GRID_API__.getSelectedRows();
-
-// Update data
-window.__PYWRY_GRID_API__.setGridOption('rowData', newData);
-
-// Apply transactions
-window.__PYWRY_GRID_API__.applyTransaction({ update: [row1, row2] });
-
-// Export to CSV
-window.__PYWRY_GRID_API__.exportDataAsCsv();
-```
-
----
-
-## MCP Server (AI Agents)
-
-
-Click to expand
-
-**In this section:** [Overview](#mcp-overview) · [Installation](#mcp-installation) · [Running the Server](#running-the-mcp-server) · [Configuration](#mcp-configuration) · [Available Tools](#available-mcp-tools) · [Skills System](#skills-system) · [Component Reference](#component-reference) · [Event Handling](#mcp-event-handling) · [Resources & Prompts](#resources--prompts)
-
----
-
-### MCP Overview
-
-PyWry includes a built-in [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that allows AI agents (Claude, GPT, etc.) to create and manipulate native desktop widgets programmatically.
-
-**What AI agents can do with PyWry MCP:**
-
-- Create native desktop windows with interactive toolbars
-- Build dynamic UIs with 17 Pydantic-based components
-- Display Plotly charts and AgGrid tables
-- Update content and styling in real-time
-- Capture user interactions via events
-- Export generated widgets as standalone Python code
-
-### MCP Installation
-
-Install PyWry with MCP support:
-
-```bash
-pip install 'pywry[mcp]'
-```
-
-This installs the `mcp` package and its dependencies.
-
-### Running the MCP Server
-
-**stdio transport** (default, for direct integration):
-
-```bash
-python -m pywry.mcp
-```
-
-**SSE transport** (for HTTP-based clients):
-
-```bash
-python -m pywry.mcp --transport sse --port 8001
-# or shorthand:
-python -m pywry.mcp --sse 8001
-```
-
-**CLI Options:**
-
-| Option | Default | Description |
-|--------|---------|-------------|
-| `--transport` | `stdio` | Transport type: `stdio` or `sse` |
-| `--port` | `8001` | Port for SSE transport |
-| `--sse [PORT]` | `8001` | Shorthand for `--transport sse --port PORT` |
-
-### MCP Configuration
-
-**VS Code / Cursor MCP Settings:**
-
-Add to your MCP settings (`.vscode/mcp.json` or global settings):
-
-```json
-{
- "servers": {
- "pywry": {
- "command": "python",
- "args": ["-m", "pywry.mcp"],
- "env": {
- "PYWRY_HEADLESS": "1"
- }
- }
- }
-}
-```
-
-For SSE transport:
-
-```json
-{
- "servers": {
- "pywry": {
- "url": "http://127.0.0.1:8001/sse"
- }
- }
-}
-```
-
-**Environment Variables:**
-
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `PYWRY_HEADLESS` | `0` | Set to `1` for inline/browser widget mode (recommended for MCP) |
-| `PYWRY_THEME` | `dark` | Default theme: `dark`, `light`, or `system` |
-
-### Available MCP Tools
-
-The MCP server exposes 25+ tools for widget creation and manipulation:
-
-#### Core Tools
-
-| Tool | Description |
-|------|-------------|
-| `get_skills` | Get context-specific guidance and documentation |
-| `create_widget` | Create a native window with HTML content and toolbars |
-| `list_widgets` | List all active widgets with their URLs |
-| `destroy_widget` | Close and clean up a widget |
-| `get_events` | Retrieve captured events from a widget |
-
-#### Widget Manipulation
-
-| Tool | Description |
-|------|-------------|
-| `set_content` | Update element text/HTML by component_id |
-| `set_style` | Update element CSS styles by component_id |
-| `show_toast` | Display a toast notification |
-| `inject_css` | Inject custom CSS into a widget |
-| `remove_css` | Remove injected CSS by style_id |
-| `navigate` | Navigate widget to a new URL |
-| `download` | Trigger a file download in the widget |
-| `send_event` | Send a custom event to a widget |
-| `update_theme` | Switch widget theme (dark/light/system) |
-
-#### Visualization Tools
-
-| Tool | Description |
-|------|-------------|
-| `show_plotly` | Create a Plotly chart widget from figure JSON |
-| `update_plotly` | Update an existing Plotly chart |
-| `show_dataframe` | Create an AgGrid table from JSON data |
-
-#### Marquee / Ticker Tools
-
-| Tool | Description |
-|------|-------------|
-| `update_marquee` | Update marquee content, speed, or state |
-| `update_ticker_item` | Update individual ticker items by ID |
-| `build_ticker_item` | Build ticker item HTML for use in marquees |
-
-#### Helper Tools
-
-| Tool | Description |
-|------|-------------|
-| `build_div` | Build a Div component HTML string |
-| `get_component_docs` | Get detailed documentation for a component |
-| `get_component_source` | Get Python source code for a component class |
-| `list_resources` | List all available resources and templates |
-| `export_widget` | Export a widget as standalone Python code |
-
-### Skills System
-
-The MCP server includes a skills system that provides context-appropriate guidance to AI agents. Skills are loaded on-demand to minimize memory usage.
-
-**Retrieving Skills:**
-
-```
-# List all available skills
-get_skills()
-
-# Get specific skill guidance
-get_skills(skill="component_reference")
-get_skills(skill="native")
-get_skills(skill="data_visualization")
-```
-
-**Available Skills:**
-
-| Skill ID | Description |
-|----------|-------------|
-| `component_reference` | **MANDATORY** - Complete reference for all 18 components and event signatures |
-| `interactive_buttons` | How to make buttons work automatically with auto-wired callbacks |
-| `native` | Desktop window mode with full OS control |
-| `jupyter` | Inline widgets in Jupyter notebook cells |
-| `iframe` | Embedded widgets in external web pages |
-| `deploy` | Production multi-user SSE server deployment |
-| `css_selectors` | Targeting elements for updates |
-| `styling` | Theme variables and CSS customization |
-| `data_visualization` | Charts, tables, and live data patterns |
-| `forms_and_inputs` | Building interactive forms with validation |
-
-### Component Reference
-
-The MCP server supports 17 toolbar component types:
-
-| Component | Description | Key Properties |
-|-----------|-------------|----------------|
-| `button` | Click button | `label`, `event`, `variant` |
-| `select` | Dropdown menu | `options`, `selected`, `event` |
-| `multiselect` | Multi-choice dropdown | `options`, `selected`, `event` |
-| `toggle` | On/off switch | `label`, `event`, `value` |
-| `checkbox` | Checkbox | `label`, `event`, `value` |
-| `radio` | Radio button group | `options`, `event` |
-| `tabs` | Tab bar | `options`, `event` |
-| `text` | Text input | `placeholder`, `event` |
-| `textarea` | Multi-line text | `rows`, `event` |
-| `search` | Search input with debounce | `debounce`, `event` |
-| `number` | Numeric input | `min`, `max`, `step`, `event` |
-| `date` | Date picker | `event` |
-| `slider` | Single value slider | `min`, `max`, `step`, `show_value`, `event` |
-| `range` | Two-handle range slider | `min`, `max`, `start`, `end`, `event` |
-| `div` | Container with content | `content`, `component_id`, `style` |
-| `secret` | Password input | `show_toggle`, `show_copy`, `event` |
-| `marquee` | Scrolling ticker | `text`, `speed`, `ticker_items` |
-
-**Button Variants:** `primary`, `neutral`, `danger`, `success`
-
-**Toolbar Positions:** `top`, `bottom`, `left`, `right`, `inside`
-
-### MCP Event Handling
-
-Events from widget interactions are captured and can be retrieved via `get_events`:
-
-```
-# Get events without clearing
-get_events(widget_id="abc123", clear=false)
-
-# Get and clear events
-get_events(widget_id="abc123", clear=true)
-```
-
-**Event Structure:**
-
-```json
-{
- "events": [
- {
- "event_type": "button:click",
- "data": {"componentId": "submit-btn"},
- "label": "Submit"
- },
- {
- "event_type": "slider:change",
- "data": {"value": 75, "componentId": "volume"},
- "label": "Volume"
- }
- ]
-}
-```
-
-**Auto-Wired Button Callbacks:**
-
-Button events following the pattern `elementId:action` are automatically wired:
-
-| Pattern | Action |
-|---------|--------|
-| `counter:increment` | Adds 1 to element with id="counter" |
-| `counter:decrement` | Subtracts 1 from element |
-| `counter:reset` | Sets element to 0 |
-| `status:toggle` | Toggles true/false |
-
-### Resources & Prompts
-
-The MCP server exposes resources and prompts for agent discovery:
-
-**Resource URIs:**
-
-| URI Pattern | Description |
-|-------------|-------------|
-| `pywry://component/{name}` | Component documentation (e.g., `pywry://component/button`) |
-| `pywry://source/{name}` | Component Python source code |
-| `pywry://skill/{name}` | Skill guidance markdown |
-| `pywry://docs/events` | Built-in events reference |
-| `pywry://docs/quickstart` | Quick start guide |
-| `pywry://export/{widget_id}` | Export widget as Python code |
-
-**Prompts:**
-
-All skills are also available as prompts prefixed with `skill:`:
-
-- `skill:component_reference`
-- `skill:native`
-- `skill:data_visualization`
-- etc.
-
-### Example: Creating a Counter Widget
-
-An AI agent would call:
-
-```json
-{
- "tool": "create_widget",
- "arguments": {
- "html": "