Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,36 @@ jobs:
- name: Test
run: zig build test -Dbackend=x11

check-macos:
strategy:
fail-fast: false
matrix:
include:
- runner: macos-14
arch: aarch64
- runner: macos-15-intel
arch: x86_64
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4

- name: Install Zig 0.16
run: |
curl -fL "https://ziglang.org/download/0.16.0/zig-${{ matrix.arch }}-macos-0.16.0.tar.xz" | tar -xJ
echo "$PWD/zig-${{ matrix.arch }}-macos-0.16.0" >> "$GITHUB_PATH"

- name: Build (native macOS defaults)
run: zig build -Doptimize=ReleaseFast

- name: Test (including PTY and macOS input)
run: zig build test -Dmacos_gui_tests=true

- name: Package app
run: |
sh tools/package-macos.sh
plutil -lint zig-out/zt.app/Contents/Info.plist
codesign --verify --deep --strict zig-out/zt.app

security-audit:
runs-on: ubuntu-24.04
steps:
Expand Down
53 changes: 50 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,41 @@ jobs:
name: zt-aarch64-linux-wayland
path: zig-out/bin/zt

build-macos:
strategy:
fail-fast: false
matrix:
include:
- runner: macos-14
arch: aarch64
- runner: macos-15-intel
arch: x86_64
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4

- name: Install Zig 0.16
run: |
curl -fL "https://ziglang.org/download/0.16.0/zig-${{ matrix.arch }}-macos-0.16.0.tar.xz" | tar -xJ
echo "$PWD/zig-${{ matrix.arch }}-macos-0.16.0" >> "$GITHUB_PATH"

- name: Build and package
run: |
zig build -Doptimize=ReleaseFast -Dstrip=true
sh tools/package-macos.sh
ditto -c -k --keepParent zig-out/zt.app "zt-${{ matrix.arch }}-macos.zip"
cp zig-out/bin/zt "zt-${{ matrix.arch }}-macos.bin"

- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: zt-${{ matrix.arch }}-macos
path: |
zt-${{ matrix.arch }}-macos.bin
zt-${{ matrix.arch }}-macos.zip

