Guppy

Guppy is an Elixir UI framework for native desktop windows built on GPUI. Your Elixir process owns state, renders it to a small IR tree, and a Rustler NIF renders that tree through GPUI.

defmodule CounterWindow do
use Guppy.Window
def mount(_arg, window) do
{:ok,
window
|> put_window_opts(
window_bounds: [width: 320, height: 220],
titlebar: [title: "Counter"]
)
|> assign(:count, 0)}
end
def render(window) do
~GUI"""
<div class="flex flex-col items-center justify-center gap-4 w-full h-full p-5 bg-[#f5f5f7] text-[#1d1d1f]">
<text id="count_label" class="text-3xl font-semibold">{@count}</text>
<button id="increment_button" click="increment"
class="px-3 py-1 rounded-md border-1 border-[#007aff] bg-[#007aff] text-sm text-[#ffffff]"
hover_class="bg-[#0070e8]">
Increment
</button>
</div>
"""
end
def handle_event("increment", _event_data, window) do
{:noreply, assign(window, :count, window.assigns.count + 1)}
end
end
{:ok, _} = Application.ensure_all_started(:guppy)
{:ok, pid} = CounterWindow.start_link(:ok)
Process.monitor(pid)
receive do
{:DOWN, _ref, :process, ^pid, _reason} ->
:ok
end

Status

Guppy is unreleased and macOS-first today. It can open native GPUI windows, render full trees, keep retained native state such as focus/scroll/text-input state by stable node identity, and route native events back to BEAM processes.

What is this? (Human Authored Section)

The goal of guppy was to see if I could use an LLM (codex 5.3->5.5 specifically) to create this complex nif for GPUI. I wondered if given the OTP source code, the zed/gpui source code and specific direction to follow the architectue of the OTP Wx modules would an LLM be able to take on this sort of large, monotonous task.

To my surprise it was able to, kinda, the history of this project is almost 100% AI commits and it actually kind of nicely documents my experices using these tools in a sort of "hands off, let the ai code" way. The first version of this code strictly worked, but it was kinda sloppy and slow and hand-rolled its own rustler(lol). Over time we've gotten closer and closer thanks to some free tokens from friends.

To be clear the AI left to its own devices would have gotten trapped in a slop-pit and never recovered. Multiple iterations of my course correcting and specificying a better api were absolutely required. Its still got some slop but I believe with enough time, patience, and tokens I will be able to get us really close to where I want to be. This is totally usable for local applications with native rendering, if you find issues please submit a PR or issues, very open to AI changes so long as its within reason.

I am not a rust expert by any means and it shows, I am slowly building up the correct understanding and vocabulary to get us closer, but any rust help would be greatly appreciated!

Installation

Add guppy to your application's dependencies:

def deps do
[
{:guppy, "~> 0.1.0"}
]
end

Platform support today is macOS on Apple Silicon only. A precompiled native artifact is published for aarch64-apple-darwin; building from source instead requires a working Rust toolchain. Adding Guppy to a Linux or Windows project will compile the native crate for a long time and then report native_gui_status as unsupported at runtime — wait for those targets before depending on Guppy there.

Run your app in prod mode {: .warning}

Debug native builds are dramatically slower than release builds — laggy enough to make interactive apps feel broken. Run anything you intend to interact with as MIX_ENV=prod mix run ..., or keep dev mode and force a release-mode native build with GUPPY_NATIVE_RELEASE=1 mix compile --force.

Quick start

Run a small example from a repo checkout:

mix run examples/hello_world.exs

For interactive demos, especially scroll-heavy examples, use an optimized native build — the difference is not subtle:

MIX_ENV=prod mix run examples/super_demo.exs

Run tests:

mix test

Examples

MIX_ENV=prod mix run examples/super_demo.exs

Broad tour of the bridge: multiple node kinds, multiple windows, scrolling, focus, pointer/keyboard events, actions, shortcuts, drag/drop, and owner cleanup.

MIX_ENV=prod mix run examples/kanban_todo.exs

Best app-style example of use Guppy.Window, assigns, handle_event/3, render/1, ~GUI, and local function components.

MIX_ENV=prod mix run examples/stress_test.exs

To show a very high-churn stress test of the UI.

mix run examples/list_row_controls.exs

Focused generic list example with row-local button, checkbox, and radio controls plus structured list_id / row_id / control_id events.

mix run examples/menu_demo.exs

App-level menu example with callback actions routed to the installing window process and Edit menu items wired to focused text input actions.

