MacUi
A declarative macOS UI toolkit built on Elixir, GLFW, and Apple Metal.
UI is defined as XML, parsed into an AST, and re-rendered every frame by a
C++ engine process (mac_ui_engine) that talks to the Elixir runtime over
stdio. The engine draws rects, lines, text, PNG images, and 3D wireframes with
Metal, and reports mouse/keyboard input back as JSON events.
Platform: macOS only. Requires a macOS 15+ SDK (Metal shader toolchain) and
GLFW (brew install glfw). One window per VM.
Features
- XML-driven UI – Describe windows, layouts, and widgets in XML (parsed with
xmerl). - Immediate-mode rendering – The AST is re-rendered each frame (~60 fps) through a continuous render loop.
- Layout primitives –
Window,Frame,VStack,HStack,Spacer. - Widgets –
Text,Button,Rect,Line,TextField(focus, text editing, blinking cursor),Checkbox,Toggle,Slider,ProgressBar,Dropdown,Tabs,List,Table. - Containers –
ScrollView(scissor-clipped with scrollbar),SplitView(draggable divider),Modal,ContextMenu,Tooltip. - Images –
Imagerenders PNG textures via Metal (MTKTextureLoader), auto-detecting size from the PNG header. - 3D support –
View3D/Cube3Dwireframe cubes with real-time rotation. - Event-driven – GenServer architecture:
MacUi.Windowowns the engine port and input;MacUi.Controllermanages application state and reacts to UI events. - Live updates – Update element text or attributes by ID without reloading the whole UI.
- Resizable – The window opens at the
<Window>size and tracks drag-resizes; the drawable and scissor regions scale with the framebuffer (including retina).
Requirements
- macOS with Xcode Command Line Tools (for the Metal toolchain:
metal,metallib) - GLFW (
brew install glfw) - Elixir ~> 1.14
Installation
The package can be installed by adding mac_ui to your list of dependencies in
mix.exs:
def deps do
[
{:mac_ui, "~> 0.1.0"}
]
end
mix deps.get && mix compile builds the native engine (via elixir_make) and
compiles the Metal shader library.
Opt-in startup
mac_ui does not start automatically. Add MacUi.Application to your
supervision tree to launch the engine (a GLFW window) and the event controller:
children = [
MacUi.Application
]
Supervisor.start_link(children, strategy: :one_for_one)
Usage
Load a UI XML file, then subscribe the calling process to events:
MacUi.load_file(Application.app_dir(:mac_ui, "priv/sample_ui.xml"))
MacUi.subscribe()
The bundled sample_ui.xml shows the full component set: a header bar with a
search field, three aligned panels (settings controls, tabs with a list, a
selectable table), a scrollable log area, a draggable split view, a right-click
context menu, a PNG image, and a modal dialog.
Defining UI in XML
<Window width="1024" height="600" bg="#1E1E1E">
<VStack padding="20" gap="15">
<Text size="24">Hello, MacUi!</Text>
<HStack gap="15">
<Button id="btn_save" on_click="save_document" width="120" height="35" bg="#007ACC">Save</Button>
<TextField id="input_field" w="240" h="35" placeholder="Type here..." />
<Spacer />
</HStack>
<Image src="logo.png" width="64" height="64" />
<View3D>
<Cube3D size="120" color="#FFFFFF" rotate_x="0.45" rotate_y="0.55" />
</View3D>
</VStack>
</Window>
Notes:
- Colors accept
#RRGGBBand#RRGGBBAA. VStack/HStackrender their children in a flow; usegap/paddingand a fixedwidth/heighton the stack soSpacerabsorbs the leftover space.Imageresolvessrcrelative topriv/(or an absolute path).- Multiple children of
Frame,ScrollView,Tab,Panel, andModaloverlap at the same origin — wrap them in aVStack/HStack.
Widget reference
| Component | Key attributes |
|---|---|
Text | size (line height; glyphs are 8×16 px) |
Button | on_click, bg, width, height |
TextField | w, h, placeholder, value |
Checkbox | checked, size, color, tooltip |
Toggle | on, w, h, track_on, track_off |
Slider | value, min, max, color, track |
ProgressBar | value, max, color, track |
Dropdown | id, value, width, height; <Option id value> children |
Tabs | id, active (matches tab title); <Tab id title> children |
List | id, selected, width; <Item id> children |
Table | column_width, selected; Column/Row/Cell children |
ScrollView | id, width, height (single child, typically a VStack) |
SplitView | id, orientation (horizontal/vertical), width, height; two Panel children |
Modal | id, visible, width, height |
ContextMenu | id, width, height; <MenuItem id action> children (opens on right-click) |
Image | src, x, y, width, height, color |
Handling events
Subscribe to UI events from any process:
MacUi.subscribe()
MacUi.Controller already does this. Events arrive as {:mac_ui_event, event}:
# Button / context-menu item clicked
{:mac_ui_event, %{event: :click, id: "btn_save", action: "save_document", x: ..., y: ...}}
# Interactive widget changed
{:mac_ui_event, %{event: :change, id: "sld_vol", type: "Slider", value: 55.0}}
# Modal dismissed by clicking the dimmed area
{:mac_ui_event, %{event: :close, id: "modal_info"}}
Controller-driven widget state
Interactive widgets (Checkbox, Toggle, Slider, Dropdown, Tabs,
List, Table) are controller-driven: clicking them emits a :change
event instead of mutating the AST directly. MacUi.Controller echoes the new
value back so the rendered UI stays in sync. To drive the state yourself,
subscribe to :change events and echo the value back:
def handle_info({:mac_ui_event, %{event: :change, id: id, type: "Slider", value: v}}, state) do
MacUi.set_attr(id, "value", to_string(v))
{:noreply, state}
end
Tooltip text appears after hovering an interactive widget for ~400 ms.
Public API
MacUi.load_file/1– read and render a UI XML file.MacUi.render_xml/1– render an XML string directly.MacUi.update_element_text/2– update text of an element by ID.MacUi.set_attr/3andset_attrs/2– update one or more attributes of an element by ID.MacUi.subscribe/0– receive UI events as messages.MacUi.Window.clear/0,draw_pixel/5,draw_line/7,draw_text/3– low-level draw commands.MacUi.Components.render_node/2– render a single component node (used by tests).
Architecture
┌─────────────────────────── Elixir VM ───────────────────────────┐
│ MacUi.Controller (GenServer) ── handles events, owns state │
│ │ subscribe / set_attr / update_element_text │
│ MacUi.Window (GenServer) ── xmerl parse → AST → render loop │
│ │ Port.command (newline-delimited draw commands) │
├────────┴────────────────────────────────────────────────────────┤
│ priv/mac_ui_engine (C++/Obj-C++) │
│ GLFW window + Metal renderer, command parser, input → JSON │
└─────────────────────────────────────────────────────────────────┘
The Elixir side renders the AST into draw commands every tick and pipes them to
the engine over stdio. The engine parses clear, window:w,h,
clip:x,y,w,h, clip_off, line:, pixel:, rect:, stroke_rect:,
text:, and image: commands into Metal draw calls. It reports input as JSON
lines (click, key, move, scroll, resize).
lib/mac_ui/window.ex– engine port lifecycle, XML parsing, AST walking, input event handling, FPS-tracked render loop.lib/mac_ui/controller.ex– subscribes to events and echoes controller-driven widget state.lib/mac_ui/components/– one module per widget plus the layout containers.c_src/main.mm– native engine: GLFW window, Metal renderer, command parser.c_src/Shaders.metal– Metal vertex/fragment shaders (compiled topriv/default.metallib).
Testing
mix test
Notes
- The engine renders to a
CAMetalLayer; shaders are compiled at build time withxcrun -sdk macosx metal. The layercontentsScaleanddrawableSizetrack the window so content stays crisp on retina and follows resize. - Keycodes are remapped in
MacUi.Window(e.g. backspace, space) forTextFieldediting. - Interaction state is split: semantic widget values are controller-driven
(
:changeevents +set_attr), while transient state (scroll offset, hover, drag, open popups, context menu, tooltip) is owned byMacUi.Window.