03 — Events & state
How user actions in the rendered page reach back into your Amalgame code, how form state flows, and how to refresh parts of the DOM without re-rendering the whole page.
The event model
Every DOM event is routed through a single uniform mechanism:
Element.<widget>(...).On(eventName, handler)
On("click", h), On("change", h), On("wheel", h), custom
events like On("my-app-event", h) — all use the same wiring.
For the common WinForms-aligned events, sugar setters exist:
| Sugar setter | DOM event | WinForms equivalent |
|---|---|---|
.OnClick(h) |
click |
Click |
.OnDblClick(h) |
dblclick |
DoubleClick |
.OnChange(h) |
change + input (for text inputs / textareas) |
TextChanged / CheckedChanged / SelectedIndexChanged |
.OnFocus(h) |
focus |
GotFocus / Enter |
.OnBlur(h) |
blur |
LostFocus / Leave |
.OnMouseEnter(h) |
mouseenter |
MouseEnter |
.OnMouseLeave(h) |
mouseleave |
MouseLeave |
.OnKeyDown(h) |
keydown |
KeyDown |
.OnKeyUp(h) |
keyup |
KeyUp |
A single Element can carry multiple events independently:
Element.Input("user")
.OnFocus((req: string) => req)
.OnBlur((req: string) => req)
.OnChange((req: string) => req)
Handler signature
Every handler is a closure with this shape:
(req: string) => string
reqis a JSON-encoded snapshot of every named form field on the page at the moment the event fires.- The return value must be valid JSON — either
"true", a string like"\"ok\"", a number"42", or a richer object.
The simplest handler echoes the form back:
Element.Button("Submit").OnClick((req: string) => req)
For a free-form text return, wrap with Json.EncodeString:
Element.Button("Greet").OnClick((req: string) => Json.EncodeString("hello"))
Form payload
Every element with a name attribute (built via the Input,
Password, Select, etc. builders, or by .Bind(name) on a raw
element) is auto-collected by the bridge window.__amc_collect
injected by Page.ApplyTo.
Example handler req:
{
"user": "alice",
"message": "Hi!",
"newsletter": true,
"priority": "normal",
"theme": "dark"
}
- Checkboxes → boolean.
- Radios → the value of the checked one in the group; key absent when none is checked.
- Multi-select listboxes → not auto-collected in v0.0.5 — read
.selectedOptionsviaWindow.Evaluntil v0.0.6. Element.RichTextBox(name)(v0.0.9) → the contenteditable's.innerHTML(preserves bold / italic / lists).Element.MonthCalendar(name, …)(v0.0.9) → the selected day as ISOYYYY-MM-DD; key absent when no day is selected.- Other inputs → their
.value(always a string in HTML, even fortype=number/type=date).
The same payload reaches every event on every Element — you don't have to wire each input into a specific handler. Read just the fields you care about.
OnResult — routing the return value
Without OnResult, the handler's return is discarded. With it,
the bridge writes the JSON-pretty-printed result into a target
element by id:
Element.Stack()
.AddChild(Element.Button("Submit")
.OnClick((req: string) => req)
.OnResult("out")) // ← writes the handler's
// return into #out
.AddChild(Element.Pre("").Id("out"))
- The router calls
JSON.stringify(JSON.parse(r), null, 2)first — valid JSON renders pretty. Plain strings fall through verbatim. OnResult(targetId)is sticky across event setters: anyOn*call that comes BEFORE it captures the route. AnyOn*AFTER doesn't, unless you set it again.- Pass
""to reset.
To handle a plain string and avoid the JSON pretty-print, return
it unquoted from the handler — Json.EncodeString and the
router co-operate so a string return prints as quoted text in the
<pre>.
Bind name collisions
If two Elements have the same id, OnResult only updates the
first match (document.getElementById). Same for Page.PatchInner /
AppendInner targets — keep ids unique.
Auto-collect treats name values like form keys: two <input
name="x"> widgets clobber each other in the payload. Use
radio-group sharing intentionally, but otherwise pick distinct
names.
Partial DOM updates
Re-rendering the whole page on every state change is wasteful and loses focus, scroll position, etc. Use the patching API for incremental changes.
Raw DOM ops (Window.*)
| Call | What it does |
|---|---|
Window.SetInnerHtml(id, html) |
Replace #id's inner HTML. |
Window.AppendHtml(id, html) |
Append HTML as the last child of #id. |
Window.RemoveElement(id) |
Drop the element with the given id. |
win.SetInnerHtml("status", "<span>Saved.</span>")
These are escape hatches — no event bindings are wired
automatically, so anything with an onclick won't have an AM
handler unless you Window.Bind it separately.
Element-typed patching (Page.*)
The typed wrappers render an Element subtree, wire any new
OnClick / OnChange it contains, and inject the result via
the raw ops above:
| Call | What it does |
|---|---|
Page.PatchInner(win, id, element) |
Render element and replace #id's children. |
Page.AppendInner(win, id, element) |
Render element and append to #id's children. |
let row: Element = Element.ListViewRow(["new.txt", "0 KB", "today"])
page.AppendInner(win, "files-body", row)
Events in the new subtree get freshly-allocated _amc_N bind
names and are auto-registered. The Page.Counter keeps growing
across patches — names stay unique.
Pattern: DataGrid live update
public static void Main() {
let win: Window = new Window("Files", 600, 400, false)
if (!win.IsValid()) { return }
let cols: List<string> = ["Name", "Size", "Modified"]
let page: Page = Page.New().SetTitle("Files").SetBody(
Element.Stack()
.AddChild(Element.Heading("Files"))
.AddChild(Element.ListView(cols, "files-body"))
.AddChild(Element.Button("Refresh")
.Attr("onclick", "window.amc_refresh('');"))
)
var counter: int = 0
win.Bind("amc_refresh", (req: string) => {
counter = counter + 1
let r: List<string> = [
"row-" + String_FromInt(counter) + ".txt",
"0 KB",
"now"
]
page.AppendInner(win, "files-body", Element.ListViewRow(r))
return "true"
})
page.ApplyTo(win)
win.Run()
win.Destroy()
}
Each click on Refresh appends a new row to the table without re-rendering the heading, the button, or the existing rows.
Dialog handlers (v0.0.6+)
Dialog.Info / Dialog.Confirm / Dialog.OpenFile etc.
don't deliver the page's form payload. They're one-shot
bridges: the handler receives the user's choice as a plain
string.
| Call | req shape |
|---|---|
Dialog.Info / Warning / Error |
"ok" (button) or "cancel" (Esc / backdrop) |
Dialog.Confirm |
"ok" or "cancel" |
Dialog.YesNoCancel |
"yes", "no", or "cancel" |
Dialog.OpenFile |
filename string ("" on cancel) |
Dialog.OpenFileContent (v0.0.8) |
"" on cancel, else JSON {"name":"…","content":"…"} |
Dialog.SaveFile |
"ok" once the download is initiated |
Dialog.Confirm(win, "Quit?", "Discard unsaved changes?",
(req: string) => {
if (req == "ok") { win.Terminate() }
return ""
})
If you need the form payload alongside the user's choice, snap
it once via Window.Eval and a hidden field, then read it from
inside the dialog handler — the per-event form-collect bridge
isn't wired through Dialog.* calls.
MenuBar action handlers (v0.0.8)
Element.MenuOption(label, actionName) calls
window.<actionName>('') on click. The empty-string argument
means the handler always receives "" as req — same caveat
as Dialogs: bind a separate Window.Eval collector first if
you need the form state when the menu option fires.
win.Bind("amc_save", (req: string) => {
// req is "" — collect the form via the regular bridge:
win.Eval("window._amc_save_payload = JSON.stringify(window.__amc_collect());")
// … then read window._amc_save_payload from a follow-up Eval.
return ""
})
Most apps don't need this — menu actions tend to drive top-level
flow (Open / Save / Quit) and read state via dedicated dialogs.
When they do need the form, a Window.Bind triggered by an
in-page Button is simpler than a MenuOption.
Custom JS events
Anything the browser fires can be bound:
Element.Div().Id("drop-zone")
.On("drop", (req: string) => req)
.On("dragover", (req: string) => req)
.On("dragleave", (req: string) => req)
The JSON payload is still the form snapshot — to read the event
itself (e.g. drag coordinates, dropped files), inject a small JS
shim via Window.Eval that captures the relevant event data into
a hidden input, and read that hidden input on the next bound
call.
data-amc-internal opt-out
Element.Link(text, url) and the global <a> interceptor route
http(s) URLs to the OS browser. If you want a specific anchor to
stay inside the webview (in-app navigation), mark it:
new Element("a")
.Attr("href", "#page2")
.Attr("data-amc-internal", "true")
.SetText("Section 2")
The default browser routing then ignores it.
What Window.Bind actually does
The plumbing under the hood. Window.Bind(name, closure) does
two things at the webview level:
- Registers
namein the internal binding table (capacity:AMALGAME_UI_WEB_MAX_BINDINGS = 64). - Injects a JS shim: every time
window.<name>(args...)is called from the page, the call is queued, the C trampoline wakes the AM closure, the closure runs, and its string return reaches the JS side as aPromiseresolution.
That's why every event handler signature is (req: string) => string
— bind only knows about JSON strings.