mix run examples/multi_window_app.exs

Guppy.App example with app-owned window supervision, stylesheet/theme resources, commands, menus, keymap data, a secondary window, and the built-in command-palette overlay.

mix run examples/overlay_demo.exs

Overlay hardening example with form-like select keyboard behavior, popover positioning/close semantics, element-local context menus, and nested non-overlay panels.

mix run examples/data_table_tree.exs

Semantic data-table/tree example with Elixir-owned expansion, selection, and sorting over native virtual rows.

mix run examples/priority3_focus_keyboard.exs

Comprehensive Priority 3 example for shortcut priority, virtual-widget roving focus, keyboard context menus, data-table resize/reorder/pinned columns, tree navigation, focus-visible affordances, and the current accessibility/semantics boundary.

mix run examples/canvas_pattern.exs

Data-only canvas example with ordered rect/rounded-rect draw commands, GPUI slash-pattern painting, and a coarse canvas click callback. This will likely be added to Easel

mix run examples/hello_world.exs

Shortest bring-up smoke test.

mix run examples/counter.exs

Smallest interactive example: one window process, one assign, one click handler.

mix run examples/click_counter.exs

Click-counter variant with a second action and derived label state.

mix run examples/text_clicks.exs

Clickable text nodes routing distinct callbacks through one window process.

mix run examples/style_gallery.exs

Style showcase across class-driven layout, color, border, shadow, and typography ops.

mix run examples/markdownview.exs

Guppy.Markdown example rendering a Markdown subset to IR with heading anchor scrolling.

Supported UI surface

Native node kinds:

Template tags:

Native event coverage includes click, close, hover, focus/blur, key down/up, shortcut actions, app-menu callback actions, context menu, drag/drop, mouse down/up/move, scroll wheel, checkbox/radio/select changes, uniform-list item click/context-menu callbacks, canvas clicks, popover callbacks, text input/textarea changes, focus/blur, context menus, and explicit shortcut actions, app lifecycle events (app_activated, app_deactivated), and window lifecycle events (window_focused, window_blurred, window_moved, window_resized, window_close_requested, window_closed). Tab and Shift-Tab traverse retained GPUI tab stops; nodes with context_menu handlers can also invoke that event from the keyboard with Shift-F10 or the context-menu key.

Guppy.ContextMenu.render/2 renders validated item data into ordinary IR buttons/dividers for element-local menus. Pair it with a node's context_menu event and keep open/close/selection state in the owning process. Guppy.App.open_context_menu/3 returns focus to the source app window by default when opened from an app-supervised window; pass return_focus_to: window_id to choose a specific app window.

Popovers support optional anchor corner, anchor position/offset, local/window anchor positioning, snap-fit mode, snap margin, Escape/outside-click close behavior, and deferred-layer priority. Select list overlays support keyboard navigation/typeahead plus anchor offset/fit/margin controls. See docs/overlays.md for lifecycle, positioning, focus-return, and nested-overlay semantics, docs/focus-keyboard.md for the current focus scopes and roving-focus model, and docs/accessibility.md for the current GPUI accessibility/semantics audit.

uniform_list items and list rows with :click/:context_menu callbacks are keyboard-focusable: Up/Down/Home/End moves between items/rows, Enter/Space activate :click, and Shift-F10/context-menu key invokes :context_menu. Keyboard-focused retained rows/items get a default focus-visible border while focus-visible mode is active. list rows also support static/layout children (text, spacer, and nested static div) plus row-local button, checkbox, and radio controls with explicit control ids. Row-control events include list_id, row_id, and control_id; Elixir remains the source of truth for checked/selected values.

