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

Requirements

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:

Widget reference

ComponentKey attributes
Textsize (line height; glyphs are 8×16 px)
Buttonon_click, bg, width, height
TextFieldw, h, placeholder, value
Checkboxchecked, size, color, tooltip
Toggleon, w, h, track_on, track_off
Slidervalue, min, max, color, track
ProgressBarvalue, max, color, track
Dropdownid, value, width, height; <Option id value> children
Tabsid, active (matches tab title); <Tab id title> children
Listid, selected, width; <Item id> children
Tablecolumn_width, selected; Column/Row/Cell children
ScrollViewid, width, height (single child, typically a VStack)
SplitViewid, orientation (horizontal/vertical), width, height; two Panel children
Modalid, visible, width, height
ContextMenuid, width, height; <MenuItem id action> children (opens on right-click)
Imagesrc, 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

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).

Testing

mix test

Notes