Packages

Declarative macOS UI toolkit built on Elixir, GLFW, and Apple Metal with XML-driven immediate-mode rendering.

Current section

Files

Jump to
mac_ui README.md
Raw

README.md

# 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](https://www.glfw.org/) (`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**`Image` renders PNG textures via Metal (`MTKTextureLoader`), auto-detecting size from the PNG header.
- **3D support**`View3D` / `Cube3D` wireframe cubes with real-time rotation.
- **Event-driven** – GenServer architecture: `MacUi.Window` owns the engine port and input; `MacUi.Controller` manages 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](https://www.glfw.org/) (`brew install glfw`)
- Elixir ~> 1.14
## Installation
The package can be installed by adding `mac_ui` to your list of dependencies in
`mix.exs`:
```elixir
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:
```elixir
children = [
MacUi.Application
]
Supervisor.start_link(children, strategy: :one_for_one)
```
## Usage
Load a UI XML file, then subscribe the calling process to events:
```elixir
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
```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 `#RRGGBB` and `#RRGGBBAA`.
- `VStack`/`HStack` render their children in a flow; use `gap`/`padding` and a
fixed `width`/`height` on the stack so `Spacer` absorbs the leftover space.
- `Image` resolves `src` relative to `priv/` (or an absolute path).
- Multiple children of `Frame`, `ScrollView`, `Tab`, `Panel`, and `Modal`
overlap at the same origin — wrap them in a `VStack`/`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:
```elixir
MacUi.subscribe()
```
`MacUi.Controller` already does this. Events arrive as `{:mac_ui_event, event}`:
```elixir
# 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:
```elixir
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/3` and `set_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 to `priv/default.metallib`).
## Testing
```bash
mix test
```
## Notes
- The engine renders to a `CAMetalLayer`; shaders are compiled at build time
with `xcrun -sdk macosx metal`. The layer `contentsScale` and `drawableSize`
track the window so content stays crisp on retina and follows resize.
- Keycodes are remapped in `MacUi.Window` (e.g. backspace, space) for
`TextField` editing.
- Interaction state is split: *semantic* widget values are controller-driven
(`:change` events + `set_attr`), while *transient* state (scroll offset,
hover, drag, open popups, context menu, tooltip) is owned by
`MacUi.Window`.