data_table and tree are semantic virtualized primitives for Elixir-owned table selection/sort state and tree selection/expansion state. Data-table columns support :auto, {:px, value}, and weighted {:fr, positive_integer} widths, plus pinned: true to keep pinned columns before unpinned columns while preserving order within each group. The data-table header is rendered outside the virtualized body list so it stays pinned while body rows scroll. Data-table events include table_id, row_id, and/or column_id, including optional row/cell context-menu callbacks, column_reorder header callbacks with target_column_id/direction, and column_resize header callbacks with width_delta; sortable headers, reorderable/resizable headers, clickable rows/cells, and row/cell context menus are keyboard-actionable when callbacks are present, and table headers/body rows/cells support arrow-key focus movement including Left/Right/Home/End between headers, Alt-Left/Alt-Right and pointer Alt-drag column reorder callbacks, Shift-Left/Shift-Right and pointer-drag column resize callbacks, Home/End across body rows/cells, Right from a row into its first cell, Left from a first cell back to its row, Down from a sortable header into its column, and Up back to that header. Tree events include tree_id and item_id, including optional row :context_menu callbacks for command-backed context menus; tree rows with select/toggle/context-menu callbacks are keyboard-actionable for Up/Down/Home/End row focus movement, Left focus return to a parent row, Right focus entry into the first child row, Enter selection, Space/Left/Right disclosure toggles, and keyboard context-menu invocation. Keyboard-focused retained headers/rows/cells/items get a default focus-visible border while focus-visible mode is active. selected_row_id, selected_cell, and selected_id are semantic state only; native rendering does not add default selection highlights, so apply explicit row/cell/item styles from Elixir when visual selection is needed. First-pass table cells intentionally support static text, spacer, and nested static div content.

canvas is a data-only drawing primitive for bounded custom painting. It supports ordered :rect, :rounded_rect, and :pattern_rect commands with existing named/hex color validation, unit slash-pattern parameters, wrapper style/viewport sizing, and coarse optional :click / :context_menu callbacks. Elixir code does not run during native paint; path/text/image canvas commands and per-command hit testing remain deferred.

Guppy.set_menus/1 installs app/runtime menus for the calling process. Guppy.set_dock_menu/1 installs the Dock/app-icon menu from the same menu item shape. Custom app-menu actions use %{id:, label:, callback:} and arrive as {:guppy_menu_event, %{type: :menu_action, id: id, callback: callback}}; Dock menu actions arrive with type: :dock_menu_action. Edit menu items can use %{id:, label:, os_action: :cut | :copy | :paste | :select_all} to target focused native text inputs. macOS Services can be included as %{label: "Services", system_menu: :services}. Call Guppy.set_menus([]) / Guppy.set_dock_menu([]) to clear menus; menus are also cleared when the installing process exits.

Guppy.set_app_badge/1 sets a process-owned app/Dock badge label on supported platforms (macOS today); pass nil to clear it. The badge is cleared automatically when the installing process exits. App coordinators can also own the badge via :app_badge config and Guppy.App.set_app_badge/2, which reinstall after runtime-server restart. GPUI 0.2.2 does not expose desktop notification APIs, so notifications remain deferred.

Guppy.open_file_dialog/0, Guppy.choose_directory_dialog/0, and Guppy.save_file_dialog/0 provide narrow platform file dialogs through the native runtime. Open/choose support :multiple, :prompt, :directory, and extension allow-list :filters; save supports :directory, :default_name, and :filters; all support :owner_view_id for caller-owned window liveness checks. Dialog cancellation returns {:ok, nil}. GPUI 0.2.2 does not expose sheet-style owner-window APIs, so :owner_view_id is a logical association rather than a native modal sheet parent.

Guppy.read_clipboard_text/0 and Guppy.write_clipboard_text/1 provide narrow text clipboard access through the native runtime. Reads return {:ok, text} or {:ok, nil} when no text is available; writes accept binaries only.

app_activated/app_deactivated are sent to the claimed app owner as {:guppy_app_event, event} when native window activation changes make the app active/inactive; use Guppy.App modules can handle them as handle_event("app_activated", data, state) / handle_event("app_deactivated", data, state). window_focused/window_blurred are emitted from GPUI window activation changes, and Guppy.focus_window/1 activates a caller-owned native window. window_moved/window_resized include x, y, width, and height in logical pixels when GPUI reports bounds changes. window_close_requested is informational: native close requests are not vetoable from Elixir today, and a successful close is followed by window_closed.

App processes

Guppy.App is the optional app-level coordinator for larger programs. A Guppy.App process owns app-global resources such as configured windows, themes, stylesheets, commands, keymaps, menus, and packaging metadata. Guppy.Window remains the rendering/process abstraction for each window.

defmodule MyApp do
use Guppy.App,
windows: [%{id: "main", module: MyApp.MainWindow}],
commands: [%{id: "new_file", label: "New File"}],
keymap: [%{key: "cmd-n", command: "new_file"}],
menus: [%{label: "File", items: [%{id: "new_file", label: "New", callback: "new_file"}]}],
stylesheet: %{classes: %{"card" => %{style: "p-4 rounded-lg", hover_style: "bg-blue"}}},
package: %{bundle_id: "dev.example.my_app"},
exit_on_last_window_closed: true
def handle_command("new_file", payload, state) do
# menu/keymap/palette dispatch lands here asynchronously
{:noreply, state}
end
end
{:ok, _supervisor} = MyApp.start_link([])

