Files
el/lang/el-compiler/runtime/PLATFORM_BRIDGE_SPEC.md

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:

  1. A slot table of up to 4096 widget objects, indexed by int64_t handle.
  2. Implementations of all 33 __* widget functions declared in el_native_target.h.
  3. 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_t value 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 ElWidgetKind enum where 0 = EL_WIDGET_FREE to 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 press
  • on_change → text field value change (keystroke-level)
  • on_submit → text field Enter key (text field only; stored in cb_click slot 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_invoke or g_idle_add + semaphore for synchronous dispatch
  • Win32: SendMessage(hwnd, WM_APP, ...) or PostMessage + 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:

  1. extern declarations of all el_<platform>_* functions (your bridge's C API)
  2. 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_tint64_t handle: (int64_t)h
  • el_val_tint: (int)(int64_t)value
  • el_val_tconst char*: EL_CSTR(value)
  • el_val_tfloat: (float)el_to_float(value) (for color channels)
  • int64_t handle → el_val_t: (el_val_t)handle
  • const 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_read declaration, implementation is in seed)
  • Slot table with 4096 entries, scan starting at index 1
  • el_widget_get returns NULL for handle 0, negative, out-of-range, and FREE slots
  • All functions null-check el_widget_get result before use
  • el_<platform>_init is idempotent
  • el_<platform>_run_loop either 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.h updated with #ifdef EL_TARGET_<PLATFORM> block
  • el_seed.c updated 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.o and el_runtime.o (duplicate symbol check)
  • detect-platforms script updated with detection logic for the new platform
  • Basic smoke test: create window → add label → show window → run loop