Page Script and the Bridge
Event handlers wire clicks on markup that Dragon renders and re-paints. But the webview is a real browser: a page can carry its own <script>, and anything that runs on a web page runs here - a chart library, an editor, a keyboard shortcut layer, plain vanilla JS you wrote for one input. The moment page script owns some interactivity, it needs a way to reach Dragon - to load data, to save, to do the things only the native side can do.
That is the bridge. Page script calls a named Dragon handler and gets a Promise; Dragon registers the handler by name:
from ui import rpc
def lookup(payload: str) -> str {
...
}
rpc("weather.lookup", lookup)
window.dr.call('weather.lookup', JSON.stringify({ city: 'Lagos' }))
.then(function (s) { console.log(JSON.parse(s)); });
A handler takes one str and returns one str. The bridge does not impose a format, but JSON is the convention on both sides: [decode[dict[str, Any]]](/docs/1404-stdlib-data) / json.dumps in Dragon, JSON.parse / JSON.stringify in the page. Names are plain strings; the dots are just a naming convention. Register everything before App.run() - registration is setup, the run loop is the program's body.
A complete app
The page below owns its own little UI in vanilla JS; Dragon owns the data. The button's click handler is page script, not a Dragon event handler, and it reaches Dragon through the bridge:
import ui
import json
from json import decode
from html import HTML
from ui import rpc
from ui.desktop import Window
def lookup(payload: str) -> str {
const d: dict[str, Any] = decode[dict[str, Any]](payload.encode("utf-8"))
const city: str = d["city"]
temps: dict[str, int] = {Lagos: 31, Abuja: 28, Jos: 22}
if city not in temps {
raise ValueError(f"no reading for {city}")
}
return json.dumps({city: city, celsius: temps[city]})
}
rpc("weather.lookup", lookup)
view: HTML = template[HTML] {
<body style="font-family:system-ui;max-width:22rem;margin:2rem auto">
<h1>Weather</h1>
<input id="city" value="Lagos">
<button id="go">Look up</button>
<p id="out"></p>
<script>
document.getElementById('go').onclick = function () {
var city = document.getElementById('city').value;
window.dr.call('weather.lookup', JSON.stringify({ city: city }))
.then(function (s) {
var r = JSON.parse(s);
document.getElementById('out').textContent = r.city + ': ' + r.celsius + ' C';
})
.catch(function (e) {
document.getElementById('out').textContent = e.message;
});
};
</script>
</body>
}
win: Window = Window("Weather", 420, 260)
win.body = view
win.show()
ui.App.run()
Type a city, click the button, and the page script round-trips through Dragon: window.dr.call posts the request to native, lookup runs, and the Promise resolves with its return value.
Errors reject the Promise
Look at the handler again: an unknown city does not return an error blob, it raises. A raised exception rejects the page's Promise, and the exception message becomes the rejection's Error.message - so the page handles failure where JS already handles failure, in .catch:
window.dr.call('weather.lookup', JSON.stringify({ city: 'Nowhere' }))
.catch(function (e) { console.log(e.message); }); // "no reading for Nowhere"
Calling a name nothing registered rejects the same way. One channel for success, one for failure, on both sides of the bridge.
Handlers never block the window
Every handler runs on a green thread, off the UI thread. A handler can read a database, call a web API over the http client, or crunch for a second - the window keeps painting, scrolling, and answering clicks the whole time, and the Promise settles when the work is done. Two calls in flight at once are two green threads; they do not queue behind each other.
The flip side of that freedom is the usual one: handlers run concurrently, so shared mutable state needs the same care as any concurrent code. A module-level Lock around your store is the boring, correct default.
Which tool when
- Dragon owns the state, Dragon paints it - use signals and event handlers. No JS to write at all.
- The page owns some interactivity in JS (a library from
assets/, an editor, custom canvas work) - give it data and actions withui.rpc+window.dr.call.
The two compose freely in one window: reactive Dragon views around a JS-driven island is a normal shape, not a compromise.