Window ids are strings. The first configured window starts by default unless start: false; additional windows start when Guppy.App.open_window/3 is called or when their spec sets start: true. App windows run under an app-owned DynamicSupervisor; the coordinator registry exposes Guppy.App.windows/1, Guppy.App.window_pid/2, Guppy.App.open_window/3, Guppy.App.focus_window/2, and Guppy.App.close_window/2. Set exit_on_last_window_closed: true for example-style apps that should terminate their app supervisor when the last app-owned window closes.

App config is plain Elixir data validated into structs. Use child-spec/start options and optional init/1 for final config assembly. App-owned menus and Dock menus dispatch callbacks to handle_command/3; callback items that reference registered commands inherit the command's disabled state when native menus are installed. App-owned badge labels use :app_badge config or Guppy.App.set_app_badge/2. App-supervised windows can route native :action shortcut events to handle_command/3 by using Guppy.App.command_bindings/1 on a focusable root element (or Guppy.App.command_callback/0 as the IR action callback). Keymap entries are checked in declaration order, skipping disabled commands. Guppy.App.open_command_palette/1 opens a minimal built-in command-palette overlay backed by the same command registry; Guppy.App.open_context_menu/3 opens a transient popup for command-backed context menu items. Use Guppy.App.set_command_enabled/3 to toggle a command without replacing the full registry; disabled commands stay visible in the palette/context/Dock menus but do not dispatch. Standalone Guppy.Window modules and low-level Guppy.open_window/1..3 continue to work without an app.

Themes

Themes are app-scoped Elixir data that compile semantic color/style tokens to primitive IR style tuples before native render. Guppy.App.Theme.default(:dark) and Guppy.App.Theme.default(:light) provide built-in defaults, Guppy.App.Theme.refine/2 creates explicit overrides from an existing theme, and apps can register Guppy.App.ThemeFamily values and activate a theme by id.

Useful helpers:

See docs/theme.md for the Zed/GPUI audit notes and current Guppy theme boundaries.

Window processes

Guppy.Window modules can be supervised directly via their generated child_spec/1 and use these callbacks:

Define handle_info(message, window) without @impl Guppy.Window when the process should handle ordinary messages or timers.

Missing optional handlers and unmatched handler clauses are treated as no-op handlers that skip rerendering.

Helpers imported by use Guppy.Window include:

Guppy.Window monitors the Guppy runtime server. If the supervised server restarts, the window process reopens from its current assigns. Lower-level callers using Guppy.open_window/1..3 own their own recovery policy.

Preferred Guppy.Window modules may handle "window_focused", "window_blurred", "window_moved", and "window_resized" in handle_event/3; window_closed remains lifecycle-driving and stops the window process. window_close_requested remains informational for lower-level owners and is not exposed as a veto callback by Guppy.Window.

Templates and components

Guppy.Component provides ~GUI templates and first-pass function components. Inside a render(assigns) or render(window) function, @name reads from the assigns map or window assigns.

Dotted local tags call a function in the same module:

<.stat_badge stat={stat} />

Remote tags call render/1 on the referenced module:

<Guppy.UI.Badge id="release_badge" label="Beta ready" />

Nested content is passed as @children.

Components can declare props with prop/3 and prop/4 for required props, defaults, unknown prop rejection, and simple type validation.

Markdown

Guppy.Markdown is a remote component for a small Markdown subset (headings, paragraphs, unordered/ordered lists, bold/italic/code runs, and link-ish inline runs). It is also a fun example of how this can work! It intentionally renders to Guppy IR in Elixir instead of depending on Zed's markdown crates, which are not part of Guppy's active gpui = 0.2.2 dependency surface.

Window options

Configure native GPUI window behavior during mount/2:

def mount(_arg, window) do
{:ok,
window
|> put_window_opts(
window_bounds: [width: 960, height: 760],
window_min_size: [width: 760, height: 560],
titlebar: [title: "Style gallery"],
focus: true,
show: true,
is_resizable: true,
is_movable: true,
is_minimizable: true,
kind: :normal,
window_background: :opaque,
window_decorations: :server
)}
end

Supported options match the gpui = 0.2.2 surface Guppy uses:

Styling

Styles are ordered lists of style ops, these follow the tailwind inspired styles of gpui-rs. The style-surface parity pass is moving new surface area to property-first canonical tuples generated from data/gpui_style_catalog.json.

