25 KiB
Platform Bridge Specification — el-native
This document is the authoritative reference for anyone implementing a new platform bridge for the el-native widget system. Read it top to bottom before writing a single line of code.
What a Platform Bridge Is
The el compiler (elc) emits C code. That C code calls __-prefixed functions for everything OS-related: printing, file I/O, threading, and — when building a native UI app — widget creation and event handling. These __ functions are the OS boundary.
For native UI, the bridge is the translation layer between that fixed C API and whatever platform toolkit you are targeting. The bridge owns:
- A slot table of up to 4096 widget objects, indexed by
int64_thandle. - Implementations of all 33
__*widget functions declared inel_native_target.h. - Callback dispatch from platform events back into El function symbols resolved via
dlsym(or a platform equivalent).
The thin wrappers in el_seed.c (#ifdef EL_TARGET_* blocks) marshal between el_val_t and native C types, then call through to the bridge. The bridge itself never touches el_val_t — it works only with plain C types (int64_t, const char*, int, float).
Existing bridges:
| File | Platform | Toolkit | Language |
|---|---|---|---|
el_appkit.m |
macOS | AppKit | ObjC (MRC) |
el_gtk4.c |
Linux | GTK4 | C |
el_win32.c |
Windows | Win32/ComCtl | C |
el_uikit.m |
iOS | UIKit | ObjC (MRC) |
el_android.c + ElBridge.java |
Android | View/JNI | C + Java |
el_sdl2.c |
Embedded Linux / Pi | SDL2 | C |
el_lvgl.c |
Microcontrollers | LVGL | C |
The Slot System
Every widget — window, button, label, container, image — is stored in a static array:
#define EL_<PLATFORM>_MAX_WIDGETS 4096
typedef struct {
ElWidgetKind kind;
/* platform-specific object reference (pointer, handle, ID...) */
/* callback names */
char* cb_click;
char* cb_change;
} ElWidget;
static ElWidget _el_widgets[EL_<PLATFORM>_MAX_WIDGETS];
Rules:
- Slot 0 is never valid. Scan starts at index 1. This ensures 0 is never a valid handle.
- Handle = slot index. An
int64_tvalue returned to El code is a direct index into_el_widgets[]. - -1 = invalid. All create functions return -1 on failure (table full, platform API error).
- Slot is FREE until allocated, FREE again after destroy. Use an
ElWidgetKindenum where0 = EL_WIDGET_FREEto track liveness. - 4096 slots is the system-wide maximum. This is intentional and sufficient for any realistic UI. Do not increase it without a compelling reason.
Slot allocation and release pattern
static int64_t el_widget_alloc(ElWidgetKind kind, /* platform object ref */) {
for (int i = 1; i < EL_<PLATFORM>_MAX_WIDGETS; i++) {
if (_el_widgets[i].kind == EL_WIDGET_FREE) {
_el_widgets[i].kind = kind;
/* store platform object ref — retain/addref if needed */
_el_widgets[i].cb_click = NULL;
_el_widgets[i].cb_change = NULL;
return (int64_t)i;
}
}
return -1; /* table full */
}
static ElWidget* el_widget_get(int64_t handle) {
if (handle <= 0 || handle >= EL_<PLATFORM>_MAX_WIDGETS) return NULL;
if (_el_widgets[handle].kind == EL_WIDGET_FREE) return NULL;
return &_el_widgets[handle];
}
static void el_widget_free(int64_t handle) {
ElWidget* w = el_widget_get(handle);
if (!w) return;
/* release platform object ref */
w->kind = EL_WIDGET_FREE;
free(w->cb_click); w->cb_click = NULL;
free(w->cb_change); w->cb_change = NULL;
}
NULL handle guard: el_widget_get must return NULL for any handle that is 0, negative, out of range, or points to a FREE slot. Every __ function that takes a handle must null-check the result of el_widget_get before doing anything. Failing to do so causes crashes or corruption when El code passes an uninitialized handle.
The 33 Required Functions
Every bridge must implement all 33 functions listed below. They are grouped by category. The signatures shown are from el_native_target.h and from el_seed.c's wrapper layer; the bridge itself uses plain C types (the wrappers do the el_val_t ↔ C-type conversion).
Internal C signatures (what the bridge implements)
These are what your .c / .m file defines. The el_seed.c wrappers call these.
Lifecycle (2 functions)
void el_<platform>_init(void);
Purpose: Initialize the platform UI toolkit. Must be idempotent (safe to call more than once). Called once from __native_init before any widget creation.
Edge cases: On platforms where the toolkit must be initialized before a display connection is established (X11, Wayland), this is where that happens. On Android, this is a no-op because ElBridge.java calls init from Java.
void el_<platform>_run_loop(void);
Purpose: Start the platform event loop. On most platforms this never returns. Exceptions: Android (the loop is Java-managed — this must be a no-op) and headless test builds.
Edge cases: Must be called from the main thread. On iOS/UIKit, calls UIApplicationMain which never returns; El code must set el_main_entry_fn before calling this.
Window (3 functions)
int64_t el_<platform>_window_create(const char* title, int w, int h, int mw, int mh);
Purpose: Create a top-level window. w/h = initial size in logical pixels. mw/mh = minimum size (0 = no minimum).
Returns: Slot handle, or -1 on failure.
Edge cases: NULL or empty title must be handled gracefully (use ""). On mobile (iOS, Android), the concept of a "window" maps to the root view controller / activity root view — create that here.
void el_<platform>_window_show(int64_t handle);
Purpose: Make the window visible. On some platforms windows are hidden at creation; this makes them appear.
Edge cases: NULL handle → return silently. Calling on an already-visible window is a no-op.
void el_<platform>_window_set_title(int64_t handle, const char* title);
Purpose: Update the window's title bar text at runtime.
Edge cases: NULL handle or NULL title → no-op.
Layout containers (4 functions)
int64_t el_<platform>_vstack_create(int spacing);
int64_t el_<platform>_hstack_create(int spacing);
Purpose: Create a vertical/horizontal linear container. spacing = gap between children in logical pixels.
Returns: Slot handle, or -1.
Edge cases: spacing = 0 is valid and common.
int64_t el_<platform>_zstack_create(void);
Purpose: Create a z-axis layered container (children overlap, no stacking direction). Used for overlays.
Returns: Slot handle, or -1.
int64_t el_<platform>_scroll_create(void);
Purpose: Create a scrollable container. Scrolls vertically by default. Only the first child added is the scrollable content.
Returns: Slot handle, or -1.
Leaf widgets (5 functions)
int64_t el_<platform>_label_create(const char* text);
Purpose: Create a non-editable text label.
Edge cases: NULL/empty text → label with empty string, not a crash.
int64_t el_<platform>_button_create(const char* label);
Purpose: Create a clickable button. The label is the button's visible text.
Edge cases: The button must wire up an action target at creation time so that click callbacks registered later via el_<platform>_widget_on_click will fire. On platforms with target-action (AppKit, UIKit), allocate the delegate object here.
int64_t el_<platform>_text_field_create(const char* placeholder);
Purpose: Create a single-line text input. placeholder is the hint text shown when empty.
Edge cases: NULL placeholder → no hint text displayed.
int64_t el_<platform>_text_area_create(const char* placeholder);
Purpose: Create a multi-line text input (scrollable).
Edge cases: Same as text_field_create. On AppKit, this wraps NSTextView inside NSScrollView — the slot's obj points to the scroll view, not the text view.
int64_t el_<platform>_image_create(const char* path_or_name);
Purpose: Create an image widget. path_or_name can be a filesystem path or a platform resource name. Try path first, fall back to named resource.
Edge cases: Non-existent path → create an empty image widget (do not crash). NULL → same.
Widget properties (12 functions)
void el_<platform>_widget_set_text(int64_t handle, const char* text);
Purpose: Update the text content of a label, button, text field, text area, or window title. Must dispatch on kind to use the correct API.
Edge cases: NULL handle → no-op. NULL text → treat as "".
const char* el_<platform>_widget_get_text(int64_t handle);
Purpose: Return the current text of a widget. Returns a const char* that the el_seed.c wrapper wraps into an el_val_t string.
Edge cases: NULL handle → return "" (never NULL). The caller in el_seed.c handles the strdup lifetime issue — see the AppKit reference implementation notes.
void el_<platform>_widget_set_color(int64_t h, float r, float g, float b, float a);
void el_<platform>_widget_set_bg_color(int64_t h, float r, float g, float b, float a);
Purpose: Set foreground (text) color and background color respectively. All channels are normalized floats [0.0, 1.0].
Edge cases: For containers, set_color may be a no-op (no text); set_bg_color should set the layer/surface background. On platforms without alpha compositing, clamp alpha to 0 or 1.
void el_<platform>_widget_set_font(int64_t h, const char* family, int size, int bold);
Purpose: Set the font on a text-bearing widget. family = font family name or "system" for the platform default. size = point size. bold = 0 or 1.
Edge cases: If family is not found, fall back to the system font. Non-text widgets (containers, images) → no-op.
void el_<platform>_widget_set_padding(int64_t h, int top, int right, int bottom, int left);
Purpose: Set internal padding/insets for a container or text area.
Edge cases: On platforms where padding is per-view (not per-container), map to the nearest equivalent (margin, insets, text container inset). For leaf widgets other than text areas, this may be a partial no-op.
void el_<platform>_widget_set_width(int64_t h, int width);
void el_<platform>_widget_set_height(int64_t h, int height);
Purpose: Apply a fixed-size constraint. width/height in logical pixels.
Edge cases: On platforms with Auto Layout or constraint systems, add a fixed-size constraint. Do not apply to windows (size is set at creation). Calling multiple times should override the previous constraint, not add another.
void el_<platform>_widget_set_flex(int64_t h, int flex);
Purpose: Set the flex/expansion factor. flex > 0 → the widget expands to fill available space. flex == 0 → hugs content size.
Edge cases: Maps to content-hugging priority (AppKit), GtkWidget::hexpand/vexpand (GTK4), or layout weight (Android). For windows → no-op.
void el_<platform>_widget_set_corner_radius(int64_t h, int radius);
Purpose: Apply rounded corners to the widget's visual layer.
Edge cases: Requires backing layer / GPU surface. On platforms without layer compositing (Win32 without DX), this may be a no-op or require manual painting. Radius in logical pixels.
void el_<platform>_widget_set_disabled(int64_t h, int disabled);
Purpose: Enable or disable user interaction. disabled = 1 → greyed out, non-interactive.
Edge cases: Only meaningful for interactive widgets (button, text field). For containers/labels → no-op.
void el_<platform>_widget_set_hidden(int64_t h, int hidden);
Purpose: Show or hide the widget. hidden = 1 → invisible but still in layout.
Edge cases: For windows, map to orderOut/hide or equivalent. For views, use setHidden/gtk_widget_set_visible or equivalent.
Tree management (3 functions)
void el_<platform>_widget_add_child(int64_t parent, int64_t child);
Purpose: Attach a child widget to a parent container. Dispatch on parent kind:
- Window → add to root content view/container
- VStack/HStack → add as arranged/linear child
- ZStack → add as overlapping subview
- Scroll → set as document/content view (first child only)
- Other → add as plain subview
Edge cases: NULL parent or child handle → no-op. Attempting to add a window as a child → no-op. Adding the same child twice is platform-defined behavior (tolerate it).
void el_<platform>_widget_remove_child(int64_t parent, int64_t child);
Purpose: Detach child from its parent. The child slot remains allocated; the widget is not destroyed.
Edge cases: NULL handles → no-op. Child not currently attached → no-op.
void el_<platform>_widget_destroy(int64_t handle);
Purpose: Destroy a widget: remove from superview/parent, release the platform object, release callback strings, mark slot as FREE.
Edge cases: For windows, close the window. Free any delegate/target objects stored in side tables. After destroy, the handle is invalid — El code must not use it again (this is the caller's responsibility, not enforced here).
Event registration (3 functions)
void el_<platform>_widget_on_click(int64_t h, const char* fn_name);
void el_<platform>_widget_on_change(int64_t h, const char* fn_name);
void el_<platform>_widget_on_submit(int64_t h, const char* fn_name);
Purpose: Register an El callback function by symbol name.
on_click→ button presson_change→ text field value change (keystroke-level)on_submit→ text field Enter key (text field only; stored incb_clickslot in AppKit)
The implementation stores strdup(fn_name) in the widget's cb_click or cb_change field. The platform event handler calls el_<platform>_invoke_cb (see callback ABI section).
Edge cases: NULL or empty fn_name → clear the callback (free + set NULL). Calling on a non-interactive widget (label, image) → no-op or store silently (harmless).
Manifest reader (1 function)
/* Note: __manifest_read is implemented in el_seed.c, not in the bridge. */
/* Bridges do NOT need to implement this. */
__manifest_read is handled entirely in the platform-independent section of el_seed.c. It reads a JSON/EL manifest file from the path in the EL_MANIFEST environment variable. Bridge authors do not need to implement this.
The Callback ABI
When a platform event fires (button clicked, text changed), the bridge must call back into the El runtime. The mechanism:
typedef void (*ElCb2)(int64_t handle, int64_t data);
static void el_<platform>_invoke_cb(const char* fn_name, int64_t handle, const char* data) {
if (!fn_name || !*fn_name) return;
void* sym = dlsym(RTLD_DEFAULT, fn_name);
if (!sym) return;
ElCb2 fn = (ElCb2)sym;
fn(handle, (int64_t)(uintptr_t)(data ? data : ""));
}
The El callback signature (in El source):
fn handler(handle: Int, data: String) -> Void
Which compiles to:
void handler(int64_t handle, int64_t data)
Where data is a const char* cast to int64_t (the el String representation). For click events, data is "". For change/submit events, data is the current widget text.
On platforms without dlsym (Windows, some embedded systems): use GetProcAddress(GetModuleHandle(NULL), fn_name) on Windows, or maintain a manual symbol registration table for embedded targets where dynamic linking is unavailable.
Thread safety for callbacks: Callbacks fired from a background thread must be marshalled to the UI thread before calling into El code. El code may call __widget_set_text or other UI functions synchronously from within the callback — those must run on the UI thread.
Thread Safety Contract
All platform UI operations must execute on the main/UI thread. This is a hard requirement on every platform (AppKit, UIKit, GTK4, Win32, Android View, SDL2 main thread rule).
The reference pattern (AppKit):
static void el_appkit_sync_main(void (^block)(void)) {
if ([NSThread isMainThread]) {
block();
} else {
dispatch_sync(dispatch_get_main_queue(), block);
}
}
For other platforms:
- GTK4:
g_main_context_invokeorg_idle_add+ semaphore for synchronous dispatch - Win32:
SendMessage(hwnd, WM_APP, ...)orPostMessage+ wait - Android:
Activity.runOnUiThread - SDL2: All ops must be called from the thread that initialized SDL (the main thread)
- LVGL:
lv_lock()/lv_unlock()for thread-safe access
El program flow is: main() → build UI (on main thread) → __native_run_loop(). Because UI is built before the run loop starts, most widget creation calls are already on the main thread. The sync dispatch wrapper exists to handle callbacks that arrive on worker threads (e.g., network callbacks that update UI).
Integration Pattern
In el_native_target.h
Add an #ifdef EL_TARGET_<PLATFORM> block declaring all 33 __* functions with their el_val_t signatures (identical to the existing blocks for MACOS, LINUX, etc.):
#ifdef EL_TARGET_<PLATFORM>
void __native_init(void);
void __native_run_loop(void);
el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
el_val_t min_width, el_val_t min_height);
void __window_show(el_val_t handle);
void __window_set_title(el_val_t handle, el_val_t title);
el_val_t __vstack_create(el_val_t spacing);
el_val_t __hstack_create(el_val_t spacing);
el_val_t __zstack_create(void);
el_val_t __scroll_create(void);
el_val_t __label_create(el_val_t text);
el_val_t __button_create(el_val_t label);
el_val_t __text_field_create(el_val_t placeholder);
el_val_t __text_area_create(el_val_t placeholder);
el_val_t __image_create(el_val_t path_or_name);
void __widget_set_text(el_val_t handle, el_val_t text);
el_val_t __widget_get_text(el_val_t handle);
void __widget_set_color(el_val_t handle, el_val_t r, el_val_t g,
el_val_t b, el_val_t a);
void __widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g,
el_val_t b, el_val_t a);
void __widget_set_font(el_val_t handle, el_val_t family,
el_val_t size, el_val_t bold);
void __widget_set_padding(el_val_t handle, el_val_t top, el_val_t right,
el_val_t bottom, el_val_t left);
void __widget_set_width(el_val_t handle, el_val_t width);
void __widget_set_height(el_val_t handle, el_val_t height);
void __widget_set_flex(el_val_t handle, el_val_t flex);
void __widget_set_corner_radius(el_val_t handle, el_val_t radius);
void __widget_set_disabled(el_val_t handle, el_val_t disabled);
void __widget_set_hidden(el_val_t handle, el_val_t hidden);
void __widget_add_child(el_val_t parent, el_val_t child);
void __widget_remove_child(el_val_t parent, el_val_t child);
void __widget_destroy(el_val_t handle);
void __widget_on_click(el_val_t handle, el_val_t fn_name);
void __widget_on_change(el_val_t handle, el_val_t fn_name);
void __widget_on_submit(el_val_t handle, el_val_t fn_name);
el_val_t __manifest_read(el_val_t path);
#endif /* EL_TARGET_<PLATFORM> */
In el_seed.c
Add an #ifdef EL_TARGET_<PLATFORM> block containing:
externdeclarations of allel_<platform>_*functions (your bridge's C API)- Thin wrapper functions that marshal
el_val_t↔ C types and call through
The wrapper pattern (copy from the EL_TARGET_MACOS block and substitute the platform name):
#ifdef EL_TARGET_<PLATFORM>
/* Forward declarations — implemented in el_<platform>.c */
extern void el_<platform>_init(void);
extern void el_<platform>_run_loop(void);
extern int64_t el_<platform>_window_create(const char* title, int w, int h, int mw, int mh);
/* ... all others ... */
/* Wrappers */
void __native_init(void) { el_<platform>_init(); }
void __native_run_loop(void) { el_<platform>_run_loop(); }
el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
el_val_t min_width, el_val_t min_height) {
return (el_val_t)el_<platform>_window_create(
EL_CSTR(title),
(int)(int64_t)width, (int)(int64_t)height,
(int)(int64_t)min_width, (int)(int64_t)min_height);
}
void __window_show(el_val_t h) { el_<platform>_window_show((int64_t)h); }
void __window_set_title(el_val_t h, el_val_t t) { el_<platform>_window_set_title((int64_t)h, EL_CSTR(t)); }
/* ... continue for all 33 functions ... */
#endif /* EL_TARGET_<PLATFORM> */
Key marshalling rules:
el_val_t→int64_thandle:(int64_t)hel_val_t→int:(int)(int64_t)valueel_val_t→const char*:EL_CSTR(value)el_val_t→float:(float)el_to_float(value)(for color channels)int64_thandle →el_val_t:(el_val_t)handleconst char*→el_val_t:EL_STR(str)— but read the get_text note below
__widget_get_text note: The bridge returns const char*. The el_seed.c wrapper wraps it with EL_STR(s). The returned pointer must remain valid until the El program is done with it. The AppKit implementation returns a strdup'd string — the caller (seed wrapper) stores it without tracking it in the arena. This is a known lifetime edge; be consistent with the platform's existing pattern.
How el Strings Work
Inside El compiled C code, strings are el_val_t values where the value is the uintptr_t cast of a const char*:
#define EL_STR(s) ((el_val_t)(uintptr_t)(s))
#define EL_CSTR(v) ((const char*)(uintptr_t)(v))
To construct a string result in el_seed.c:
static char* s = strdup("hello");
return EL_STR(s);
To read a string argument passed from El:
const char* text = EL_CSTR(some_el_val_t_argument);
The EL_STR / EL_CSTR macros are defined in el_seed.h and are available in el_seed.c. Bridge files (el_<platform>.c) do not use these macros — they only deal in const char* at their API boundary.
Known Gotchas
Duplicate symbols with el_runtime.c
el_runtime.c defines many of the same __-prefixed symbols as el_seed.c. When linking both (required for the native-hello example), the linker will reject duplicate definitions. The build system uses nmedit (macOS) to hide the el_runtime.c copies of symbols that el_seed.c already defines, keeping el_seed.c canonical.
If you are writing a build script for a new platform and see duplicate symbol link errors involving __println, __print, __str_len, etc., apply the same nmedit / objcopy --weaken-symbol / strip --strip-symbol trick from the macOS build to your platform's object file handling.
The nmedit trick on macOS
# Build a keep-list: symbols defined in el_seed.o but also in el_runtime.o
nm el_seed.o | awk '/^[0-9a-f]+ T _/{print $3}' | sort > .seed_T.txt
nm el_runtime.o | awk '/^[0-9a-f]+ T _/{print $3}' | sort > .rt_T.txt
# Keep only symbols unique to el_runtime.o
comm -23 .rt_T.txt .seed_T.txt > .rt_keep.txt
nmedit -s .rt_keep.txt el_runtime.o
On Linux, use objcopy with --weaken-symbol for each duplicate, or link el_seed.o before el_runtime.o and use --allow-multiple-definition if your use case permits it.
ObjC bridges must use MRC, not ARC
Bridges that use Objective-C (AppKit, UIKit) must compile without ARC (-fno-objc-arc). The reason: the widget table stores id values in a plain C struct. ARC cannot insert retain/release through C struct boundaries, and will reject explicit [obj retain] / [obj release] calls in ARC mode. Use:
clang -ObjC -fno-objc-arc -framework Cocoa -c el_appkit.m
Widget table struct — don't put id fields in C structs under ARC
If you ever add ARC-managed object fields to the ElWidget struct, you will get a compile error. Keep the struct C-only (pointers as void* if you must, cast when using) or compile as MRC.
The el_seed.c __manifest_read is platform-independent
__manifest_read is already implemented in el_seed.c (in the section compiled unconditionally). You do not implement it in your bridge. You do need to declare it in the el_native_target.h block for your platform (matching the other platforms), but el_seed.c already has the implementation.
Completion Checklist
Before declaring your bridge ready for integration:
- All 33
__*functions implemented (including__manifest_readdeclaration, implementation is in seed) - Slot table with 4096 entries, scan starting at index 1
el_widget_getreturns NULL for handle 0, negative, out-of-range, and FREE slots- All functions null-check
el_widget_getresult before use el_<platform>_initis idempotentel_<platform>_run_loopeither never returns or is documented as a no-op (Android)- Callback dispatch via
dlsym(or platform equivalent) implemented - All UI operations dispatched to the main/UI thread
el_native_target.hupdated with#ifdef EL_TARGET_<PLATFORM>blockel_seed.cupdated with#ifdef EL_TARGET_<PLATFORM>extern + wrapper block- Bridge compiles cleanly with no warnings:
cc -DEL_TARGET_<PLATFORM> -Wall -Wextra -c el_<platform>.c - Bridge links cleanly with
el_seed.oandel_runtime.o(duplicate symbol check) detect-platformsscript updated with detection logic for the new platform- Basic smoke test: create window → add label → show window → run loop