release:
needs: [build-x86_64-x11, build-x86_64-wayland, build-aarch64-x11, build-aarch64-wayland]
needs: [build-x86_64-x11, build-x86_64-wayland, build-aarch64-x11, build-aarch64-wayland, build-macos]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
Expand All @@ -168,19 +201,33 @@ jobs:
mv zt-x86_64-linux-wayland/zt zt-x86_64-linux-wayland.bin
mv zt-aarch64-linux-x11/zt zt-aarch64-linux-x11.bin
mv zt-aarch64-linux-wayland/zt zt-aarch64-linux-wayland.bin
mv zt-aarch64-macos/* .
mv zt-x86_64-macos/* .
chmod +x zt-*.bin

- name: Create or update release
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG=${GITHUB_REF#refs/tags/}
# Create release if it doesn't exist; skip if already created manually
gh release create "${TAG}" --title "${TAG}" --generate-notes 2>/dev/null || true
# Keep a new release in draft until all assets have been uploaded.
if ! gh release view "${TAG}" >/dev/null 2>&1; then
gh release create "${TAG}" --verify-tag --draft --title "${TAG}" --generate-notes
fi
NOTES="docs/releases/${TAG}.md"
if [ -f "$NOTES" ]; then
TITLE=$(head -n 1 "$NOTES" | sed 's/^# //')
gh release edit "${TAG}" --title "$TITLE" --notes-file "$NOTES"
fi
# Upload binaries (--clobber overwrites if re-run)
gh release upload "${TAG}" \
zt-x86_64-linux-x11.bin \
zt-x86_64-linux-wayland.bin \
zt-aarch64-linux-x11.bin \
zt-aarch64-linux-wayland.bin \
zt-aarch64-macos.bin \
zt-x86_64-macos.bin \
zt-aarch64-macos.zip \
zt-x86_64-macos.zip \
--clobber
gh release edit "${TAG}" --draft=false --latest
85 changes: 72 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
# zt — minimal terminal emulator in Zig

[![Zig](https://img.shields.io/badge/Zig-0.16+-f7a41d?logo=zig&logoColor=white)](https://ziglang.org)
[![Zig](https://img.shields.io/badge/Zig-0.16.0-f7a41d?logo=zig&logoColor=white)](https://ziglang.org)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![macOS](https://img.shields.io/badge/Platform-macOS_14+-black?logo=apple&logoColor=white)](https://github.com/midasdf/zt/releases/tag/v0.10.0)
[![Linux](https://img.shields.io/badge/Platform-Linux-yellow?logo=linux&logoColor=white)](https://kernel.org)

A small terminal emulator written in Zig. Renders to the Linux framebuffer, X11 (XCB + SHM), Wayland (pure Zig wire protocol, no libwayland), or macOS (Cocoa/AppKit, untested). No GPU required.
A small terminal emulator for **macOS and Linux**, written in Zig. Native Cocoa/AppKit on Mac; framebuffer, X11 (XCB + SHM), and Wayland (pure Zig wire protocol, no libwayland) on Linux. No GPU required.

**New in 0.10.0: native Mac support** for Apple Silicon and Intel on macOS 14+. Download a Finder-launchable `zt.app` from the [0.10.0 release](https://github.com/midasdf/zt/releases/tag/v0.10.0), or [build with Homebrew](#macos-backend).

![Image](https://github.com/user-attachments/assets/01ab9a42-2efe-41f7-b123-e7312dc5b8d7)

Originally built for the [HackberryPi Zero](https://github.com/ZitaoTech/Hackberry-Pi_Zero) (RPi Zero 2W + 720x720 HyperPixel4). Runs on any Linux system.
Originally built for the [HackberryPi Zero](https://github.com/ZitaoTech/Hackberry-Pi_Zero) (RPi Zero 2W + 720x720 HyperPixel4). Now supports macOS as well as Linux.

> **Note:** This is an experimental project. It works well enough for daily use with common CLI tools, but it is not a full-featured terminal. See [Limitations](#limitations) for what's missing.

Expand All @@ -22,7 +25,7 @@ Measured on Intel i5-12450H, 1 CPU core, X11 (:0, hardware GPU), `-Doptimize=Rel

### Rendering

- **Four backends** — framebuffer (no X11/Wayland), XCB + SHM under X11, pure Zig Wayland client (no libwayland), Cocoa/AppKit on macOS (experimental, untested)
- **Four backends** — framebuffer (no X11/Wayland), XCB + SHM under X11, pure Zig Wayland client (no libwayland), Cocoa/AppKit on macOS
- **Pixel scaling** — `-Dscale=2` or `-Dscale=4` for HiDPI. Integer scaling, same font blob
- **Double-buffered SHM** — tear-free rendering on X11 and Wayland
- **Adaptive frame limiter** — reduces FPS under heavy output to avoid wasting CPU
Expand All @@ -34,10 +37,10 @@ Measured on Intel i5-12450H, 1 CPU core, X11 (:0, hardware GPU), `-Doptimize=Rel
- **CJK wide characters** — double-width rendering with boundary repair
- **59,635 glyphs** — UFO bitmap font + Nerd Fonts icons, embedded at compile time
- **XKB keyboard layout** — any X11/Wayland layout (US, JP, DE, FR, etc.)
- **Input method** — XIM under X11, text-input-v3 under Wayland (fcitx5, ibus, etc.)
- **Inline pre-edit (IME composition)** — Wayland `text-input-v3` preedit; composition text is rendered at the cursor position with reverse-video / underline / highlight feedback from the IME
- **Input method** — native AppKit input methods on macOS, XIM under X11, and text-input-v3 under Wayland (fcitx5, ibus, etc.)
- **Inline pre-edit (IME composition)** — macOS AppKit and Wayland `text-input-v3` preedit; composition text is rendered at the cursor position with reverse-video / underline / highlight feedback from the IME
- **OSC 8 hyperlinks** — parsed and stored; click-to-open is not yet implemented
- **OSC 52 clipboard** — copy to system clipboard via xclip/wl-copy (disabled by default for security)
- **OSC 52 clipboard** — copy to system clipboard via pbcopy on macOS or xclip/wl-copy on Linux (disabled by default for security)

### Performance

Expand All @@ -50,7 +53,7 @@ Measured on Intel i5-12450H, 1 CPU core, X11 (:0, hardware GPU), `-Doptimize=Rel

## Build

Requires Zig 0.16+.
Requires Zig 0.16.0. Zig 0.17 changes the language and standard library and is not supported yet.

| | fbdev | X11 | Wayland |
|---|---|---|---|
Expand All @@ -59,6 +62,20 @@ Requires Zig 0.16+.

### Quick Start

On macOS, download `zt-aarch64-macos.zip` for Apple Silicon or
`zt-x86_64-macos.zip` for Intel from the [latest release](https://github.com/midasdf/zt/releases/latest),
extract it, and open `zt.app`. To build from source:

```sh
brew install zig@0.16
export PATH="$(brew --prefix zig@0.16)/bin:$PATH"
zig build -Doptimize=ReleaseFast
sh tools/package-macos.sh
open zig-out/zt.app
```

On Linux:

```sh
# X11
zig build -Dbackend=x11 -Doptimize=ReleaseFast
Expand Down Expand Up @@ -106,8 +123,8 @@ zig build -Dbackend=x11 -Dfont=path/to/myfont.ttf -Doptimize=ReleaseFast
# Cross-compile for aarch64
zig build -Dtarget=aarch64-linux -Doptimize=ReleaseSmall

# macOS (experimental, untested — see note below)
zig build -Dbackend=macos -Dshell=/bin/zsh -Doptimize=ReleaseFast
# macOS (automatically selects Cocoa and /bin/zsh)
zig build -Doptimize=ReleaseFast

# Run tests
zig build test
Expand All @@ -119,9 +136,51 @@ Implements the Wayland wire protocol directly in Zig — no libwayland-client de

Supported protocols: xdg-shell, wl_shm, text-input-v3 (IME), wl_data_device + primary selection (clipboard), xdg-decoration, wp_cursor_shape_manager_v1.

### macOS Backend (Experimental)
### macOS Backend

Uses Cocoa/AppKit and CoreGraphics without X11 or third-party runtime libraries.
Requires macOS 14 or later. The native macOS build selects the Cocoa backend and `/bin/zsh` automatically.
Install Xcode Command Line Tools (`xcode-select --install`) and **Zig 0.16.0**.
Install the pinned compiler with Homebrew:

```sh
brew install zig@0.16
export PATH="$(brew --prefix zig@0.16)/bin:$PATH"
```

```sh
# Build and launch from Terminal
zig build -Doptimize=ReleaseFast
./zig-out/bin/zt

# Optional: larger text with 2x bitmap scaling
zig build -Dscale=2 -Doptimize=ReleaseFast

# Create a Finder-launchable app
sh tools/package-macos.sh
open zig-out/zt.app

# Unit tests, including real PTY I/O
zig build test

# Cocoa integration tests (requires a desktop session)
zig build test -Dmacos_gui_tests=true
```

> **Note:** Developed without access to macOS hardware and never tested on a real Mac. Uses Cocoa/AppKit via `objc_msgSend` from Zig. Bug reports welcome.
`Cmd+C` copies the selection, `Cmd+V` pastes, and `Cmd+Q` / `Cmd+W` close
zt. Mouse selection, wheel scrolling, terminal mouse reporting, and native
input-method composition use the same dispatcher as the Linux backends.
App bundles start the shell in your home directory; CLI launches preserve the
current directory. Option acts as the terminal Alt modifier. IME composition is shown inline,
and the candidate window is positioned at the cursor.

The CI configuration builds and runs tests on Apple Silicon and Intel macOS.
The release workflow produces architecture-specific `.bin` executables and
`.zip` app bundles. App bundles use ad hoc signatures; they are not notarized.
Automated Cocoa tests cover window creation, drawing, text/IME callbacks,
keyboard modifiers, large pastes, resizing, and closing. Physical keyboard,
IME candidate selection, and display changes require manual checks; see
[the macOS QA checklist](docs/macos-qa.md).

## Status

Expand All @@ -130,7 +189,7 @@ Supported protocols: xdg-shell, wl_shm, text-input-v3 (IME), wl_data_device + pr
| fbdev | Stable — used daily on HackberryPi |
| X11 | Stable — primary development target |
| Wayland | Works — IME and basic usage tested |
| macOS | Untested — compiles but never run on real hardware |
| macOS | Supported — Cocoa, Apple Silicon and Intel; macOS 14+ |

## Configuration

Expand Down
50 changes: 40 additions & 10 deletions build.zig
Original file line number Diff line number Diff line change
@@ -1,30 +1,46 @@
const std = @import("std");

pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const target = b.standardTargetOptions(.{
.default_target = if (@import("builtin").os.tag == .macos)
.{ .os_version_min = .{ .semver = .{ .major = 14, .minor = 0, .patch = 0 } } }
else
.{},
});
const optimize = b.standardOptimizeOption(.{});

// Optional sysroot for cross-compilation (-Dsysroot=/path/to/sysroot).
// Expected layout: <sysroot>/include and <sysroot>/lib.
// Linux: <sysroot>/include and <sysroot>/lib; macOS: an Apple SDK.
// Needed because b.addTranslateC does not inherit --search-prefix.
const sysroot_opt = b.option([]const u8, "sysroot", "Sysroot for cross-compile translate-c include/library paths");
const sysroot_opt = b.option([]const u8, "sysroot", "Sysroot for cross-compile translate-c include/library paths") orelse
if (target.result.os.tag == .macos and @import("builtin").os.tag == .macos)
std.mem.trim(u8, b.run(&.{ "xcrun", "--sdk", "macosx", "--show-sdk-path" }), " \r\n\t")
else
null;
const sysroot_inc: ?std.Build.LazyPath = if (sysroot_opt) |s|
.{ .cwd_relative = b.pathJoin(&.{ s, "include" }) }
.{ .cwd_relative = b.pathJoin(&.{ s, if (target.result.os.tag == .macos) "usr/include" else "include" }) }
else
null;
const sysroot_lib: ?std.Build.LazyPath = if (sysroot_opt) |s|
.{ .cwd_relative = b.pathJoin(&.{ s, "lib" }) }
.{ .cwd_relative = b.pathJoin(&.{ s, if (target.result.os.tag == .macos) "usr/lib" else "lib" }) }
else
null;

const backend_opt = b.option([]const u8, "backend", "Rendering backend: fbdev, x11, wayland, or macos") orelse "fbdev";
const backend_opt = b.option([]const u8, "backend", "Rendering backend: fbdev, x11, wayland, or macos") orelse if (target.result.os.tag == .macos) "macos" else "fbdev";
const is_x11 = std.mem.eql(u8, backend_opt, "x11");
const is_wayland = std.mem.eql(u8, backend_opt, "wayland");
const is_macos = std.mem.eql(u8, backend_opt, "macos");
if (!is_x11 and !is_wayland and !is_macos and !std.mem.eql(u8, backend_opt, "fbdev")) {
std.debug.panic("invalid -Dbackend='{s}'; expected fbdev, x11, wayland, or macos", .{backend_opt});
}

if (is_macos != (target.result.os.tag == .macos)) {
@panic("macOS targets require -Dbackend=macos; Linux backends require a Linux target");
}
if (!is_macos and target.result.os.tag != .linux) {
@panic("zt supports Linux and macOS targets");
}

const keymap_opt = b.option([]const u8, "keymap", "Keyboard layout: us or jp (default: us)") orelse "us";
const use_jp_keymap = std.mem.eql(u8, keymap_opt, "jp");
if (!use_jp_keymap and !std.mem.eql(u8, keymap_opt, "us")) {
Expand Down Expand Up @@ -69,21 +85,22 @@ pub fn build(b: *std.Build) void {
}
const scrollback_lines_opt = b.option(u32, "scrollback_lines", "Scrollback rows for main screen (0 disables, default 10000)") orelse 10000;
const alt_screen_wheel_scrollback_opt = b.option(bool, "alt_screen_wheel_scrollback", "Make the mouse wheel scroll the alt-screen scrollback ring instead of arrow keys (default: false)") orelse false;
const shell_opt_raw = b.option([]const u8, "shell", "Shell path (default: /bin/sh)") orelse "/bin/sh";
const shell_opt_raw = b.option([]const u8, "shell", "Shell path (default: /bin/zsh on macOS, /bin/sh on Linux)") orelse if (is_macos) "/bin/zsh" else "/bin/sh";
const shell_opt: [:0]const u8 = b.allocator.dupeZ(u8, shell_opt_raw) catch @panic("OOM");

const options = b.addOptions();
options.addOption(bool, "use_x11", is_x11);
options.addOption(bool, "use_wayland", is_wayland);
options.addOption(bool, "use_macos", is_macos);
options.addOption(bool, "macos_gui_tests", b.option(bool, "macos_gui_tests", "Run Cocoa integration tests (requires a macOS desktop session)") orelse false);
options.addOption(bool, "use_jp_keymap", use_jp_keymap);
options.addOption(u32, "scale", scale_opt);
options.addOption(u32, "max_fps", max_fps_opt);
options.addOption(u32, "pty_buf_kb", pty_buf_kb_opt);
options.addOption(u32, "scrollback_lines", scrollback_lines_opt);
options.addOption(bool, "alt_screen_wheel_scrollback", alt_screen_wheel_scrollback_opt);
options.addOption([:0]const u8, "shell", shell_opt);
options.addOption([:0]const u8, "version", b.allocator.dupeZ(u8, "0.9.3") catch @panic("OOM"));
options.addOption([:0]const u8, "version", b.allocator.dupeZ(u8, "0.10.0") catch @panic("OOM"));

const config_mod = b.createModule(.{
.root_source_file = b.path("config.zig"),
Expand Down Expand Up @@ -162,15 +179,21 @@ pub fn build(b: *std.Build) void {
exe_mod.addImport("c_xkb", c_xkb_wl.createModule());
} else if (is_macos) {
exe_mod.linkFramework("Cocoa", .{});
exe_mod.linkFramework("QuartzCore", .{});
exe_mod.linkFramework("CoreGraphics", .{});
exe_mod.link_libc = true;
if (sysroot_opt) |s| {
exe_mod.addFrameworkPath(.{ .cwd_relative = b.pathJoin(&.{ s, "System/Library/Frameworks" }) });
exe_mod.addSystemIncludePath(sysroot_inc.?);
exe_mod.addLibraryPath(sysroot_lib.?);
}

const c_pty_macos = b.addTranslateC(.{
.root_source_file = b.path("src/c_pty_macos.h"),
.target = target,
.optimize = optimize,
.link_libc = true,
});
if (sysroot_inc) |p| c_pty_macos.addIncludePath(p);
exe_mod.addImport("c_pty_macos", c_pty_macos.createModule());
}

Expand Down Expand Up @@ -228,15 +251,22 @@ pub fn build(b: *std.Build) void {
test_mod.addImport("c_xkb", c_xkb_wl_test.createModule());
} else if (is_macos) {
test_mod.linkFramework("Cocoa", .{});
test_mod.linkFramework("QuartzCore", .{});
test_mod.linkFramework("CoreGraphics", .{});
test_mod.link_libc = true;

if (sysroot_opt) |s| {
test_mod.addFrameworkPath(.{ .cwd_relative = b.pathJoin(&.{ s, "System/Library/Frameworks" }) });
test_mod.addSystemIncludePath(sysroot_inc.?);
test_mod.addLibraryPath(sysroot_lib.?);
}

const c_pty_macos_test = b.addTranslateC(.{
.root_source_file = b.path("src/c_pty_macos.h"),
.target = target,
.optimize = optimize,
.link_libc = true,
});
if (sysroot_inc) |p| c_pty_macos_test.addIncludePath(p);
test_mod.addImport("c_pty_macos", c_pty_macos_test.createModule());
}

Expand Down
2 changes: 1 addition & 1 deletion build.zig.zon
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.{
.name = .zt,
.version = "0.9.3",
.version = "0.10.0",
.minimum_zig_version = "0.16.0",
.fingerprint = 0xc36136a6af00f24b,
.paths = .{
Expand Down
Loading
Loading