style: [:flex, :flex_col, Guppy.Style.p(4), {:padding, :y, {:rem, 0.25}}, {:bg, :gray}, {:bg, :blue}]

Box-spacing, size/aspect ratio, position, display, visibility, overflow visible/clip/hidden/scroll, scroll behavior booleans, debug flags, cursor, border, radius, named/hex/gradient/pattern color including text background, opacity, scrollbar width, named/arbitrary shadow, flex grow/shrink/alignment/self-alignment/basis, core text/font size/line-height/weight/family/fallbacks/features/decoration/strikethrough, line clamp, grid count/span/full-span/line placement, and image-only object-fit/grayscale helpers/classes already use canonical tuple ops or image option tuples: Guppy.Style.py(1) and template class="py-1" both produce {:padding, :y, {:rem, 0.25}}; Guppy.Style.mx(:auto) produces {:margin, :x, :auto}; Guppy.Style.gap_x("px") produces {:gap, :x, {:px, 1}}; Guppy.Style.w("full") produces {:width, {:fraction, 1}}; Guppy.Style.aspect_ratio(1.5) produces {:aspect_ratio, 1.5}; Guppy.Style.top(-2) produces {:inset, :top, {:rem, -0.5}}; class="hidden invisible overflow-visible overflow-x-clip overflow-x-scroll scroll-concurrent scroll-axis-restricted debug debug-below cursor-pointer border-1 rounded-sm bg-[#0f172a] bg-pattern-slash-[red,1,4] opacity-50 scrollbar-w-[12px] text-white text-bg-yellow shadow-md shadow-[red,0,2,4,-1] flex-col basis-1/2 grow-[2] shrink-[0.5] aspect-video items-stretch self-stretch justify-evenly text-xl text-[14px] leading-[18px] font-bold font-[650] font-family-[Monaco] font-fallbacks-[Monaco,Menlo] font-features-[calt=0,kern=1] font-ligatures-none underline decoration-red decoration-wavy line-through strikethrough-red strikethrough-2 line-clamp-2 grid-cols-3 col-start-2 col-span-full" produces display, visibility, overflow, scroll behavior, debug flags, cursor, border-width, border-radius, color, pattern background, text-background, opacity, scrollbar-width, named/arbitrary shadow, flex grow/shrink/alignment/self-alignment/basis, aspect ratio, text/font-size/line-height/weight/family/fallbacks/features/decoration/strikethrough, line-clamp, and grid tuple ops. On <image>, class="object-cover grayscale" maps to image object_fit and grayscale options. Later ops win over earlier ones, and order is preserved through the bridge. In ~GUI templates, class may be a string or a dynamic list of strings; nil and false list entries are ignored. Raw style values must be canonical style lists (for example style={Guppy.Style.py(1)} or style={[Guppy.Style.py(1)]}); use class for class-token strings.

Stateful style lists are explicit:

Text nodes and div-like nodes support the current style-token surface where applicable. Grid style ops include :grid, {:grid_cols, n}, {:grid_rows, n}, {:col_span, n}, :col_span_full, {:row_span, n}, and :row_span_full.

Div-like nodes support two-stop background linear gradients:

style: [
{:bg_linear_gradient, [angle: 135, from: {"#0f172a", 0.0}, to: {"#2563eb", 1.0}]}
]

The equivalent template class form is bg-linear-gradient-[135,#0f172a:0,#2563eb:1].

Div-like nodes can also opt into a native opacity animation with animation: %{id: "stable_id", duration_ms: 500, repeat: true, from: 0.4, to: 1.0}.

Distribution

Guppy is source-build first. Rustler builds and copies the NIF into priv/native/ during normal Mix compilation. The macOS source-build path, clean-install/load smoke, and generated package smoke are covered by CI.

Current source-build support:

TargetStatus
aarch64-apple-darwinsupported / primary development target
x86_64-apple-darwinplanned, needs CI confirmation
aarch64-unknown-linux-gnuplanned, needs GPUI runtime validation
x86_64-unknown-linux-gnuplanned, needs GPUI runtime validation
x86_64-pc-windows-msvcplanned, needs GPUI/runtime validation

rustler_precompiled serves a checksummed aarch64-apple-darwin artifact by default; broader targets stay out of the precompiled matrix until they are validated. Set GUPPY_NATIVE_FORCE_BUILD=1 to build the crate from source instead (the repo itself always source-builds via mise.toml). See docs/distribution.md.