AppCore in C
Create desktop applications, manage windows and layouts, and dispatch work in C.
On this page
AppCore provides an application runtime, windows, layout containers, panels, and popups for C applications. You include <AppCore/CAPI.h>, which automatically includes the window layout declarations and <Ultralight/CAPI.h>.
Before working in C, read the App Framework guides starting with App Lifecycle and Settings. This page covers what differs in C, while C API Conventions covers shared C rules for memory management, callbacks, and threading.
Differences from C++
AppCore replaces C++ structs and listener interfaces with opaque handles, descriptor structs, and individual callback setters.
| C++ Feature | C Equivalent | What Changes |
|---|---|---|
Settings struct |
ulCreateSettings() and ulSettingsSet*() |
Configure an allocated handle and destroy it after calling ulCreateApp(). |
AppListener and WindowListener |
ulAppSet*Callback() and ulWindowSet*Callback() |
Register individual callbacks per event instead of implementing an interface. |
LayoutCallback guard objects |
Slot setters like ulLayoutNodeSetLayoutChangeCallback() |
Attach callbacks directly to slots without saving a guard object. |
| Option structs with designated initializers | Descriptor structs (ULPanelDesc, ULContainerDesc, ULForegroundPanelDesc) |
Zero-initialize the descriptor and set struct_size. |
Size strings ("240px", "30%", "2fr") |
ULLayoutSize via ulLayoutSizePx(), ulLayoutSizePct(), and ulLayoutSizeFr() |
Build sizes with helper functions, where zero-initialization leaves the size unset. |
RefPtr node handles |
ULPanel, ULContainer, and ULLayoutNode handles |
Returned layout handles are owned and must be destroyed manually. |
TimerHandle |
ULAppTimer |
Timers repeat on the main thread until released with ulDestroyAppTimer(). |
Anchor fluent builder methods |
ULAnchorDesc |
Set kind and populate only the fields that placement style requires. |
Creating the App
To initialize the application, configure a settings handle, create the app runtime, and register your per-frame update callback.
#include <AppCore/CAPI.h>
static ULApp app = NULL;
static void OnUpdate(void* user_data) {
(void)user_data;
// Per-frame app logic goes here.
}
void CreateApp(void) {
ULSettings settings = ulCreateSettings();
ULString developer = ulCreateString("Acme");
ULString name = ulCreateString("Rocket Planner");
ulSettingsSetDeveloperName(settings, developer);
ulSettingsSetAppName(settings, name);
ulDestroyString(developer);
ulDestroyString(name);
app = ulCreateApp(settings, NULL);
ulDestroySettings(settings); // The App keeps its own copy.
ulAppSetUpdateCallback(app, OnUpdate, NULL, NULL);
}
Both ulCreateApp() and the settings setters copy the values you pass, so you can destroy temporary ULString and ULSettings handles immediately after using them. If you need the underlying ULRenderer instance, call ulAppGetRenderer()— the returned handle is borrowed and owned by the app, so you must never destroy it.
Destroy all open windows before calling ulDestroyApp(). For the full teardown sequence and application lifecycle, see the C examples in Writing Your First App.
Window Callbacks
AppCore provides individual callback setters for each window event rather than a listener object. Passing NULL as the callback function clears the registration.
- Window handles match your created pointers. The
windowargument passed to callbacks matches theULWindowhandle you created, including popups. You can test identity with==and reuse a single callback function across multiple windows. - User data cleanup runs on replacement or destruction. Any
destroy_user_datahook you register runs when the callback is replaced or whenulDestroyWindow()executes, not when the window closes. For details on user data ownership, see C API Conventions.
Closing and Destroying Windows
Calling ulWindowClose() triggers the window's close callback, closes any owned popups, and disables hosted panels. The window handle remains valid, so you must still release it with ulDestroyWindow().
🚧 Close Before Destroying
Calling
ulDestroyWindow()on an open window closes it immediately without invoking your close callback. If you rely on close logic to save state or confirm teardown, callulWindowClose()first.
Intercepting Input Events
You can intercept keyboard, mouse, and scroll events before any page receives them by registering input callbacks on the window.
#include <AppCore/CAPI.h>
void ShowHelp(void);
// Open the help window on F1 before any page sees the key.
static bool OnKeyEvent(void* user_data, ULWindow window, ULKeyEvent evt) {
(void)user_data;
(void)window;
if (ulKeyEventGetType(evt) == kKeyEventType_RawKeyDown &&
ulKeyEventGetVirtualKeyCode(evt) == 0x70) { // F1
ShowHelp();
return false;
}
return true;
}
void InterceptKeys(ULWindow window) {
ulWindowSetKeyEventCallback(window, OnKeyEvent, NULL, NULL);
}
Keep these rules in mind when handling input events:
- Return
falseto consume the event. Returntrueto pass it on so the active page can process it. - Event handles are borrowed. The handle is valid only during the callback, so you must never destroy it.
- Key codes have no named constants in C. Compare numeric virtual key codes directly (such as
0x70for F1) instead of C++ constants likeKeyCodes::GK_F1. - Getters inspect event properties. Read keyboard events with
ulKeyEventGetType(),ulKeyEventGetVirtualKeyCode(), andulKeyEventGetModifiers(). Similar getters exist for mouse and scroll events.
For details on window input routing, see Windows, Monitors, and DPI.
Laying Out Panels
You arrange a window's interface by organizing panels into a tree of row and column containers.
Configuring Descriptors and Sizes
To split a window into rows and columns, configure descriptor structs and add child nodes one call at a time:
#include <AppCore/CAPI.h>
static ULWindow main_window = NULL; // From ulCreateWindow().
static ULPanel sidebar = NULL;
static ULPanel content = NULL;
void BuildLayout(void) {
ULContainerDesc body_desc = {0};
body_desc.struct_size = sizeof(ULContainerDesc);
body_desc.key = "body";
body_desc.flags = kULLayoutFlags_Resizable;
ULPanelDesc sidebar_desc = {0};
sidebar_desc.struct_size = sizeof(ULPanelDesc);
sidebar_desc.key = "sidebar";
sidebar_desc.size = ulLayoutSizePx(240);
sidebar_desc.min_size = ulLayoutSizePx(160);
ULContainer root = ulWindowGetLayout(main_window);
ULContainer body = ulContainerAddRow(root, &body_desc, NULL);
sidebar = ulContainerAddPanel(body, &sidebar_desc, NULL, NULL);
content = ulContainerAddPanel(body, NULL, NULL, NULL);
// The tree keeps both containers.
ulDestroyContainer(body);
ulDestroyContainer(root);
}
Passing NULL for a descriptor uses default settings for every field. Leaving a child node's size unset gives it a single flex share (1fr).
The C API provides no fluent layout builder— you build the tree node by node. To configure the window's root container, call ulWindowConfigureLayout(). For sizing rules and container properties, see Laying Out Panels.
🚧 Always Set Struct Size
Leaving
struct_sizeset to 0 causes AppCore to ignore the descriptor, log a warning, and fall back to default values. This rule applies to every descriptor struct in the library, includingULBackdropOptions.
Managing Node Handles and Identity
Here is what to know about layout handles:
- Destroying a layout handle releases only your reference. It never removes the node from the layout tree. To remove a node and its children, call
ulContainerRemove(). For handle ownership details, see C API Conventions. - Node operations take a
ULLayoutNodehandle. CallulPanelAsLayoutNode()orulContainerAsLayoutNode()to convert a handle, and destroy the resulting layout node when finished. - Compare node identity with
ulLayoutNodeIsSame(), never==. Separate queries return distinct pointer values for the same node. To compare two panels or containers, convert both toULLayoutNodehandles first.
Accessing a Panel's View
Call ulPanelGetView() to access a panel's hosted View and load web content:
#include <AppCore/CAPI.h>
void LoadIntoPanel(ULPanel panel, const char* url_string) {
ULView view = ulPanelGetView(panel);
if (!view)
return; // The panel was removed or its window closed.
ULString url = ulCreateString(url_string);
ulViewLoadURL(view, url);
ulDestroyString(url);
ulDestroyView(view);
}
ulPanelGetView() returns an owned ULView handle, or NULL if the panel was removed or its window closed. Destroying this handle releases only your reference— the panel keeps its hosted View alive. For View callbacks, lifetime rules, and passing NULL handles, see Views in C.
Registering Layout Callbacks
In C, layout callbacks attach directly to slots on the target node or window, eliminating the need to store guard objects. Available setters include ulLayoutNodeSetLayoutChangeCallback(), ulLayoutNodeSetUserResizeCallback(), ulPanelSetDismissCallback(), ulWindowSetFocusChangeCallback(), and ulWindowSetEditableStateCallback(). A slot remains active until you clear it with NULL, replace it, remove the node, or close the window.
Node and panel handles passed as callback parameters are borrowed and remain valid only during the call. If you need to keep one past the callback, call ulCreateLayoutNodeRef() or ulCreatePanelRef(). Never call a destroy function on a borrowed handle.
📘 Window Handles in Callbacks
The focus-change and editable-state callbacks pass a borrowed
ULWindowhandle that never equals your original window pointer. Compare them usingulWindowIsSame()instead of==, and never destroy the borrowed handle.
Floating Panels and Popups
You can anchor a floating panel beneath a web element by configuring an anchor descriptor on the window's foreground layer.
#include <AppCore/CAPI.h>
#include <Ultralight/CAPI/CAPI_DOMElement.h>
static ULWindow main_window = NULL;
static ULPanel toolbar = NULL;
static ULPanel dropdown = NULL;
static void OnDropdownDismissed(void* user_data, ULPanel panel) {
(void)user_data;
(void)panel;
// The panel is already hidden; update application state here.
}
// Open a dropdown under a button on the toolbar's page.
void OpenDropdown(ULDOMElement button) {
ULDOMRect box = ulDOMElementGetBoundingClientRect(button);
ULForegroundPanelDesc desc = {0};
desc.struct_size = sizeof(ULForegroundPanelDesc);
desc.width = ulLayoutSizePx(240);
desc.height = ulLayoutSizePx(180);
desc.placement.kind = kULAnchorKind_Below;
desc.placement.target = toolbar;
desc.placement.rect = (ULLayoutRect){box.x, box.y, box.width, box.height};
desc.placement.fit = kULAnchorFit_Flip;
desc.dismiss = kULDismiss_Auto;
desc.focus = kULFocusPolicy_Grab;
ULForeground foreground = ulWindowGetForeground(main_window);
dropdown = ulForegroundAddPanel(foreground, &desc, NULL);
ulDestroyForeground(foreground);
ulPanelSetDismissCallback(dropdown, OnDropdownDismissed, NULL, NULL);
}
Keep these points in mind when using floating panels and popups:
- Zero-initialize
ULAnchorDesc. Setkindand fill only the fields that placement style requires. An all-zero anchor places the panel at the window origin. - Pass an element's bounding rect directly. Its coordinates match the target panel's logical pixels. For DOM measurements, see DOM Access in C.
- Floating panels clip to the window. Use
ulCreatePopupWindow()for menus that extend outside the window. - Popups return an owned
ULWindowhandle. A popup closes automatically when its owner closes, but you must still callulDestroyWindow()on the handle when finished. For popup behavior and window flags, see Popups and Dialogs.
Posting Tasks and Timers
To dispatch work to the main thread from any thread, pass a callback and payload to ulAppPostTask():
#include <AppCore/CAPI.h>
#include <stdlib.h>
#include <string.h>
void ShowSave(const char* text);
static ULApp app = NULL;
static void ShowSaveTask(void* user_data) {
ShowSave(user_data);
}
// Called on a worker thread once the save file is read.
void OnSaveLoaded(const char* text) {
char* copy = malloc(strlen(text) + 1);
strcpy(copy, text);
// free() runs once the task has run on the main thread.
ulAppPostTask(app, ShowSaveTask, copy, free);
}
Here is how tasks and timers work:
- Tasks run on the next update. Calling
ulAppPostTask()wakes the main loop immediately— the task runs on the next update without waiting for a timer tick. - Ownership and cleanup match the renderer. Task callbacks follow the same rules as
ulRendererPostTask(). For details on thread dispatch and memory management, see Renderer in C. - Timers schedule recurring main-thread work. Call
ulAppSetInterval()with an interval in milliseconds and a callback. Like task posting, you can safely call it from any thread. - Timers repeat until destroyed.
ulAppSetInterval()returns an ownedULAppTimerhandle. CallulDestroyAppTimer()on the main thread to cancel the timer and free its user data.
Features Without C Equivalents
A few C++ convenience methods and DSL builders have no direct equivalents in C.
| C++ Feature | C Alternative |
|---|---|
Window::BuildLayout() and builders |
Add nodes one call at a time with ulContainerAddRow() or ulContainerAddPanel(), and configure the root using ulWindowConfigureLayout(). |
After() insertion position |
Insert before an anchor using insert_before, or pass NULL to append. |
LayoutNode::next_sibling(), previous_sibling(), and Container::children() |
Walk sibling nodes by index using ulLayoutNodeGetParent(), ulLayoutNodeGetIndex(), and ulContainerGetChildAt(). |
App::instance() |
Store your ULApp handle in application state. |
App::is_profiler_active() and App::profiler_trace_path() |
Profiler inspection methods are unavailable in C. |