From 6951376e970295bb969b546bb71ebc12a97bb39d Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:24:22 +0900 Subject: [PATCH 1/4] Add supported native macOS builds, Cocoa input and app packaging --- .github/workflows/ci.yml | 30 +++ .github/workflows/release.yml | 41 +++- README.md | 58 ++++- build.zig | 48 +++- config.zig | 1 + docs/macos-qa.md | 29 +++ src/backend/macos.zig | 419 +++++++++++++++++++++++++++++----- src/main.zig | 19 +- src/posix.zig | 170 ++++++++++---- src/pty.zig | 4 +- tools/package-macos.sh | 38 +++ 11 files changed, 730 insertions(+), 127 deletions(-) create mode 100644 docs/macos-qa.md create mode 100644 tools/package-macos.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8d086d..ca418ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 413cb1e..8a794b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -168,6 +201,8 @@ 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 @@ -183,4 +218,8 @@ jobs: 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 diff --git a/README.md b/README.md index e2b7f35..af2c4a7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![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 written in Zig. Renders to the Linux framebuffer, X11 (XCB + SHM), Wayland (pure Zig wire protocol, no libwayland), or macOS (Cocoa/AppKit). No GPU required. ![Image](https://github.com/user-attachments/assets/01ab9a42-2efe-41f7-b123-e7312dc5b8d7) @@ -22,7 +22,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 @@ -50,7 +50,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 | |---|---|---|---| @@ -106,8 +106,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 @@ -119,9 +119,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 -> **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. +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 +``` + +`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 @@ -130,7 +172,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 diff --git a/build.zig b/build.zig index 676e4e6..7b9e76b 100644 --- a/build.zig +++ b/build.zig @@ -1,23 +1,32 @@ 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: /include and /lib. + // Linux: /include and /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"); @@ -25,6 +34,13 @@ pub fn build(b: *std.Build) void { 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")) { @@ -69,13 +85,14 @@ 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); @@ -162,8 +179,13 @@ 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"), @@ -171,6 +193,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .link_libc = true, }); + if (sysroot_inc) |p| c_pty_macos.addIncludePath(p); exe_mod.addImport("c_pty_macos", c_pty_macos.createModule()); } @@ -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()); } diff --git a/config.zig b/config.zig index 8f791c1..5ab5489 100644 --- a/config.zig +++ b/config.zig @@ -1,5 +1,6 @@ const std = @import("std"); const build_options = @import("build_options"); +pub const macos_gui_tests = build_options.macos_gui_tests; pub const Backend = enum { fbdev, diff --git a/docs/macos-qa.md b/docs/macos-qa.md new file mode 100644 index 0000000..eb2eb0d --- /dev/null +++ b/docs/macos-qa.md @@ -0,0 +1,29 @@ +# macOS manual QA + +Run this checklist on a Mac with a desktop session after `zig build test`. +Run `zig build test -Dmacos_gui_tests=true` to also exercise real Cocoa window +creation, drawing, input callbacks, candidate geometry, large pastes, and closing. +These checks do not simulate physical keyboard input or IME candidate selection. + +Validated locally on Apple Silicon: release build, PTY/unit tests, Cocoa +integration tests, app signature and Finder launch. Intel cross-build also passes; +Intel runtime checks run in CI. Full manual checks below remain to be completed. + +- Launch `./zig-out/bin/zt` and `open zig-out/zt.app`; confirm a shell prompt appears. +- Type ASCII, shifted punctuation, and Japanese keyboard-layout punctuation. +- Check Enter, Tab, Backspace, arrows, Ctrl+C, Ctrl+D, and Option+letter. +- Switch to Japanese IME, compose text, move the candidate selection with arrows, + commit with Enter, and cancel with Escape. Confirm text appears once, composition + is underlined, and the candidate window follows the cursor. +- Select text with a mouse drag; copy with Cmd+C and paste with Cmd+V. +- Run `cat` and paste multiline text, then Ctrl+D. Check bracketed paste in a shell + or editor that enables it. +- Scroll shell history; check wheel behavior in `less` and mouse-enabled `vim`. +- Resize the window; run `stty size` and confirm rows and columns track the window. +- Print colors and Japanese text; confirm the image is upright and readable. +- Move between Retina and non-Retina displays and check redraw. +- Change the title with `printf '\033]2;macOS test\007'`. +- Switch focus away and back; check input and cursor blinking still work. +- Run sustained output and confirm input/window controls remain responsive. +- Close with Cmd+W, Cmd+Q, the titlebar close button, and shell `exit`. +- Repeat launch/resize/close on an Intel Mac to exercise Objective-C struct returns. diff --git a/src/backend/macos.zig b/src/backend/macos.zig index 367d802..3fa48b8 100644 --- a/src/backend/macos.zig +++ b/src/backend/macos.zig @@ -59,6 +59,7 @@ extern "CoreGraphics" fn CGBitmapContextCreateImage(ctx: id) ?id; extern "CoreGraphics" fn CGContextDrawImage(ctx: id, rect: CGRect, image: id) void; extern "CoreGraphics" fn CGContextRelease(ctx: id) void; extern "CoreGraphics" fn CGImageRelease(image: id) void; +extern "Foundation" fn NSHomeDirectory() id; // CoreGraphics bitmap info constants const kCGBitmapByteOrder32Little: u32 = 2 << 12; // 8192 @@ -210,9 +211,51 @@ fn msgSend_insertText(target: id, _sel: SEL, text: id, repRange: NSRange) void { // Event types (shared with main.zig — keep in sync with x11.zig) // ============================================================================= +pub const MouseEvent = struct { + x: u32, // pixel x + y: u32, // pixel y + button: Button, + action: Action, + modifiers: input_mod.Modifiers, + + pub const Button = enum(u3) { + left = 0, + middle = 1, + right = 2, + none = 3, + wheel_up = 4, + wheel_down = 5, + wheel_left = 6, + wheel_right = 7, + }; + + pub const Action = enum(u2) { + press, + release, + motion, + }; +}; + +pub const PreeditEvent = struct { + data: [128]u8 = undefined, + len: u32 = 0, + caret: u32 = 0, + active: bool = false, + // Per-byte feedback flags, indexed in lockstep with `data`. + // Bit 0 = reverse, Bit 1 = underline, Bit 2 = highlight. + feedback: [128]u8 = [_]u8{0} ** 128, + + pub fn slice(self: *const PreeditEvent) []const u8 { + return self.data[0..self.len]; + } +}; + pub const Event = union(enum) { key: KeyEvent, text: TextEvent, + preedit: PreeditEvent, + mouse: MouseEvent, + copy_selection: void, paste: PasteEvent, resize: ResizeEvent, expose: void, @@ -280,9 +323,13 @@ pub const MacosBackend = struct { event_head: u32 = 0, event_tail: u32 = 0, - paste_buf_data: [16384]u8 = undefined, + paste_buf_data: std.ArrayList(u8) = .empty, has_marked_text: bool = false, + marked_length: u64 = 0, + ime_x: u32 = 0, + ime_y: u32 = config.cell_height, + autorelease_pool: id, // ========================================================================= // Ring buffer helpers @@ -329,8 +376,22 @@ pub const MacosBackend = struct { posix.close(pipe_fds[1]); } + const pool = msgSend_id(msgSend_id(cls("NSAutoreleasePool"), sel("alloc")), sel("init")); + errdefer msgSend_void(pool, sel("drain")); + // 2. NSApplication setup const app = msgSend_id(cls("NSApplication"), sel("sharedApplication")); + // Finder launches bundles with an unspecified working directory. + // Start their shell at home; CLI launches retain the caller's directory. + const bundle = msgSend_id(cls("NSBundle"), sel("mainBundle")); + const bundle_path = msgSend_id(bundle, sel("bundlePath")); + if (msgSend_cstr(bundle_path, sel("UTF8String"))) |path| { + if (std.mem.endsWith(u8, std.mem.span(path), ".app")) { + if (msgSend_cstr(NSHomeDirectory(), sel("UTF8String"))) |home| { + if (std.c.chdir(home) != 0) return error.HomeDirectoryUnavailable; + } + } + } // setActivationPolicy: NSApplicationActivationPolicyRegular = 0 msgSend_void_i64(app, sel("setActivationPolicy:"), 0); @@ -353,6 +414,7 @@ pub const MacosBackend = struct { kCGBitmapInfo, ) orelse return error.CGBitmapContextFailed; + errdefer CGContextRelease(cg_context); const data_ptr = CGBitmapContextGetData(cg_context) orelse return error.CGBitmapDataNull; const buffer_size = @as(usize, stride) * @as(usize, height); const buffer = data_ptr[0..buffer_size]; @@ -380,8 +442,12 @@ pub const MacosBackend = struct { // 8. Set view as contentView and configure window msgSend_void_id(window, sel("setContentView:"), view); - msgSend_void_id(window, sel("makeFirstResponder:"), view); + const make_first_responder: *const fn (id, SEL, id) callconv(.c) BOOL = @ptrCast(&objc_msgSend); + _ = make_first_responder(window, sel("makeFirstResponder:"), view); + msgSend_void_bool(window, sel("setReleasedWhenClosed:"), NO); + msgSend_void_bool(window, sel("setAcceptsMouseMovedEvents:"), YES); msgSend_void_id(window, sel("setDelegate:"), view); + installMenu(app, view); // 9. Set window title const title_str = createNSString("zt"); @@ -408,6 +474,7 @@ pub const MacosBackend = struct { .window = window, .view = view, .cg_context = cg_context, + .autorelease_pool = pool, }; } @@ -425,10 +492,17 @@ pub const MacosBackend = struct { // ========================================================================= pub fn deinit(self: *Self) void { + self.paste_buf_data.deinit(std.heap.c_allocator); + msgSend_void_optid(self.app, sel("setMainMenu:"), null); posix.close(self.wakeup_read_fd); posix.close(self.wakeup_write_fd); + _ = object_setInstanceVariable(self.view, "_zt_backend", null); + msgSend_void_optid(self.window, sel("setDelegate:"), null); + msgSend_void(self.window, sel("close")); + msgSend_void(self.view, sel("release")); + msgSend_void(self.window, sel("release")); CGContextRelease(self.cg_context); - // NSWindow and NSView are autoreleased by Cocoa + msgSend_void(self.autorelease_pool, sel("drain")); } // ========================================================================= @@ -469,7 +543,7 @@ pub const MacosBackend = struct { } pub fn flush(_: *Self) void { - // No-op on macOS (Cocoa display system handles this) + // Display is serviced by pollEvents. } // ========================================================================= @@ -481,31 +555,34 @@ pub const MacosBackend = struct { } pub fn pollEvents(self: *Self) ?Event { - // 1. Pump Cocoa events: dispatch all pending NSApp events. - // This triggers our keyDown:/drawRect:/windowDidResize: callbacks - // which push into the event_queue ring buffer. - const default_mode = getDefaultRunLoopMode(); + // Consume queued callbacks before pumping another NSEvent. This keeps + // paste storage alive until the dispatcher has consumed it. + if (self.popEvent()) |ev| return ev; + const pool = msgSend_id(msgSend_id(cls("NSAutoreleasePool"), sel("alloc")), sel("init")); + defer msgSend_void(pool, sel("drain")); + var drain_buf: [64]u8 = undefined; while (true) { - const ns_event = msgSend_nextEvent( - self.app, - sel("nextEventMatchingMask:untilDate:inMode:dequeue:"), - NSEventMaskAny, - null, // untilDate:nil — don't block - default_mode, - YES, // dequeue - ); - if (ns_event) |ev| { - // Dispatch to the appropriate target (NSWindow → NSView callbacks) - msgSend_void_id(self.app, sel("sendEvent:"), ev); - } else break; + const n = posix.read(self.wakeup_read_fd, &drain_buf) catch break; + if (n == 0) break; } + const date = msgSend_id(cls("NSDate"), sel("distantPast")); + while (msgSend_nextEvent(self.app, sel("nextEventMatchingMask:untilDate:inMode:dequeue:"), NSEventMaskAny, date, getDefaultRunLoopMode(), YES)) |ev| { + msgSend_void_id(self.app, sel("sendEvent:"), ev); + if (self.event_head != self.event_tail) break; + } + msgSend_void(self.app, sel("updateWindows")); + return self.popEvent(); + } - // 2. Drain wakeup pipe - var drain_buf: [64]u8 = undefined; - _ = posix.read(self.wakeup_read_fd, &drain_buf) catch {}; + pub fn updateTitle(self: *Self, title: []const u8) void { + var buf: [512]u8 = undefined; + const text = std.fmt.bufPrintZ(&buf, "{s}", .{title}) catch return; + msgSend_void_id(self.window, sel("setTitle:"), createNSString(text)); + } - // 3. Return next queued event - return self.popEvent(); + pub fn updateImeCursorPos(self: *Self, x: u32, y: u32) void { + self.ime_x = x; + self.ime_y = y; } // ========================================================================= @@ -597,6 +674,9 @@ fn registerZTViewClass() ?id { _ = class_addMethod(new_class, sel("drawRect:"), @ptrCast(@constCast(&ztDrawRect)), "v@:{CGRect=dddd}"); _ = class_addMethod(new_class, sel("acceptsFirstResponder"), @ptrCast(@constCast(&ztAcceptsFirstResponder)), "c@:"); _ = class_addMethod(new_class, sel("canBecomeKeyView"), @ptrCast(@constCast(&ztCanBecomeKeyView)), "c@:"); + _ = class_addMethod(new_class, sel("ztRequestClose:"), @ptrCast(&ztRequestClose), "v@:@"); + _ = class_addMethod(new_class, sel("copy:"), @ptrCast(&ztCopy), "v@:@"); + _ = class_addMethod(new_class, sel("paste:"), @ptrCast(&ztPaste), "v@:@"); // --- Keyboard --- _ = class_addMethod(new_class, sel("keyDown:"), @ptrCast(@constCast(&ztKeyDown)), "v@:@"); @@ -607,6 +687,19 @@ fn registerZTViewClass() ?id { // We handle all keys through the evdev key event path, so this is a no-op. _ = class_addMethod(new_class, sel("doCommandBySelector:"), @ptrCast(@constCast(&ztDoCommandBySelector)), "v@::"); + // --- Mouse input --- + _ = class_addMethod(new_class, sel("mouseDown:"), @ptrCast(&ztMouseDown), "v@:@"); + _ = class_addMethod(new_class, sel("mouseUp:"), @ptrCast(&ztMouseUp), "v@:@"); + _ = class_addMethod(new_class, sel("rightMouseDown:"), @ptrCast(&ztMouseDown), "v@:@"); + _ = class_addMethod(new_class, sel("rightMouseUp:"), @ptrCast(&ztMouseUp), "v@:@"); + _ = class_addMethod(new_class, sel("otherMouseDown:"), @ptrCast(&ztMouseDown), "v@:@"); + _ = class_addMethod(new_class, sel("otherMouseUp:"), @ptrCast(&ztMouseUp), "v@:@"); + _ = class_addMethod(new_class, sel("mouseDragged:"), @ptrCast(&ztMouseMoved), "v@:@"); + _ = class_addMethod(new_class, sel("rightMouseDragged:"), @ptrCast(&ztMouseMoved), "v@:@"); + _ = class_addMethod(new_class, sel("otherMouseDragged:"), @ptrCast(&ztMouseMoved), "v@:@"); + _ = class_addMethod(new_class, sel("mouseMoved:"), @ptrCast(&ztMouseMoved), "v@:@"); + _ = class_addMethod(new_class, sel("scrollWheel:"), @ptrCast(&ztScrollWheel), "v@:@"); + // --- NSTextInputClient --- _ = class_addMethod(new_class, sel("insertText:replacementRange:"), @ptrCast(@constCast(&ztInsertText)), "v@:@{_NSRange=QQ}"); _ = class_addMethod(new_class, sel("hasMarkedText"), @ptrCast(@constCast(&ztHasMarkedText)), "c@:"); @@ -664,10 +757,47 @@ fn ztCanBecomeKeyView(_: id, _: SEL) callconv(.c) BOOL { return YES; } +fn ztRequestClose(view: id, _: SEL, _: ?id) callconv(.c) void { + if (MacosBackend.getBackendFromView(view)) |backend| backend.pushEvent(.close); +} + +fn ztCopy(view: id, _: SEL, _: ?id) callconv(.c) void { + if (MacosBackend.getBackendFromView(view)) |backend| backend.pushEvent(.copy_selection); +} + +fn ztPaste(view: id, _: SEL, _: ?id) callconv(.c) void { + if (MacosBackend.getBackendFromView(view)) |backend| handlePaste(backend); +} + +fn menuItem(title: [*:0]const u8, action: SEL, key: [*:0]const u8, target: id) id { + const init: *const fn (id, SEL, id, SEL, id) callconv(.c) id = @ptrCast(&objc_msgSend); + const item = init(msgSend_id(cls("NSMenuItem"), sel("alloc")), sel("initWithTitle:action:keyEquivalent:"), createNSString(title), action, createNSString(key)); + msgSend_void_id(item, sel("setTarget:"), target); + return msgSend_id(item, sel("autorelease")); +} + +fn installMenu(app: id, view: id) void { + const menu = msgSend_id(msgSend_id(cls("NSMenu"), sel("alloc")), sel("init")); + defer msgSend_void(menu, sel("release")); + const app_menu = msgSend_id(msgSend_id(cls("NSMenu"), sel("alloc")), sel("init")); + defer msgSend_void(app_menu, sel("release")); + const app_item = menuItem("zt", sel("ztRequestClose:"), "", view); + msgSend_void_id(app_item, sel("setSubmenu:"), app_menu); + msgSend_void_id(app_menu, sel("addItem:"), menuItem("Close Window", sel("ztRequestClose:"), "w", view)); + msgSend_void_id(app_menu, sel("addItem:"), menuItem("Quit zt", sel("ztRequestClose:"), "q", view)); + msgSend_void_id(menu, sel("addItem:"), app_item); + const edit_menu = msgSend_id(msgSend_id(cls("NSMenu"), sel("alloc")), sel("init")); + defer msgSend_void(edit_menu, sel("release")); + const edit_item = menuItem("Edit", sel("copy:"), "", view); + msgSend_void_id(edit_item, sel("setSubmenu:"), edit_menu); + msgSend_void_id(edit_menu, sel("addItem:"), menuItem("Copy", sel("copy:"), "c", view)); + msgSend_void_id(edit_menu, sel("addItem:"), menuItem("Paste", sel("paste:"), "v", view)); + msgSend_void_id(menu, sel("addItem:"), edit_item); + msgSend_void_id(app, sel("setMainMenu:"), menu); +} + fn ztDoCommandBySelector(_: id, _: SEL, _: SEL) callconv(.c) void { - // No-op: all keys are handled through the evdev key event path. - // Without this, NSView's default calls NSBeep() for every - // unhandled command selector (insertNewline:, insertTab:, etc.). + // Special keys are dispatched by keyDown when no IME composition is active. } fn ztKeyDown(self_view: id, _: SEL, ns_event: id) callconv(.c) void { @@ -689,6 +819,10 @@ fn ztKeyDown(self_view: id, _: SEL, ns_event: id) callconv(.c) void { backend.pushEvent(.close); return; }, + 0x08 => { // Cmd+C — copy selected text + backend.pushEvent(.copy_selection); + return; + }, 0x09 => { // Cmd+V — paste handlePaste(backend); return; @@ -734,7 +868,7 @@ fn ztKeyDown(self_view: id, _: SEL, ns_event: id) callconv(.c) void { else => false, }; - if (is_special) { + if (is_special and !backend.has_marked_text) { backend.pushEvent(.{ .key = .{ .keycode = evdev_code, .pressed = true, @@ -746,7 +880,7 @@ fn ztKeyDown(self_view: id, _: SEL, ns_event: id) callconv(.c) void { // Text-producing keys: let IME path handle via interpretKeyEvents → insertText. // Ctrl+key combos still need KeyEvent since insertText filters single-byte ASCII. const mods = flagsToModifiers(flags); - if (mods.ctrl or mods.alt) { + if ((mods.ctrl or mods.alt) and !backend.has_marked_text) { if (evdev_code != 0) { backend.pushEvent(.{ .key = .{ .keycode = evdev_code, @@ -807,20 +941,20 @@ fn ztInsertText(self_view: id, _: SEL, text_obj: id, _: NSRange) callconv(.c) vo const len = std.mem.len(cstr); if (len == 0) return; - // Skip all single-byte ASCII — these are already handled by the key - // event path (macosToEvdev → translateKey). Without this guard, keys - // like Enter (0x0D) and Tab (0x09) produce duplicates: one from the - // key event and one from insertText. Only let through multi-byte - // sequences (IME-composed text, dead key output, etc.). - if (len == 1 and cstr[0] < 0x80) return; - - var text_event: TextEvent = .{}; - const copy_len = @min(len, text_event.data.len); - @memcpy(text_event.data[0..copy_len], cstr[0..copy_len]); - text_event.len = @intCast(copy_len); - backend.pushEvent(.{ .text = text_event }); - backend.has_marked_text = false; + backend.marked_length = 0; + backend.pushEvent(.{ .preedit = .{} }); + // Printable ASCII comes through insertText too. Preserve layout and avoid + // splitting a UTF-8 character across the fixed-size event payload. + var rest = cstr[0..len]; + while (rest.len > 0) { + const n = utf8PrefixLen(rest, 128); + var event: TextEvent = .{}; + @memcpy(event.data[0..n], rest[0..n]); + event.len = @intCast(n); + backend.pushEvent(.{ .text = event }); + rest = rest[n..]; + } } fn ztHasMarkedText(self_view: id, _: SEL) callconv(.c) BOOL { @@ -828,14 +962,29 @@ fn ztHasMarkedText(self_view: id, _: SEL) callconv(.c) BOOL { return if (backend.has_marked_text) YES else NO; } -fn ztSetMarkedText(self_view: id, _: SEL, _: id, _: NSRange, _: NSRange) callconv(.c) void { +fn ztSetMarkedText(self_view: id, _: SEL, text: id, selected: NSRange, _: NSRange) callconv(.c) void { const backend = MacosBackend.getBackendFromView(self_view) orelse return; - backend.has_marked_text = true; + const str = if (msgSend_bool(text, sel("isKindOfClass:"), cls("NSAttributedString"))) + msgSend_id(text, sel("string")) + else + text; + const bytes = std.mem.span(msgSend_cstr(str, sel("UTF8String")) orelse return); + backend.marked_length = msgSend_u64(str, sel("length")); // Cocoa uses UTF-16 units + backend.has_marked_text = bytes.len > 0; + var event: PreeditEvent = .{ .active = backend.has_marked_text }; + const n = utf8PrefixLen(bytes, event.data.len); + @memcpy(event.data[0..n], bytes[0..n]); + @memset(event.feedback[0..n], 2); // underline composition + event.len = @intCast(n); + event.caret = utf16Caret(bytes[0..n], selected.location); + backend.pushEvent(.{ .preedit = event }); } fn ztUnmarkText(self_view: id, _: SEL) callconv(.c) void { const backend = MacosBackend.getBackendFromView(self_view) orelse return; backend.has_marked_text = false; + backend.marked_length = 0; + backend.pushEvent(.{ .preedit = .{} }); } fn ztValidAttributes(_: id, _: SEL) callconv(.c) id { @@ -843,12 +992,28 @@ fn ztValidAttributes(_: id, _: SEL) callconv(.c) id { return msgSend_id(cls("NSArray"), sel("array")); } -fn ztFirstRect(_: id, _: SEL, _: NSRange, _: ?*NSRange) callconv(.c) CGRect { - return CGRect.make(0, 0, 0, 0); +fn ztFirstRect(view: id, _: SEL, range: NSRange, actual: ?*NSRange) callconv(.c) CGRect { + const backend = MacosBackend.getBackendFromView(view) orelse return CGRect.make(0, 0, 0, 0); + if (actual) |out| out.* = range; + const bounds = msgSend_CGRect(view, sel("bounds")); + const rect = CGRect.make(@floatFromInt(backend.ime_x), @max(0, bounds.size.height - @as(f64, @floatFromInt(backend.ime_y))), @floatFromInt(config.cell_width), @floatFromInt(config.cell_height)); + if (builtin.cpu.arch == .x86_64) { + const convert_view: *const fn (*CGRect, id, SEL, CGRect, ?id) callconv(.c) void = @ptrCast(&objc_msgSend_stret); + var in_window: CGRect = undefined; + convert_view(&in_window, view, sel("convertRect:toView:"), rect, null); + const convert_screen: *const fn (*CGRect, id, SEL, CGRect) callconv(.c) void = @ptrCast(&objc_msgSend_stret); + var result: CGRect = undefined; + convert_screen(&result, backend.window, sel("convertRectToScreen:"), in_window); + return result; + } + const convert_view: *const fn (id, SEL, CGRect, ?id) callconv(.c) CGRect = @ptrCast(&objc_msgSend); + const in_window = convert_view(view, sel("convertRect:toView:"), rect, null); + const convert_screen: *const fn (id, SEL, CGRect) callconv(.c) CGRect = @ptrCast(&objc_msgSend); + return convert_screen(backend.window, sel("convertRectToScreen:"), in_window); } fn ztCharacterIndex(_: id, _: SEL, _: CGPoint) callconv(.c) NSUInteger { - return std.math.maxInt(NSUInteger); // NSNotFound + return 0x7FFFFFFFFFFFFFFF; // NSNotFound } fn ztAttributedSubstring(_: id, _: SEL, _: NSRange, _: ?*NSRange) callconv(.c) ?id { @@ -858,7 +1023,7 @@ fn ztAttributedSubstring(_: id, _: SEL, _: NSRange, _: ?*NSRange) callconv(.c) ? fn ztMarkedRange(self_view: id, _: SEL) callconv(.c) NSRange { const backend = MacosBackend.getBackendFromView(self_view) orelse return .{ .location = 0x7FFFFFFFFFFFFFFF, .length = 0 }; if (backend.has_marked_text) { - return .{ .location = 0, .length = 0 }; + return .{ .location = 0, .length = backend.marked_length }; } // NSNotFound return .{ .location = 0x7FFFFFFFFFFFFFFF, .length = 0 }; @@ -947,13 +1112,18 @@ fn handlePaste(backend: *MacosBackend) void { const type_string = createNSString("public.utf8-plain-text"); // [pasteboard stringForType:NSPasteboardTypeString] const str_obj = msgSend_id_type(pasteboard, sel("stringForType:"), type_string) orelse return; + queuePaste(backend, str_obj); +} + +fn queuePaste(backend: *MacosBackend, str_obj: id) void { const cstr = msgSend_cstr(str_obj, sel("UTF8String")) orelse return; const len = std.mem.len(cstr); if (len == 0) return; - const copy_len = @min(len, backend.paste_buf_data.len); - @memcpy(backend.paste_buf_data[0..copy_len], cstr[0..copy_len]); - backend.pushEvent(.{ .paste = .{ .ptr = &backend.paste_buf_data, .len = @intCast(copy_len) } }); + if (len > std.math.maxInt(u32)) return; + backend.paste_buf_data.resize(std.heap.c_allocator, len) catch return; + @memcpy(backend.paste_buf_data.items, cstr[0..len]); + backend.pushEvent(.{ .paste = .{ .ptr = backend.paste_buf_data.items.ptr, .len = @intCast(len) } }); } fn createNSString(str: [*:0]const u8) id { @@ -980,12 +1150,10 @@ fn setViewBackendPtr(view: id, backend: *MacosBackend) void { // object_setInstanceVariable is simpler but we use the // runtime-safe pattern: get the ivar offset and write directly. // However, object_setInstanceVariable is fine for our use case. - const f: *const fn (id, [*:0]const u8, *anyopaque) callconv(.c) void = - @ptrCast(&object_setInstanceVariable); - f(view, "_zt_backend", @ptrCast(backend)); + _ = object_setInstanceVariable(view, "_zt_backend", @ptrCast(backend)); } -extern "objc" fn object_setInstanceVariable(obj: id, name: [*:0]const u8, value: *anyopaque) void; +extern "objc" fn object_setInstanceVariable(obj: id, name: [*:0]const u8, value: ?*anyopaque) ?*anyopaque; fn getDefaultRunLoopMode() id { // NSDefaultRunLoopMode is an NSString constant. @@ -993,3 +1161,142 @@ fn getDefaultRunLoopMode() id { const ptr = @extern(*const id, .{ .name = "NSDefaultRunLoopMode" }); return ptr.*; } + +fn utf8PrefixLen(bytes: []const u8, capacity: usize) usize { + var n = @min(bytes.len, capacity); + if (n < bytes.len) { + while (n > 0 and (bytes[n] & 0xC0) == 0x80) n -= 1; + } + return n; +} + +/// Renderer carets use UTF-8 byte offsets; AppKit ranges count UTF-16 units. +fn utf16Caret(bytes: []const u8, units: u64) u32 { + var offset: usize = 0; + var used: u64 = 0; + while (offset < bytes.len and used < units) { + const n = std.unicode.utf8ByteSequenceLength(bytes[offset]) catch break; + if (offset + n > bytes.len) break; + const cp = std.unicode.utf8Decode(bytes[offset..][0..n]) catch break; + const width: u64 = if (cp > 0xFFFF) 2 else 1; + if (used + width > units) break; + used += width; + offset += n; + } + return @intCast(offset); +} + +fn mouseEvent(view: id, event: id, action: MouseEvent.Action, button: MouseEvent.Button) void { + const backend = MacosBackend.getBackendFromView(view) orelse return; + const location: *const fn (id, SEL) callconv(.c) CGPoint = @ptrCast(&objc_msgSend); + const convert: *const fn (id, SEL, CGPoint, ?id) callconv(.c) CGPoint = @ptrCast(&objc_msgSend); + const point = convert(view, sel("convertPoint:fromView:"), location(event, sel("locationInWindow")), null); + const bounds = msgSend_CGRect(view, sel("bounds")); + backend.pushEvent(.{ .mouse = .{ + .x = @intFromFloat(@max(0, point.x)), + .y = @intFromFloat(@max(0, bounds.size.height - point.y)), + .button = button, + .action = action, + .modifiers = flagsToModifiers(msgSend_u64(event, sel("modifierFlags"))), + } }); +} + +fn mouseButton(event: id) MouseEvent.Button { + return switch (msgSend_u64(event, sel("buttonNumber"))) { + 0 => .left, + 1 => .right, + 2 => .middle, + else => .none, + }; +} + +fn ztMouseDown(view: id, _: SEL, event: id) callconv(.c) void { + mouseEvent(view, event, .press, mouseButton(event)); +} + +fn ztMouseUp(view: id, _: SEL, event: id) callconv(.c) void { + mouseEvent(view, event, .release, mouseButton(event)); +} + +fn ztMouseMoved(view: id, _: SEL, event: id) callconv(.c) void { + mouseEvent(view, event, .motion, .none); +} + +fn ztScrollWheel(view: id, _: SEL, event: id) callconv(.c) void { + const delta: *const fn (id, SEL) callconv(.c) f64 = @ptrCast(&objc_msgSend); + const dy = delta(event, sel("scrollingDeltaY")); + if (dy != 0) mouseEvent(view, event, .press, if (dy > 0) .wheel_up else .wheel_down); +} + +test "macOS text chunks preserve UTF-8 boundaries" { + try std.testing.expectEqual(@as(usize, 1), utf8PrefixLen("a日本", 3)); + try std.testing.expectEqual(@as(usize, 4), utf8PrefixLen("a日本", 4)); + try std.testing.expectEqual(@as(usize, 7), utf8PrefixLen("a日本", 128)); +} + +test "macOS composition caret converts UTF-16 to UTF-8 offsets" { + try std.testing.expectEqual(@as(u32, 5), utf16Caret("a😀日", 3)); + try std.testing.expectEqual(@as(u32, 1), utf16Caret("a😀日", 2)); + try std.testing.expectEqual(@as(u32, 8), utf16Caret("a😀日", 4)); +} + +test "macOS Cocoa window, text composition, geometry and close integration" { + if (!config.macos_gui_tests) return error.SkipZigTest; + var backend = try MacosBackend.init(); + backend.postInit(); + defer backend.deinit(); + // Service the real AppKit run loop and drawing before examining callbacks. + for (0..10) |_| { + while (backend.pollEvents()) |_| {} + posix.sleep(10 * std.time.ns_per_ms); + } + try std.testing.expectEqual(@as(u32, 80 * config.cell_width), backend.queryGeometry().w); + backend.updateTitle("zt Cocoa integration test"); + const title = msgSend_id(backend.window, sel("title")); + try std.testing.expectEqualStrings("zt Cocoa integration test", std.mem.span(msgSend_cstr(title, sel("UTF8String")).?)); + + const range: NSRange = .{ .location = 0, .length = 0 }; + ztInsertText(backend.view, sel("insertText:replacementRange:"), createNSString("ASCII 日本😀"), range); + _ = backend.popEvent(); // Cleared preedit + const text = backend.popEvent().?.text; + try std.testing.expectEqualStrings("ASCII 日本😀", text.slice()); + ztSetMarkedText(backend.view, sel("setMarkedText:selectedRange:replacementRange:"), createNSString("a😀日"), .{ .location = 3, .length = 0 }, range); + const preedit = backend.popEvent().?.preedit; + try std.testing.expect(preedit.active); + try std.testing.expectEqual(@as(u32, 5), preedit.caret); + try std.testing.expectEqual(@as(u64, 4), ztMarkedRange(backend.view, sel("markedRange")).length); + backend.updateImeCursorPos(config.cell_width, config.cell_height); + const candidate = ztFirstRect(backend.view, sel("firstRectForCharacterRange:actualRange:"), range, null); + try std.testing.expect(candidate.size.width > 0 and std.math.isFinite(candidate.origin.x)); + ztUnmarkText(backend.view, sel("unmarkText")); + try std.testing.expect(!backend.popEvent().?.preedit.active); + try backend.resize(100 * config.cell_width, 30 * config.cell_height); + try std.testing.expectEqual(@as(usize, backend.stride) * backend.height, backend.buffer.len); + backend.markDirtyRows(0, backend.height); + backend.present(); + msgSend_void(backend.view, sel("displayIfNeeded")); + try std.testing.expectEqual(NO, ztWindowShouldClose(backend.view, sel("windowShouldClose:"), backend.window)); + try std.testing.expect(backend.popEvent().? == .close); + + // Construct actual NSEvents without requiring Accessibility permissions. + const key_event: *const fn (id, SEL, u64, CGPoint, u64, f64, i64, ?id, id, id, BOOL, u16) callconv(.c) id = @ptrCast(&objc_msgSend); + const characters = createNSString("c"); + const event = key_event(cls("NSEvent"), sel("keyEventWithType:location:modifierFlags:timestamp:windowNumber:context:characters:charactersIgnoringModifiers:isARepeat:keyCode:"), 10, .{ .x = 0, .y = 0 }, NSEventModifierFlagControl, 0, 0, null, characters, characters, NO, 0x08); + ztKeyDown(backend.view, sel("keyDown:"), event); + const key = backend.popEvent().?.key; + try std.testing.expectEqual(input_mod.KEY.C, key.keycode); + try std.testing.expect(key.modifiers.ctrl); + const copy_event = key_event(cls("NSEvent"), sel("keyEventWithType:location:modifierFlags:timestamp:windowNumber:context:characters:charactersIgnoringModifiers:isARepeat:keyCode:"), 10, .{ .x = 0, .y = 0 }, NSEventModifierFlagCommand, 0, 0, null, characters, characters, NO, 0x08); + const menu = msgSend_id(backend.app, sel("mainMenu")); + try std.testing.expect(msgSend_bool(menu, sel("performKeyEquivalent:"), copy_event)); + try std.testing.expect(backend.popEvent().? == .copy_selection); + + // Large pastes must survive in full, including a multibyte final character. + const large = try std.testing.allocator.allocSentinel(u8, 20003, 0); + defer std.testing.allocator.free(large); + @memset(large[0..20000], 'x'); + @memcpy(large[20000..20003], "日"); + queuePaste(&backend, createNSString(large)); + const paste = backend.popEvent().?.paste; + try std.testing.expectEqualStrings(large, paste.slice()); +} diff --git a/src/main.zig b/src/main.zig index bc509b3..22a26ea 100644 --- a/src/main.zig +++ b/src/main.zig @@ -97,7 +97,12 @@ fn setupSignals() !posix.fd_t { _ = std.c.sigprocmask(std.c.SIG.BLOCK, &mask, null); // SIGPIPE: ignore globally so bad writes return EPIPE // instead of terminating the process. - _ = std.c.signal(std.c.SIG.PIPE, std.c.SIG.IGN); + var action: std.c.Sigaction = .{ + .handler = .{ .handler = std.c.SIG.IGN }, + .mask = std.mem.zeroes(std.c.sigset_t), + .flags = 0, + }; + _ = std.c.sigaction(std.c.SIG.PIPE, &action, null); return -1; } } @@ -183,9 +188,9 @@ fn kqueueAddFd(kq: i32, fd: posix.fd_t, tag: usize) !void { _ = try posix.kevent(kq, &changelist, &.{}, null); } -fn kqueueAddSignal(kq: i32, sig: u6) !void { +fn kqueueAddSignal(kq: i32, sig: std.c.SIG) !void { const changelist = [1]posix.Kevent{.{ - .ident = sig, + .ident = @intFromEnum(sig), .filter = std.c.EVFILT.SIGNAL, .flags = std.c.EV.ADD, .fflags = 0, @@ -588,6 +593,7 @@ fn dispatchClipboardCopy(data: []const u8) void { const argv: [*:null]const ?[*:0]const u8 = switch (config.backend) { .x11 => &[_:null]?[*:0]const u8{ "xclip", "-selection", "clipboard" }, .wayland => &[_:null]?[*:0]const u8{"wl-copy"}, + .macos => &[_:null]?[*:0]const u8{"/usr/bin/pbcopy"}, else => return, }; @@ -1543,8 +1549,9 @@ pub fn main(init: std.process.Init.Minimal) !void { for (kevents[0..n]) |kev| { if (kev.filter == std.c.EVFILT.READ) { if (kev.udata == @intFromEnum(KqueueTag.pty)) { - // PTY readable — drain all available data - while (true) { + // Bound each drain so sustained output cannot starve Cocoa. + var drained: usize = 0; + while (drained < config.pty_buf_size) { const bytes_read = pty.read(&pty_buf) catch |err| switch (err) { error.WouldBlock => break, else => { @@ -1557,6 +1564,7 @@ pub fn main(init: std.process.Init.Minimal) !void { break; } bytes_since_render += bytes_read; + drained += bytes_read; // Clear selection when terminal content changes if (term.selection != null) { term.clearSelection(); @@ -1936,4 +1944,5 @@ test { _ = @import("render.zig"); _ = @import("scrollback.zig"); if (config.backend == .x11) _ = @import("backend/x11.zig"); + if (config.backend == .macos) _ = @import("backend/macos.zig"); } diff --git a/src/posix.zig b/src/posix.zig index 672be73..882ae8f 100644 --- a/src/posix.zig +++ b/src/posix.zig @@ -35,9 +35,8 @@ pub const unexpectedErrno = std.posix.unexpectedErrno; // Missing-in-0.16 shims ----------------------------------------------------- // -// All functions below are Linux-only — the Zig 0.16 stdlib still uses these -// platform-specific syscall wrappers. macOS targets must be ported separately -// (kqueue/kevent above are the only macOS-aware shims here). +// Shared process/file wrappers use libc on macOS and raw syscalls on Linux. +// Linux backend socket helpers remain Linux-only. // // Audit against /lib/std/posix.zig @ Zig 0.16.0 (1686 lines): // write — verified absent in 0.16 stdlib; hand-rolled required @@ -58,9 +57,9 @@ const linux_only_msg = "posix shim: this wrapper is Linux-only; macOS port pendi // Errno helper: comptime (errno → error) lookup table used by all switch-heavy // wrappers. Each entry is .{ linux.E tag, anyerror value }. The caller's declared // return type constrains the visible set; @errorCast bridges anyerror at runtime. -const ErrnoEntry = struct { linux.E, anyerror }; +const ErrnoEntry = struct { E, anyerror }; -inline fn mapErrno(e: linux.E, comptime mapping: []const ErrnoEntry) anyerror { +inline fn mapErrno(e: E, comptime mapping: []const ErrnoEntry) anyerror { inline for (mapping) |entry| { if (e == entry[0]) return entry[1]; } @@ -94,10 +93,9 @@ const write_map = [_]ErrnoEntry{ }; pub inline fn write(fd: fd_t, bytes: []const u8) WriteError!usize { - if (builtin.os.tag != .linux) @compileError(linux_only_msg); while (true) { - const rc = linux.write(fd, bytes.ptr, bytes.len); - const e = linux.errno(rc); + const rc = if (builtin.os.tag == .macos) std.c.write(fd, bytes.ptr, bytes.len) else linux.write(fd, bytes.ptr, bytes.len); + const e = if (builtin.os.tag == .macos) std.c.errno(rc) else linux.errno(rc); if (e == .SUCCESS) return @intCast(rc); if (e == .INTR) continue; // retry transparently, std.posix.write semantics return @errorCast(mapErrno(e, &write_map)); @@ -105,8 +103,11 @@ pub inline fn write(fd: fd_t, bytes: []const u8) WriteError!usize { } pub inline fn close(fd: fd_t) void { - if (builtin.os.tag != .linux) @compileError(linux_only_msg); - _ = linux.close(fd); + if (builtin.os.tag == .macos) { + _ = std.c.close(fd); + } else { + _ = linux.close(fd); + } } pub const PipeError = error{ SystemFdQuotaExceeded, ProcessFdQuotaExceeded, Unexpected }; @@ -117,23 +118,31 @@ const pipe_map = [_]ErrnoEntry{ }; pub inline fn pipe() PipeError![2]fd_t { - if (builtin.os.tag != .linux) @compileError(linux_only_msg); - var fds: [2]i32 = undefined; - // Use pipe2(O_CLOEXEC) so the pipe fds are never leaked to a - // future exec'd child (clipboard helper, etc.). Previously this - // called linux.pipe(), which created fds WITHOUT O_CLOEXEC; the - // callers had to remember to set FD_CLOEXEC by hand, and any - // early-return path in a forked child would leak the read/write - // ends into the exec'd program. - const rc = linux.pipe2(&fds, .{ .CLOEXEC = true }); - const e = linux.errno(rc); - if (e == .SUCCESS) return fds; - return @errorCast(mapErrno(e, &pipe_map)); + return pipe2(.{ .CLOEXEC = true }); } -pub inline fn pipe2(flags: linux.O) PipeError![2]fd_t { - if (builtin.os.tag != .linux) @compileError(linux_only_msg); - var fds: [2]i32 = undefined; +/// Darwin has no pipe2; set flags before exposing either descriptor. +pub inline fn pipe2(flags: O) PipeError![2]fd_t { + var fds: [2]fd_t = undefined; + if (builtin.os.tag == .macos) { + const rc = std.c.pipe(&fds); + if (std.c.errno(rc) != .SUCCESS) return @errorCast(mapErrno(std.c.errno(rc), &pipe_map)); + errdefer { + close(fds[0]); + close(fds[1]); + } + for (fds) |fd| { + if (flags.CLOEXEC) { + _ = fcntl(fd, F.SETFD, 1) catch return error.Unexpected; + } + if (flags.NONBLOCK) { + const old = fcntl(fd, F.GETFL, 0) catch return error.Unexpected; + const nonblock: u32 = @bitCast(O{ .NONBLOCK = true }); + _ = fcntl(fd, F.SETFL, @as(u32, @bitCast(old)) | nonblock) catch return error.Unexpected; + } + } + return fds; + } const rc = linux.pipe2(&fds, flags); const e = linux.errno(rc); if (e == .SUCCESS) return fds; @@ -147,8 +156,12 @@ const fork_map = [_]ErrnoEntry{ .{ .NOMEM, error.SystemResources }, }; -pub inline fn fork() ForkError!linux.pid_t { - if (builtin.os.tag != .linux) @compileError(linux_only_msg); +pub inline fn fork() ForkError!pid_t { + if (builtin.os.tag == .macos) { + const rc = std.c.fork(); + if (rc >= 0) return rc; + return @errorCast(mapErrno(std.c.errno(rc), &fork_map)); + } const rc = linux.fork(); const e = linux.errno(rc); if (e == .SUCCESS) return @intCast(@as(isize, @bitCast(rc))); @@ -158,7 +171,14 @@ pub inline fn fork() ForkError!linux.pid_t { pub const Dup2Error = error{Unexpected}; pub inline fn dup2(old_fd: fd_t, new_fd: fd_t) Dup2Error!void { - if (builtin.os.tag != .linux) @compileError(linux_only_msg); + if (builtin.os.tag == .macos) { + while (std.c.dup2(old_fd, new_fd) < 0) { + const e = std.c.errno(@as(c_int, -1)); + if (e == .INTR) continue; + return error.Unexpected; + } + return; + } const rc = linux.dup2(old_fd, new_fd); return switch (linux.errno(rc)) { .SUCCESS => {}, @@ -167,7 +187,7 @@ pub inline fn dup2(old_fd: fd_t, new_fd: fd_t) Dup2Error!void { } pub inline fn exit(status: u8) noreturn { - if (builtin.os.tag != .linux) @compileError(linux_only_msg); + if (builtin.os.tag == .macos) std.c._exit(status); linux.exit_group(status); } @@ -179,11 +199,16 @@ pub var environ: std.process.Environ = .empty; /// Block the current thread for `nanoseconds`. Replacement for /// `std.Thread.sleep` (removed in Zig 0.16). pub inline fn sleep(nanoseconds: u64) void { - if (builtin.os.tag != .linux) @compileError(linux_only_msg); var req: timespec = .{ .sec = @intCast(nanoseconds / std.time.ns_per_s), .nsec = @intCast(nanoseconds % std.time.ns_per_s), }; + if (builtin.os.tag == .macos) { + while (std.c.nanosleep(&req, &req) < 0) { + if (std.c.errno(@as(c_int, -1)) != .INTR) break; + } + return; + } while (true) { const rc = linux.clock_nanosleep(.MONOTONIC, .{ .ABSTIME = false }, &req, &req); if (linux.errno(rc) != .INTR) break; @@ -195,9 +220,12 @@ pub inline fn sleep(nanoseconds: u64) void { /// MONOTONIC is preferable for the delta-only callers in zt and avoids /// NTP/DST-jump artefacts. pub inline fn nanoTimestamp() i128 { - if (builtin.os.tag != .linux) @compileError(linux_only_msg); var ts: timespec = undefined; - _ = linux.clock_gettime(.MONOTONIC, &ts); + if (builtin.os.tag == .macos) { + _ = std.c.clock_gettime(std.c.CLOCK.MONOTONIC, &ts); + } else { + _ = linux.clock_gettime(.MONOTONIC, &ts); + } return @as(i128, ts.sec) * std.time.ns_per_s + @as(i128, ts.nsec); } @@ -218,8 +246,8 @@ pub const ExecveError = error{ Unexpected, }; -inline fn execveErrno(rc: usize) ExecveError { - return switch (linux.errno(rc)) { +inline fn execveErrno(e: E) ExecveError { + return switch (e) { .ACCES => error.AccessDenied, .NOENT => error.FileNotFound, .NOTDIR => error.NotDir, @@ -242,10 +270,9 @@ pub inline fn execvpeZ( argv: [*:null]const ?[*:0]const u8, envp: [*:null]const ?[*:0]const u8, ) ExecveError { - if (builtin.os.tag != .linux) @compileError(linux_only_msg); const file_slice = std.mem.span(file); if (std.mem.indexOfScalar(u8, file_slice, '/') != null) { - return execveErrno(linux.execve(file, argv, envp)); + return execveZ(file, argv, envp); } const path = getenv("PATH") orelse "/usr/local/bin:/usr/bin:/bin"; var buf: [std.fs.max_path_bytes]u8 = undefined; @@ -257,7 +284,7 @@ pub inline fn execvpeZ( last_err = error.NameTooLong; continue; }; - const err = execveErrno(linux.execve(full.ptr, argv, envp)); + const err = execveZ(full.ptr, argv, envp); switch (err) { error.FileNotFound, error.NotDir => continue, error.AccessDenied => { @@ -270,8 +297,19 @@ pub inline fn execvpeZ( return last_err; } +fn execveZ(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8, envp: [*:null]const ?[*:0]const u8) ExecveError { + if (builtin.os.tag == .macos) return execveErrno(std.c.errno(std.c.execve(file, argv, envp))); + return execveErrno(linux.errno(linux.execve(file, argv, envp))); +} + pub inline fn kqueue() !i32 { - if (builtin.os.tag == .macos) return @intCast(std.c.kqueue()); + if (builtin.os.tag == .macos) { + const fd = std.c.kqueue(); + if (fd < 0) return error.Unexpected; + errdefer close(fd); + _ = try fcntl(fd, F.SETFD, 1); + return fd; + } @compileError("kqueue is macOS-only"); } @@ -290,7 +328,7 @@ pub inline fn kevent( @intCast(eventlist.len), timeout, ); - if (rc < 0) return error.Unexpected; + if (rc < 0) return if (std.c.errno(rc) == .INTR) error.Interrupted else error.Unexpected; return @intCast(rc); } @compileError("kevent is macOS-only"); @@ -333,8 +371,7 @@ const open_map = [_]ErrnoEntry{ .{ .AGAIN, error.WouldBlock }, }; -pub inline fn open(path: []const u8, flags: linux.O, mode: linux.mode_t) OpenError!fd_t { - if (builtin.os.tag != .linux) @compileError(linux_only_msg); +pub inline fn open(path: []const u8, flags: O, mode: mode_t) OpenError!fd_t { var buf: [std.fs.max_path_bytes]u8 = undefined; if (path.len >= buf.len) return error.NameTooLong; @memcpy(buf[0..path.len], path); @@ -342,10 +379,9 @@ pub inline fn open(path: []const u8, flags: linux.O, mode: linux.mode_t) OpenErr return openZ(@ptrCast(buf[0..path.len :0]), flags, mode); } -pub inline fn openZ(path: [*:0]const u8, flags: linux.O, mode: linux.mode_t) OpenError!fd_t { - if (builtin.os.tag != .linux) @compileError(linux_only_msg); - const rc = linux.open(path, flags, mode); - const e = linux.errno(rc); +pub inline fn openZ(path: [*:0]const u8, flags: O, mode: mode_t) OpenError!fd_t { + const rc = if (builtin.os.tag == .macos) std.c.open(path, flags, @as(c_uint, mode)) else linux.open(path, flags, mode); + const e = if (builtin.os.tag == .macos) std.c.errno(rc) else linux.errno(rc); if (e == .SUCCESS) return @intCast(rc); return @errorCast(mapErrno(e, &open_map)); } @@ -362,7 +398,11 @@ const fcntl_map = [_]ErrnoEntry{ /// Truncates the raw `usize` return to `i32` to match the kernel fcntl ABI /// and avoid leaking sign-extended errno-encoded values into flag-masking callers. pub inline fn fcntl(fd: fd_t, cmd: i32, arg: usize) FcntlError!i32 { - if (builtin.os.tag != .linux) @compileError(linux_only_msg); + if (builtin.os.tag == .macos) { + const rc = std.c.fcntl(fd, cmd, @as(c_int, @bitCast(@as(u32, @truncate(arg))))); + if (rc >= 0) return rc; + return @errorCast(mapErrno(std.c.errno(rc), &fcntl_map)); + } const rc = linux.fcntl(fd, cmd, arg); const e = linux.errno(rc); if (e == .SUCCESS) return @bitCast(@as(u32, @truncate(rc))); @@ -455,3 +495,41 @@ pub inline fn connect(sockfd: fd_t, sock_addr: *const anyopaque, len: u32) Conne if (e == .SUCCESS) return; return @errorCast(mapErrno(e, &connect_map)); } + +test "POSIX pipe flags and read/write round trip" { + const fds = try pipe2(.{ .CLOEXEC = true, .NONBLOCK = true }); + defer close(fds[0]); + defer close(fds[1]); + const nonblock: u32 = @bitCast(O{ .NONBLOCK = true }); + for (fds) |fd| { + try std.testing.expect((try fcntl(fd, F.GETFD, 0) & 1) != 0); + try std.testing.expect((@as(u32, @bitCast(try fcntl(fd, F.GETFL, 0))) & nonblock) != 0); + } + var buffer: [4]u8 = undefined; + try std.testing.expectError(error.WouldBlock, read(fds[0], &buffer)); + try std.testing.expectEqual(@as(usize, 4), try write(fds[1], "test")); + try std.testing.expectEqual(@as(usize, 4), try read(fds[0], &buffer)); + try std.testing.expectEqualStrings("test", &buffer); +} + +test "POSIX monotonic time advances during sleep" { + const before = nanoTimestamp(); + sleep(std.time.ns_per_ms); + try std.testing.expect(nanoTimestamp() > before); +} + +test "POSIX exec reports missing absolute paths" { + const args = [_:null]?[*:0]const u8{"/zt-does-not-exist"}; + const env = [_:null]?[*:0]const u8{}; + try std.testing.expectEqual(error.FileNotFound, execvpeZ(args[0].?, &args, &env)); +} + +test "macOS kqueue is close-on-exec and accepts an empty poll" { + if (builtin.os.tag != .macos) return error.SkipZigTest; + const fd = try kqueue(); + defer close(fd); + try std.testing.expect((try fcntl(fd, F.GETFD, 0) & 1) != 0); + var events: [1]Kevent = undefined; + const timeout: timespec = .{ .sec = 0, .nsec = 0 }; + try std.testing.expectEqual(@as(usize, 0), try kevent(fd, &.{}, &events, &timeout)); +} diff --git a/src/pty.zig b/src/pty.zig index 0cd9759..026d3ad 100644 --- a/src/pty.zig +++ b/src/pty.zig @@ -342,7 +342,7 @@ pub const Pty = struct { reaped = true; break; } - if (std.c.getErrno(rc) == .CHILD) { + if (std.c.errno(rc) == .CHILD) { reaped = true; break; } @@ -410,7 +410,7 @@ test "Pty: exec argv builder keeps all arguments" { test "Pty: spawn and read echo output" { // Skip when /dev/ptmx (Linux) is missing or invisible (e.g. restricted CI/sandbox). - var pty = Pty.spawn(80, 24, "/bin/echo", null) catch |err| switch (err) { + var pty = if (is_macos) try Pty.spawn(80, 24, "/bin/echo", null) else Pty.spawn(80, 24, "/bin/echo", null) catch |err| switch (err) { error.FileNotFound => return error.SkipZigTest, else => |e| return e, }; diff --git a/tools/package-macos.sh b/tools/package-macos.sh new file mode 100644 index 0000000..2a7a720 --- /dev/null +++ b/tools/package-macos.sh @@ -0,0 +1,38 @@ +#!/bin/sh +set -eu + +binary=${1:-zig-out/bin/zt} +bundle=${2:-zig-out/zt.app} +version=$(sed -n 's/.*\.version = "\([0-9][0-9.]*\)".*/\1/p' build.zig.zon) +if [ -z "$version" ]; then + echo "Run this script from the zt repository root" >&2 + exit 1 +fi + +if [ ! -x "$binary" ]; then + echo "Build zt first: zig build -Doptimize=ReleaseFast" >&2 + exit 1 +fi + +mkdir -p "$bundle/Contents/MacOS" +cp "$binary" "$bundle/Contents/MacOS/zt" +cat > "$bundle/Contents/Info.plist" < + + + + CFBundleExecutablezt + CFBundleIdentifierio.github.midasdf.zt + CFBundleNamezt + CFBundlePackageTypeAPPL + CFBundleShortVersionString$version + CFBundleVersion$version + LSMinimumSystemVersion14.0 + NSHighResolutionCapable + NSPrincipalClassNSApplication + + +PLIST + +codesign --force --sign - "$bundle" +echo "Created $bundle" From 884d8467b61fccc8519136f6e2de063ed2aa468f Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:30:40 +0900 Subject: [PATCH 2/4] Configure PTY nonblocking mode before short-lived children can exit --- src/pty.zig | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/src/pty.zig b/src/pty.zig index 026d3ad..f605dc5 100644 --- a/src/pty.zig +++ b/src/pty.zig @@ -121,6 +121,16 @@ pub const Pty = struct { ) catch return error.PathTooLong; }; + // Set nonblocking before forking. On Darwin, F_SETFL invokes a tty + // ioctl which can fail with ENOTTY once a short-lived child closes the + // slave. Configuring the master first avoids that exit race entirely. + { + const cur_flags = try posix.fcntl(master_fd, posix.F.GETFL, 0); + const O_NONBLOCK: u32 = @bitCast(posix.O{ .NONBLOCK = true }); + const new_flags: usize = @as(u32, @bitCast(cur_flags)) | O_NONBLOCK; + _ = try posix.fcntl(master_fd, posix.F.SETFL, new_flags); + } + // 4. Fork const pid = try posix.fork(); @@ -291,14 +301,6 @@ pub const Pty = struct { } // === Parent process === - // Set master_fd nonblocking - { - const cur_flags = try posix.fcntl(master_fd, posix.F.GETFL, 0); - const O_NONBLOCK: u32 = @bitCast(posix.O{ .NONBLOCK = true }); - const new_flags: usize = @as(u32, @bitCast(cur_flags)) | O_NONBLOCK; - _ = try posix.fcntl(master_fd, posix.F.SETFL, new_flags); - } - return Pty{ .master_fd = master_fd, .child_pid = pid, @@ -416,11 +418,18 @@ test "Pty: spawn and read echo output" { }; defer pty.deinit(); - // Wait for output - posix.sleep(100 * std.time.ns_per_ms); - var buf: [256]u8 = undefined; - const n = pty.read(&buf) catch 0; + const deadline = posix.nanoTimestamp() + 2 * std.time.ns_per_s; + const n = while (posix.nanoTimestamp() < deadline) { + const n = pty.read(&buf) catch |err| switch (err) { + error.WouldBlock => { + posix.sleep(10 * std.time.ns_per_ms); + continue; + }, + else => return err, + }; + break n; + } else @as(usize, 0); // echo with no args outputs "\r\n" or "\n" try testing.expect(n > 0); } From ed00846a7d65687270c263441012c4347d23fb44 Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:35:14 +0900 Subject: [PATCH 3/4] Keep Darwin PTY slave open during setup and correct Cocoa drag handling --- src/backend/macos.zig | 46 +++++++++++++++++++++++++++++++++++++------ src/pty.zig | 19 ++++++++++++++---- 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/src/backend/macos.zig b/src/backend/macos.zig index 3fa48b8..bc83376 100644 --- a/src/backend/macos.zig +++ b/src/backend/macos.zig @@ -327,6 +327,7 @@ pub const MacosBackend = struct { has_marked_text: bool = false, marked_length: u64 = 0, + marked_selection: NSRange = .{ .location = 0, .length = 0 }, ime_x: u32 = 0, ime_y: u32 = config.cell_height, autorelease_pool: id, @@ -575,6 +576,8 @@ pub const MacosBackend = struct { } pub fn updateTitle(self: *Self, title: []const u8) void { + const pool = msgSend_id(msgSend_id(cls("NSAutoreleasePool"), sel("alloc")), sel("init")); + defer msgSend_void(pool, sel("drain")); var buf: [512]u8 = undefined; const text = std.fmt.bufPrintZ(&buf, "{s}", .{title}) catch return; msgSend_void_id(self.window, sel("setTitle:"), createNSString(text)); @@ -694,9 +697,9 @@ fn registerZTViewClass() ?id { _ = class_addMethod(new_class, sel("rightMouseUp:"), @ptrCast(&ztMouseUp), "v@:@"); _ = class_addMethod(new_class, sel("otherMouseDown:"), @ptrCast(&ztMouseDown), "v@:@"); _ = class_addMethod(new_class, sel("otherMouseUp:"), @ptrCast(&ztMouseUp), "v@:@"); - _ = class_addMethod(new_class, sel("mouseDragged:"), @ptrCast(&ztMouseMoved), "v@:@"); - _ = class_addMethod(new_class, sel("rightMouseDragged:"), @ptrCast(&ztMouseMoved), "v@:@"); - _ = class_addMethod(new_class, sel("otherMouseDragged:"), @ptrCast(&ztMouseMoved), "v@:@"); + _ = class_addMethod(new_class, sel("mouseDragged:"), @ptrCast(&ztMouseDragged), "v@:@"); + _ = class_addMethod(new_class, sel("rightMouseDragged:"), @ptrCast(&ztMouseDragged), "v@:@"); + _ = class_addMethod(new_class, sel("otherMouseDragged:"), @ptrCast(&ztMouseDragged), "v@:@"); _ = class_addMethod(new_class, sel("mouseMoved:"), @ptrCast(&ztMouseMoved), "v@:@"); _ = class_addMethod(new_class, sel("scrollWheel:"), @ptrCast(&ztScrollWheel), "v@:@"); @@ -970,12 +973,15 @@ fn ztSetMarkedText(self_view: id, _: SEL, text: id, selected: NSRange, _: NSRang text; const bytes = std.mem.span(msgSend_cstr(str, sel("UTF8String")) orelse return); backend.marked_length = msgSend_u64(str, sel("length")); // Cocoa uses UTF-16 units + backend.marked_selection = selected; backend.has_marked_text = bytes.len > 0; var event: PreeditEvent = .{ .active = backend.has_marked_text }; const n = utf8PrefixLen(bytes, event.data.len); @memcpy(event.data[0..n], bytes[0..n]); @memset(event.feedback[0..n], 2); // underline composition event.len = @intCast(n); + // The inline preview is bounded like the other backends. Clamp only its + // rendered caret; AppKit still sees the full UTF-16 selection below. event.caret = utf16Caret(bytes[0..n], selected.location); backend.pushEvent(.{ .preedit = event }); } @@ -1029,7 +1035,10 @@ fn ztMarkedRange(self_view: id, _: SEL) callconv(.c) NSRange { return .{ .location = 0x7FFFFFFFFFFFFFFF, .length = 0 }; } -fn ztSelectedRange(_: id, _: SEL) callconv(.c) NSRange { +fn ztSelectedRange(view: id, _: SEL) callconv(.c) NSRange { + if (MacosBackend.getBackendFromView(view)) |backend| { + if (backend.has_marked_text) return backend.marked_selection; + } return .{ .location = 0, .length = 0 }; } @@ -1211,11 +1220,18 @@ fn mouseButton(event: id) MouseEvent.Button { } fn ztMouseDown(view: id, _: SEL, event: id) callconv(.c) void { - mouseEvent(view, event, .press, mouseButton(event)); + const button = mouseButton(event); + if (button != .none) mouseEvent(view, event, .press, button); } fn ztMouseUp(view: id, _: SEL, event: id) callconv(.c) void { - mouseEvent(view, event, .release, mouseButton(event)); + const button = mouseButton(event); + if (button != .none) mouseEvent(view, event, .release, button); +} + +fn ztMouseDragged(view: id, _: SEL, event: id) callconv(.c) void { + const button = mouseButton(event); + if (button != .none) mouseEvent(view, event, .motion, button); } fn ztMouseMoved(view: id, _: SEL, event: id) callconv(.c) void { @@ -1226,6 +1242,8 @@ fn ztScrollWheel(view: id, _: SEL, event: id) callconv(.c) void { const delta: *const fn (id, SEL) callconv(.c) f64 = @ptrCast(&objc_msgSend); const dy = delta(event, sel("scrollingDeltaY")); if (dy != 0) mouseEvent(view, event, .press, if (dy > 0) .wheel_up else .wheel_down); + const dx = delta(event, sel("scrollingDeltaX")); + if (dx != 0) mouseEvent(view, event, .press, if (dx > 0) .wheel_left else .wheel_right); } test "macOS text chunks preserve UTF-8 boundaries" { @@ -1265,6 +1283,7 @@ test "macOS Cocoa window, text composition, geometry and close integration" { try std.testing.expect(preedit.active); try std.testing.expectEqual(@as(u32, 5), preedit.caret); try std.testing.expectEqual(@as(u64, 4), ztMarkedRange(backend.view, sel("markedRange")).length); + try std.testing.expectEqual(@as(u64, 3), ztSelectedRange(backend.view, sel("selectedRange")).location); backend.updateImeCursorPos(config.cell_width, config.cell_height); const candidate = ztFirstRect(backend.view, sel("firstRectForCharacterRange:actualRange:"), range, null); try std.testing.expect(candidate.size.width > 0 and std.math.isFinite(candidate.origin.x)); @@ -1291,11 +1310,26 @@ test "macOS Cocoa window, text composition, geometry and close integration" { try std.testing.expect(msgSend_bool(menu, sel("performKeyEquivalent:"), copy_event)); try std.testing.expect(backend.popEvent().? == .copy_selection); + const mouse_event: *const fn (id, SEL, u64, CGPoint, u64, f64, i64, ?id, i64, i64, f32) callconv(.c) id = @ptrCast(&objc_msgSend); + const drag = mouse_event(cls("NSEvent"), sel("mouseEventWithType:location:modifierFlags:timestamp:windowNumber:context:eventNumber:clickCount:pressure:"), 6, .{ .x = 10, .y = 10 }, 0, 0, @intCast(msgSend_u64(backend.window, sel("windowNumber"))), null, 1, 1, 1); + ztMouseDragged(backend.view, sel("mouseDragged:"), drag); + const motion = backend.popEvent().?.mouse; + try std.testing.expectEqual(MouseEvent.Action.motion, motion.action); + try std.testing.expectEqual(MouseEvent.Button.left, motion.button); + ztMouseMoved(backend.view, sel("mouseMoved:"), drag); + try std.testing.expectEqual(MouseEvent.Button.none, backend.popEvent().?.mouse.button); + // Large pastes must survive in full, including a multibyte final character. const large = try std.testing.allocator.allocSentinel(u8, 20003, 0); defer std.testing.allocator.free(large); @memset(large[0..20000], 'x'); @memcpy(large[20000..20003], "日"); + ztSetMarkedText(backend.view, sel("setMarkedText:selectedRange:replacementRange:"), createNSString(large), .{ .location = 20001, .length = 0 }, range); + const preview = backend.popEvent().?.preedit; + try std.testing.expectEqual(@as(u32, 128), preview.caret); + try std.testing.expectEqual(@as(u64, 20001), ztSelectedRange(backend.view, sel("selectedRange")).location); + ztUnmarkText(backend.view, sel("unmarkText")); + _ = backend.popEvent(); queuePaste(&backend, createNSString(large)); const paste = backend.popEvent().?.paste; try std.testing.expectEqualStrings(large, paste.slice()); diff --git a/src/pty.zig b/src/pty.zig index f605dc5..b72d0a4 100644 --- a/src/pty.zig +++ b/src/pty.zig @@ -121,9 +121,16 @@ pub const Pty = struct { ) catch return error.PathTooLong; }; - // Set nonblocking before forking. On Darwin, F_SETFL invokes a tty - // ioctl which can fail with ENOTTY once a short-lived child closes the - // slave. Configuring the master first avoids that exit race entirely. + // Darwin 14 requires an open slave for F_SETFL's tty ioctl. Open it in + // the parent and inherit it in the child so it stays alive both during + // nonblocking setup and across fork, even for immediately exiting commands. + const inherited_slave: ?posix.fd_t = if (is_macos) + try posix.open(slave_path, .{ .ACCMODE = .RDWR, .NOCTTY = true, .CLOEXEC = true }, 0) + else + null; + defer if (inherited_slave) |fd| posix.close(fd); + + // Configure the master while the slave is alive, before forking. { const cur_flags = try posix.fcntl(master_fd, posix.F.GETFL, 0); const O_NONBLOCK: u32 = @bitCast(posix.O{ .NONBLOCK = true }); @@ -158,7 +165,7 @@ pub const Pty = struct { } // b. Open slave fd - const slave_fd = posix.open( + const slave_fd = inherited_slave orelse posix.open( slave_path, .{ .ACCMODE = .RDWR }, 0, @@ -185,6 +192,10 @@ pub const Pty = struct { posix.dup2(slave_fd, 0) catch posix.exit(1); posix.dup2(slave_fd, 1) catch posix.exit(1); posix.dup2(slave_fd, 2) catch posix.exit(1); + // dup2(fd, fd) preserves CLOEXEC if launch began with closed stdio. + if (is_macos) { + for (0..3) |fd| _ = posix.fcntl(@intCast(fd), posix.F.SETFD, 0) catch posix.exit(1); + } // e. Close original slave fd (now duped to 0/1/2) if (slave_fd > 2) posix.close(slave_fd); From f32d9b44579c829ee1cc02c8d4f94be18a52870f Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:52:02 +0900 Subject: [PATCH 4/4] Prepare 0.10.0 release highlighting native Mac support --- .github/workflows/release.yml | 12 ++++++++++-- README.md | 29 +++++++++++++++++++++++------ build.zig | 2 +- build.zig.zon | 2 +- docs/releases/v0.10.0.md | 33 +++++++++++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 10 deletions(-) create mode 100644 docs/releases/v0.10.0.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a794b6..982718c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -210,8 +210,15 @@ jobs: 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 \ @@ -223,3 +230,4 @@ jobs: zt-aarch64-macos.zip \ zt-x86_64-macos.zip \ --clobber + gh release edit "${TAG}" --draft=false --latest diff --git a/README.md b/README.md index af2c4a7..5e28ccd 100644 --- a/README.md +++ b/README.md @@ -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). 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. @@ -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 @@ -59,6 +62,20 @@ Requires Zig 0.16.0. Zig 0.17 changes the language and standard library and is n ### 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 diff --git a/build.zig b/build.zig index 7b9e76b..a1684f4 100644 --- a/build.zig +++ b/build.zig @@ -100,7 +100,7 @@ pub fn build(b: *std.Build) void { 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"), diff --git a/build.zig.zon b/build.zig.zon index 854ae5c..765e8e6 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .zt, - .version = "0.9.3", + .version = "0.10.0", .minimum_zig_version = "0.16.0", .fingerprint = 0xc36136a6af00f24b, .paths = .{ diff --git a/docs/releases/v0.10.0.md b/docs/releases/v0.10.0.md new file mode 100644 index 0000000..b124943 --- /dev/null +++ b/docs/releases/v0.10.0.md @@ -0,0 +1,33 @@ +# zt 0.10.0 — Native Mac support + +zt now officially supports **macOS 14 and later on Apple Silicon and Intel**. The native Cocoa/AppKit backend brings zt to the Mac without X11 or third-party runtime libraries. + +## Get started on Mac + +Download **`zt-aarch64-macos.zip`** for Apple Silicon or **`zt-x86_64-macos.zip`** for Intel, extract the archive, and open `zt.app`. Standalone `.bin` executables are also included. + +Prefer building 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 +``` + +## Mac highlights + +- Native Cocoa windows and menus, with `/bin/zsh` as the default login shell. +- Cmd+C / Cmd+V for copy and paste; Cmd+W / Cmd+Q for closing and quitting. Large pastes are preserved in full. +- Native input-method composition, inline preedit, and IME candidate positioning, with Japanese and emoji text support. +- Mouse selection, button-aware dragging, vertical and horizontal scrolling, and terminal mouse reporting. +- Resizing updates the terminal grid and PTY. Finder launches start at home; CLI launches preserve the current directory. +- Darwin PTY lifecycle fixes, correct Intel Objective-C struct returns, and responsive event handling during sustained output. +- Apple Silicon and Intel CI now exercise builds, real PTY I/O, Cocoa integration, and app signature verification. + +## Linux and distribution + +Linux framebuffer, X11, and Wayland support continues. This release includes x86_64 and aarch64 Linux executables for both X11 and Wayland, alongside the new Mac binaries and app bundles. + +Mac bundles are ad hoc signed and **not notarized by Apple**. Physical IME candidate interaction and multiple-display behavior remain manual QA items; see the [macOS QA checklist](https://github.com/midasdf/zt/blob/v0.10.0/docs/macos-qa.md). Zig **0.16.0** is required for source builds.