commit 738cead680f0d4bf97bf639681396a51b06c4c6c Author: Greyson Parrelli Date: Tue Aug 11 08:36:10 2026 -0400 vtabs: terminal with vertical tabs on libghostty-vt + GTK4 Uses libghostty-vt (the API Ghostty documents for external embedders) for the terminal core. Ghostty's other C API, ghostty.h, exposes a full terminal surface but only supports macOS and iOS platform tags, so it cannot be embedded on Linux. We supply the layers libghostty-vt deliberately leaves out: PTY and process management, a Cairo/Pango cell renderer, and a GTK4/libadwaita UI with a Zen-style vertical tab sidebar. Nix pins the whole toolchain (Zig 0.16 via zig-overlay, GTK 4.22, libadwaita) so no host setup is needed. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d29f66 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.zig-cache/ +zig-out/ +# Zig 0.16 unpacks fetched dependencies here; they're reproducible from +# build.zig.zon and must not be committed. +zig-pkg/ +result diff --git a/README.md b/README.md new file mode 100644 index 0000000..f9d4e45 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# vtabs + +A proof-of-concept terminal emulator with **vertical tabs**, built on +[libghostty-vt](https://github.com/ghostty-org/ghostty) and GTK4/libadwaita. + +The sidebar holds the window controls, a new-tab button, and one row per tab — +the layout Zen Browser uses for vertical tabs — with the terminal inset to its +right. + +## Quick start + +```sh +nix develop # Zig 0.16 + GTK4 + libadwaita, no host toolchain needed +zig build run +``` + +Everything is pinned by `flake.nix`; nothing needs to be installed on the host. + +## Which "libghostty" this uses, and why + +Ghostty ships two different C APIs, and only one of them is usable here: + +| | `include/ghostty.h` | `include/ghostty/vt.h` | +|---|---|---| +| name | "libghostty-internal" | **libghostty-vt** | +| scope | whole terminal incl. renderer | VT core only | +| platforms | `GHOSTTY_PLATFORM_MACOS`, `GHOSTTY_PLATFORM_IOS` | any | +| intended for | Ghostty's own macOS app | external embedders | + +`ghostty.h` is the one that would hand you a ready-made terminal surface, but +it has no Linux platform tag at all — its `ghostty_platform_e` enum only knows +macOS and iOS, and its own header says it is "tailored to the needs of the +macOS app". On Linux, Ghostty does not embed itself through that API; it builds +its GTK apprt directly into the binary. + +So this project uses **libghostty-vt**, the API Ghostty documents for external +embedders. That means we get, for free and battle-tested: + +- escape sequence parsing and full terminal state +- screen, scrollback, line wrapping, and reflow on resize +- key and mouse event encoding (including the Kitty keyboard protocol) +- paste safety checking and bracketed paste encoding + +and we supply everything above it: process management, rendering, and the UI. + +We consume it as a **Zig module** rather than through the C ABI. Ghostty's +`build.zig` detects that it is being used as a dependency and, in that mode, +builds only libghostty-vt — no GTK, no executable — so `zig build` pulls in +just the VT core. + +## Architecture + +``` +main.zig AdwApplication, CSS loading +Window.zig sidebar + GtkStack of terminals, tab management, shortcuts +Terminal.zig GtkDrawingArea: Cairo/Pango renderer + input handling +Session.zig libghostty-vt Terminal + parser, fed by the PTY +Pty.zig openpt/fork/exec, controlling terminal setup +key.zig GDK keyval -> libghostty-vt key mapping +theme.zig colors libghostty-vt has no opinion about +``` + +Two design choices worth calling out: + +**No IO thread.** The PTY is read on the GLib main loop through a unix fd +watch (`g_unix_fd_add`). Terminal state is therefore only ever touched from the +main thread, so the renderer reads the screen with no locking. Ghostty itself +uses a dedicated IO thread; that is the right answer for a real terminal, but +this is dramatically simpler and is not a bottleneck at interactive speeds. + +**No GPU renderer.** Ghostty rasterizes glyphs into an atlas and draws on the +GPU. Here, each frame walks the visible rows, groups cells into runs of +identical style, and hands each run to Pango. That is far more work per frame +in principle, but a terminal grid is small. + +## What works + +- Real shell on a real PTY, with a controlling terminal so job control, + Ctrl-C and SIGWINCH behave +- Full SGR rendering: 16/256/true color, bold, italic, underline, + strikethrough, inverse, and the bright-on-bold convention +- Block / bar / underline / hollow cursor styles +- Scrollback via mouse wheel; typing snaps back to the prompt +- Resize reflows the grid and notifies the child +- Window title (OSC 0/2) becomes the tab label +- Tabs: create, close, switch; closing the last one closes the window; + a child exiting closes its own tab + +### Shortcuts + +| | | +|---|---| +| `Ctrl+Shift+T` | new tab | +| `Ctrl+Shift+W` | close tab | +| `Ctrl+Shift+V` | paste (bracketed-paste aware, refuses unsafe pastes) | +| `Ctrl+PageUp/PageDown` | previous / next tab | +| `Alt+1`..`Alt+8` | jump to tab N, `Alt+9` jumps to the last | + +## Not implemented + +This is a proof of concept, and the following are deliberately absent: + +- **Mouse selection and copy.** libghostty-vt provides the selection and + mouse-encoding primitives, but nothing here is wired to them yet, so there is + no way to copy text. Paste works. +- **Ligatures and complex shaping.** Each run is drawn independently at a + fixed grid offset, so text that needs shaping across cell boundaries won't + look right. +- **Kitty graphics, hyperlinks, tab reordering, split panes, config file.** +- **Custom terminfo.** `TERM` is reported as `xterm-256color` rather than + `ghostty`, since we don't install a terminfo entry. + +## Development + +`./b.sh` wraps `nix develop --command zig build` and strips the enormous +command line Zig prints on failure. + +`./shot.sh out.png "text to type"` runs the app inside a throwaway **headless +Sway** and screenshots it. This keeps UI checks entirely out of your real +Wayland session — nothing appears on screen, and it works while the session is +locked. + +Screenshots use `$SHELL` like the real app does; set +`VTABS_SHOT_SHELL=/bin/sh` for output uncluttered by your shell's rc files. + +One caveat when driving it: `wtype` loses the first keystroke of every +invocation while the compositor adopts its freshly uploaded keymap, so scripted +input should begin with a throwaway key. That is a quirk of the injection tool, +not of the terminal. diff --git a/b.sh b/b.sh new file mode 100755 index 0000000..d58ce3d --- /dev/null +++ b/b.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Build helper: zig build emits an enormous "failed command" line on error, +# which drowns out the actual diagnostics. Strip it. +cd "$(dirname "$0")" +nix develop --command zig build "$@" 2>&1 \ + | grep -v "^warning: Git tree" \ + | sed '/^failed command:/,$d' diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..8f8f4e7 --- /dev/null +++ b/build.zig @@ -0,0 +1,61 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // libghostty-vt provides the terminal emulator core: escape sequence + // parsing, screen/scrollback state, and input encoding. Everything above + // it (PTY, rendering, windowing) is ours. + const ghostty = b.dependency("ghostty", .{ + .target = target, + .optimize = optimize, + }); + + // GTK4/libadwaita bindings. This is the same generated binding set + // Ghostty uses for its Linux apprt, so it matches the GObject + // introspection data of the GTK we link against. + const gobject = b.dependency("gobject", .{ + .target = target, + .optimize = optimize, + }); + + const exe = b.addExecutable(.{ + .name = "vtabs", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }), + }); + + exe.root_module.addImport("ghostty-vt", ghostty.module("ghostty-vt")); + + const gobject_imports = .{ + .{ "adw", "adw1" }, + .{ "cairo", "cairo1" }, + .{ "gdk", "gdk4" }, + .{ "gio", "gio2" }, + .{ "glib", "glib2" }, + .{ "glibunix", "glibunix2" }, + .{ "gobject", "gobject2" }, + .{ "gtk", "gtk4" }, + .{ "pango", "pango1" }, + .{ "pangocairo", "pangocairo1" }, + }; + inline for (gobject_imports) |import| { + const name, const module = import; + exe.root_module.addImport(name, gobject.module(module)); + } + + exe.root_module.linkSystemLibrary("gtk4", .{}); + exe.root_module.linkSystemLibrary("libadwaita-1", .{}); + + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + b.step("run", "Run the app").dependOn(&run_cmd.step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..fadaa2c --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,21 @@ +.{ + .name = .vtabs, + .version = "0.1.0", + .fingerprint = 0x833f5d0dd064e02a, + .minimum_zig_version = "0.16.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, + .dependencies = .{ + .ghostty = .{ + .url = "https://github.com/ghostty-org/ghostty/archive/94d775fefc21f74d9cc85a46b34c4e1d85318fd0.tar.gz", + .hash = "ghostty-1.3.2-dev-5UdBCyCSPwV-Pp82UMGLutqeo0gckMrI0GfbUbUY0-Jt", + }, + .gobject = .{ + .url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.10.0-2026-07-28-36-1/ghostty-gobject-0.10.0-2026-07-28-36-1.tar.zst", + .hash = "gobject-0.3.2-Skun7F6HogCMynX2JqeSHS7xr-8pK4ob-qRFIcEasVi3", + }, + }, +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..364ce8d --- /dev/null +++ b/flake.lock @@ -0,0 +1,85 @@ +{ + "nodes": { + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1767039857, + "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1786348146, + "narHash": "sha256-QqTLx1vezpyalH9RD66nuAenX9hbnH+gD73R7lJxnWA=", + "rev": "d482ef84049d9b7276b83a06e4e4d76983830097", + "type": "tarball", + "url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.11pre1051111.d482ef84049d/nixexprs.tar.xz" + }, + "original": { + "type": "tarball", + "url": "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz" + } + }, + "root": { + "inputs": { + "flake-compat": "flake-compat", + "nixpkgs": "nixpkgs", + "systems": "systems", + "zig": "zig" + } + }, + "systems": { + "flake": false, + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "zig": { + "inputs": { + "flake-compat": [ + "flake-compat" + ], + "nixpkgs": [ + "nixpkgs" + ], + "systems": [ + "systems" + ] + }, + "locked": { + "lastModified": 1786364596, + "narHash": "sha256-rMHN2/qJ10ORy+PfSm8hfG2mCdnsYHsWrKeq8DLraec=", + "owner": "mitchellh", + "repo": "zig-overlay", + "rev": "840c2310085417892ec51f691002ca0a497d0287", + "type": "github" + }, + "original": { + "owner": "mitchellh", + "repo": "zig-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..fc8b531 --- /dev/null +++ b/flake.nix @@ -0,0 +1,97 @@ +{ + description = "Vertical-tab terminal built on libghostty-vt"; + + inputs = { + # Ghostty tracks nixpkgs-unstable to get GTK 4.20 / GNOME 49. We follow + # suit so that our zig-gobject bindings (shared with Ghostty) match the + # GObject introspection data of the GTK we link against. + nixpkgs.url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz"; + + flake-compat = { + url = "github:edolstra/flake-compat"; + flake = false; + }; + + systems = { + url = "github:nix-systems/default"; + flake = false; + }; + + # Zig 0.16.0 is not in nixpkgs yet; libghostty-vt requires it. + zig = { + url = "github:mitchellh/zig-overlay"; + inputs = { + nixpkgs.follows = "nixpkgs"; + flake-compat.follows = "flake-compat"; + systems.follows = "systems"; + }; + }; + }; + + outputs = { + self, + nixpkgs, + zig, + systems, + ... + }: let + inherit (nixpkgs) lib legacyPackages; + + forAllSystems = f: + lib.genAttrs (import systems) (system: f legacyPackages.${system} system); + in { + devShells = forAllSystems (pkgs: system: { + default = pkgs.mkShell { + name = "vtabs"; + + nativeBuildInputs = [ + zig.packages.${system}."0.16.0" + pkgs.pkg-config + pkgs.gdb + + # Used by ./shot.sh to run the app inside a throwaway headless + # compositor and screenshot it, so UI can be checked without + # touching the developer's real session. + pkgs.sway + pkgs.grim + pkgs.wtype + ]; + + buildInputs = with pkgs; [ + # GTK4 + libadwaita, the same native UI stack Ghostty uses on Linux. + gtk4 + libadwaita + glib + gobject-introspection + pango + cairo + harfbuzz + gdk-pixbuf + graphene + + # Windowing backends GTK links against. + wayland + libxkbcommon + libx11 + + # Font stack used by Pango for glyph rasterization. + fontconfig + freetype + + # Icon themes so the app doesn't render missing-image icons. + adwaita-icon-theme + hicolor-icon-theme + ]; + + shellHook = '' + # GTK needs to find icon themes and schemas at runtime when the app + # is launched straight out of the dev shell rather than installed. + export XDG_DATA_DIRS="${pkgs.gtk4}/share/gsettings-schemas/${pkgs.gtk4.name}:${pkgs.adwaita-icon-theme}/share:${pkgs.hicolor-icon-theme}/share:$XDG_DATA_DIRS" + export GSETTINGS_SCHEMA_DIR="${pkgs.gtk4}/share/gsettings-schemas/${pkgs.gtk4.name}/glib-2.0/schemas" + ''; + }; + }); + + formatter = forAllSystems (pkgs: _: pkgs.alejandra); + }; +} diff --git a/shot.sh b/shot.sh new file mode 100755 index 0000000..7028bfd --- /dev/null +++ b/shot.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Run vtabs inside a throwaway headless Sway and screenshot it. +# +# This keeps UI checks out of the developer's real Wayland session: nothing +# pops up on screen, and it works even while the session is locked. +# +# Usage: ./shot.sh OUT.png ["keys to type"] [settle-seconds] +set -u + +cd "$(dirname "$0")" +OUT="${1:-/tmp/vtabs-shot.png}" +KEYS="${2:-}" +SETTLE="${3:-2}" + +RUNDIR="$(mktemp -d)" +trap 'rm -rf "$RUNDIR"' EXIT + +cat > "$RUNDIR/sway.conf" <'$RUNDIR/app.log' 2>&1" +CONF + +# The nix dev shell exports bash functions into the environment. Any /bin/sh +# the terminal's child spawns tries to import them and spews parse errors that +# have nothing to do with vtabs, so drop them just before exec. +cat > "$RUNDIR/launch.sh" <<'LAUNCH' +#!/usr/bin/env bash +while read -r fn; do unset -f "$fn" 2>/dev/null; done < <(declare -Fx | awk '{print $3}') +exec "$VTABS_BIN" +LAUNCH +chmod +x "$RUNDIR/launch.sh" +export VTABS_BIN="$PWD/zig-out/bin/vtabs" + +export WLR_BACKENDS=headless +export WLR_LIBINPUT_NO_DEVICES=1 +# No GPU is available to a headless compositor here, so force software +# rendering on both the compositor and GTK sides. +export WLR_RENDERER=pixman +export LIBGL_ALWAYS_SOFTWARE=1 +export GSK_RENDERER=cairo +export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" +# The nix dev shell points SHELL at a minimal bash (no readline, no +# programmable completion), which makes screenshots look broken for reasons +# that have nothing to do with vtabs. Use the system shell instead. +export SHELL="${VTABS_SHOT_SHELL:-/bin/bash}" +export SWAYSOCK="$RUNDIR/sway.sock" +# Don't let the app fall back to the real session. +unset DISPLAY +unset WAYLAND_DISPLAY + +for sock in "$XDG_RUNTIME_DIR"/wayland-*; do + [ -S "$sock" ] && basename "$sock" +done > "$RUNDIR/pre-sockets" 2>/dev/null || true + +sway -c "$RUNDIR/sway.conf" >"$RUNDIR/sway.log" 2>&1 & +SWAY_PID=$! + +# Wait for the compositor socket before talking to it. +for _ in $(seq 1 50); do + [ -S "$SWAYSOCK" ] && break + sleep 0.2 +done + +# Sway picks its own socket name, so discover it by diffing the sockets in +# XDG_RUNTIME_DIR against the ones that existed before we started. +DISPLAY_NAME="" +for _ in $(seq 1 50); do + for sock in "$XDG_RUNTIME_DIR"/wayland-*; do + case "$sock" in *.lock) continue;; esac + [ -S "$sock" ] || continue + name="$(basename "$sock")" + grep -qx "$name" "$RUNDIR/pre-sockets" && continue + DISPLAY_NAME="$name" + break + done + [ -n "$DISPLAY_NAME" ] && break + sleep 0.2 +done +if [ -z "$DISPLAY_NAME" ]; then + echo "could not determine wayland display" >&2 + cat "$RUNDIR/sway.log" >&2 + exit 1 +fi +export WAYLAND_DISPLAY="$DISPLAY_NAME" + +sleep "$SETTLE" + +if [ -n "$KEYS" ]; then + # wtype drops the first keystroke of every invocation while the compositor + # takes up its freshly uploaded keymap, so burn one on a harmless key. + wtype -k Shift_L 2>/dev/null || true + sleep 0.3 + wtype "$KEYS" 2>/dev/null || true + sleep 1.5 +fi + +if [ -n "${VTABS_SHOT_DEBUG:-}" ]; then + echo "--- swaymsg get_tree (apps) ---" >&2 + swaymsg -t get_tree 2>&1 | grep -E '"(app_id|name|pid)"' | head -30 >&2 + echo "--- sway log ---" >&2 + cat "$RUNDIR/sway.log" >&2 + echo "--- app log ---" >&2 + cat "$RUNDIR/app.log" >&2 2>/dev/null || echo "(no app log)" >&2 +fi + +if ! grim -o HEADLESS-1 "$OUT"; then + echo "grim -o failed, trying full capture" >&2 + grim "$OUT" || echo "grim failed entirely" >&2 +fi + +swaymsg exit >/dev/null 2>&1 || kill "$SWAY_PID" 2>/dev/null +wait "$SWAY_PID" 2>/dev/null + +echo "wrote $OUT" +# Surface app-side errors, which sway captures on its stdout. +grep -iE "error|warn|panic|segfault" "$RUNDIR/sway.log" | grep -v "portal" | head -20 || true diff --git a/src/Pty.zig b/src/Pty.zig new file mode 100644 index 0000000..9de14ac --- /dev/null +++ b/src/Pty.zig @@ -0,0 +1,205 @@ +//! A pseudoterminal pair with a child process (the shell) running on the +//! slave side. libghostty-vt deliberately has no opinion about process +//! management, so this is ours to own. +//! +//! The libc calls are declared directly rather than going through std.posix. +//! PTY setup is inherently POSIX-specific, and std.posix has been churning, +//! so explicit externs keep this module readable and stable. + +const std = @import("std"); + +const Pty = @This(); + +// Linux ioctl request numbers. This app is Linux-only (GTK4/Wayland), so +// inlining these is simpler than chasing them through std across versions. +const TIOCSCTTY = 0x540E; +const TIOCSWINSZ = 0x5414; + +const fd_t = std.c.fd_t; +const pid_t = std.c.pid_t; + +/// The libc surface this module needs, kept in its own namespace so the +/// names don't collide with our own methods. +const c = struct { + extern "c" fn posix_openpt(flags: c_int) c_int; + extern "c" fn grantpt(fd: c_int) c_int; + extern "c" fn unlockpt(fd: c_int) c_int; + extern "c" fn ptsname_r(fd: c_int, buf: [*]u8, buflen: usize) c_int; + extern "c" fn setsid() pid_t; + extern "c" fn fork() pid_t; + extern "c" fn close(fd: fd_t) c_int; + extern "c" fn dup2(old: fd_t, new: fd_t) c_int; + extern "c" fn open(path: [*:0]const u8, flags: c_int, ...) c_int; + extern "c" fn ioctl(fd: fd_t, request: c_ulong, ...) c_int; + extern "c" fn write(fd: fd_t, buf: [*]const u8, count: usize) isize; + extern "c" fn read(fd: fd_t, buf: [*]u8, count: usize) isize; + extern "c" fn execvpe( + file: [*:0]const u8, + argv: [*:null]const ?[*:0]const u8, + envp: [*:null]const ?[*:0]const u8, + ) c_int; + extern "c" fn _exit(code: c_int) noreturn; + extern "c" fn kill(pid: pid_t, sig: c_int) c_int; + extern "c" fn waitpid(pid: pid_t, status: ?*c_int, options: c_int) pid_t; +}; + +const O_RDWR = 0x0002; +const O_NOCTTY = 0x0100; +const SIGHUP = 1; + +pub const Winsize = extern struct { + ws_row: u16, + ws_col: u16, + ws_xpixel: u16 = 0, + ws_ypixel: u16 = 0, +}; + +/// The master side of the pair. Read terminal output from it, write user +/// input to it. +master: fd_t, + +/// PID of the child process on the slave side. +pid: pid_t, + +pub const Error = error{ + OpenPtFailed, + GrantPtFailed, + UnlockPtFailed, + PtsNameFailed, + ForkFailed, +}; + +/// Open a PTY pair and fork `argv` onto the slave side. The child gets its +/// own session with the slave as controlling terminal, so job control and +/// signals (Ctrl-C, SIGWINCH) work the way a shell expects. +pub fn create( + alloc: std.mem.Allocator, + argv: []const [:0]const u8, + size: Winsize, +) !Pty { + const master = c.posix_openpt(O_RDWR | O_NOCTTY); + if (master < 0) return Error.OpenPtFailed; + errdefer _ = c.close(master); + + if (c.grantpt(master) != 0) return Error.GrantPtFailed; + if (c.unlockpt(master) != 0) return Error.UnlockPtFailed; + + var name_buf: [128]u8 = undefined; + if (c.ptsname_r(master, &name_buf, name_buf.len) != 0) return Error.PtsNameFailed; + const slave_path = std.mem.sliceTo(&name_buf, 0); + + _ = c.ioctl(master, TIOCSWINSZ, &size); + + // Everything the child needs must be allocated before the fork. After + // forking, only async-signal-safe work is legal in the child. + const argv_z = try alloc.allocSentinel(?[*:0]const u8, argv.len, null); + defer alloc.free(argv_z); + for (argv, 0..) |arg, i| argv_z[i] = arg.ptr; + + const envp_z = try buildEnv(alloc); + defer freeEnv(alloc, envp_z); + + const slave_path_z = try alloc.dupeZ(u8, slave_path); + defer alloc.free(slave_path_z); + + const pid = c.fork(); + if (pid < 0) return Error.ForkFailed; + if (pid == 0) { + childExec(master, slave_path_z, argv_z, envp_z); + // childExec only returns on failure, and a forked child has no + // sensible way to report that back to us. + c._exit(127); + } + + return .{ .master = master, .pid = pid }; +} + +/// The child half of the fork. Only returns if something failed. +fn childExec( + master: fd_t, + slave_path: [:0]const u8, + argv: [:null]const ?[*:0]const u8, + envp: [:null]const ?[*:0]const u8, +) void { + _ = c.close(master); + + // A new session detaches us from the parent's controlling terminal so + // that we can claim the slave as our own below. + if (c.setsid() < 0) return; + + const slave = c.open(slave_path.ptr, O_RDWR); + if (slave < 0) return; + + // Claim the slave as this session's controlling terminal. Without this + // the shell has no way to deliver SIGINT to foreground jobs. + if (c.ioctl(slave, TIOCSCTTY, @as(c_int, 0)) < 0) return; + + if (c.dup2(slave, 0) < 0) return; + if (c.dup2(slave, 1) < 0) return; + if (c.dup2(slave, 2) < 0) return; + if (slave > 2) _ = c.close(slave); + + _ = c.execvpe(argv[0].?, argv.ptr, envp.ptr); +} + +/// Copy the current environment, forcing the variables that describe what +/// kind of terminal we are. We advertise xterm-256color rather than +/// ghostty's own terminfo because we don't install a terminfo entry. +fn buildEnv(alloc: std.mem.Allocator) ![:null]?[*:0]const u8 { + var list: std.ArrayListUnmanaged([*:0]const u8) = .empty; + defer list.deinit(alloc); + errdefer for (list.items) |item| alloc.free(std.mem.span(item)); + + var i: usize = 0; + while (std.c.environ[i]) |entry| : (i += 1) { + const span = std.mem.span(entry); + // Drop the variables we're about to define ourselves. + if (std.mem.startsWith(u8, span, "TERM=")) continue; + if (std.mem.startsWith(u8, span, "COLORTERM=")) continue; + try list.append(alloc, (try alloc.dupeZ(u8, span)).ptr); + } + try list.append(alloc, (try alloc.dupeZ(u8, "TERM=xterm-256color")).ptr); + try list.append(alloc, (try alloc.dupeZ(u8, "COLORTERM=truecolor")).ptr); + + const result = try alloc.allocSentinel(?[*:0]const u8, list.items.len, null); + for (list.items, 0..) |item, idx| result[idx] = item; + return result; +} + +fn freeEnv(alloc: std.mem.Allocator, envp: [:null]?[*:0]const u8) void { + for (envp) |entry| if (entry) |e| alloc.free(std.mem.span(e)); + alloc.free(envp); +} + +/// Tell the child its window changed size. This both updates the kernel's +/// idea of the terminal size and delivers SIGWINCH to the foreground group. +pub fn setSize(self: Pty, size: Winsize) void { + _ = c.ioctl(self.master, TIOCSWINSZ, &size); +} + +/// Returns the number of bytes read, or null if the PTY hung up. +pub fn read(self: Pty, buf: []u8) ?usize { + const n = c.read(self.master, buf.ptr, buf.len); + if (n <= 0) return null; + return @intCast(n); +} + +pub fn writeAll(self: Pty, bytes: []const u8) void { + var off: usize = 0; + while (off < bytes.len) { + const n = c.write(self.master, bytes.ptr + off, bytes.len - off); + // Best-effort: if the child has exited the write fails with EPIPE + // and there is nothing useful to do about it here. + if (n <= 0) return; + off += @intCast(n); + } +} + +pub fn deinit(self: *Pty) void { + _ = c.close(self.master); + // Closing the master sends SIGHUP to the child's session. Reap it so we + // don't leave a zombie behind. + _ = c.kill(self.pid, SIGHUP); + _ = c.waitpid(self.pid, null, 0); + self.* = undefined; +} diff --git a/src/Session.zig b/src/Session.zig new file mode 100644 index 0000000..c8aed18 --- /dev/null +++ b/src/Session.zig @@ -0,0 +1,203 @@ +//! One terminal session: a libghostty-vt terminal fed by a PTY. +//! +//! The PTY is read on the GLib main loop via a unix fd watch rather than a +//! dedicated IO thread. That keeps VT state single-threaded, so the renderer +//! can read the screen directly with no locking. A real terminal would want +//! Ghostty's threaded IO, but for a proof of concept this is much simpler and +//! is plenty fast for interactive use. + +const std = @import("std"); +const glib = @import("glib"); +const glibunix = @import("glibunix"); +const vt = @import("ghostty-vt"); + +const Pty = @import("Pty.zig"); + +const Session = @This(); + +/// Read buffer size. Large enough that a `cat` of a big file doesn't spend +/// all its time bouncing through the main loop. +const read_buf_size = 64 * 1024; + +alloc: std.mem.Allocator, + +/// The terminal emulator state. Must be the first field referenced by +/// `fromTerminal` below, which recovers the Session from stream callbacks. +term: vt.Terminal, + +/// Persistent parser state. Escape sequences can and do get split across +/// read boundaries, so this must outlive individual reads. +stream: vt.TerminalStream, + +pty: Pty, + +/// GLib source ID for the PTY read watch, so we can cancel it on teardown. +watch: c_uint = 0, + +/// True once the child process has exited and the PTY hung up. +exited: bool = false, + +/// Called after terminal state changes, so the owner can queue a redraw. +on_damage: *const fn (ctx: ?*anyopaque) void, + +/// Called when the terminal title changes (OSC 0/2). +on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void, + +/// Called when the child process exits. +on_exit: *const fn (ctx: ?*anyopaque) void, + +ctx: ?*anyopaque = null, + +pub const Callbacks = struct { + on_damage: *const fn (ctx: ?*anyopaque) void, + on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void, + on_exit: *const fn (ctx: ?*anyopaque) void, + ctx: ?*anyopaque, +}; + +pub fn create( + alloc: std.mem.Allocator, + cols: u16, + rows: u16, + cbs: Callbacks, +) !*Session { + const self = try alloc.create(Session); + errdefer alloc.destroy(self); + + const tio: vt.TinyIo = .init; + self.* = .{ + .alloc = alloc, + .term = try .init(tio.io(), alloc, .{ + .cols = cols, + .rows = rows, + .max_scrollback_bytes = 8 * 1024 * 1024, + }), + .stream = undefined, + .pty = undefined, + .on_damage = cbs.on_damage, + .on_title = cbs.on_title, + .on_exit = cbs.on_exit, + .ctx = cbs.ctx, + }; + errdefer self.term.deinit(alloc); + + self.stream = self.term.vtStream(); + errdefer self.stream.deinit(); + + // Wire up the side effects we care about. `readonly` handles terminal + // state but silently drops anything that needs to talk back to the + // child; we need the writes so that queries (cursor position, device + // attributes, in-band resize) get answers. + var effects = vt.TerminalStream.Handler.Effects.readonly; + effects.write_pty = &effectWritePty; + effects.title_changed = &effectTitleChanged; + self.stream.handler.effects = effects; + + const shell = try defaultShell(alloc); + defer alloc.free(shell); + + // A leading '-' in argv[0] tells the shell to start as a login shell. + const argv0 = try std.fmt.allocPrintSentinel(alloc, "-{s}", .{ + std.fs.path.basename(shell), + }, 0); + defer alloc.free(argv0); + + self.pty = try .create(alloc, &.{ shell, argv0 }, .{ + .ws_row = rows, + .ws_col = cols, + }); + errdefer self.pty.deinit(); + + self.watch = glibunix.fdAdd( + self.pty.master, + .{ .in = true, .hup = true, .err = true }, + &onReadable, + self, + ); + + return self; +} + +pub fn destroy(self: *Session) void { + if (self.watch != 0) _ = glib.Source.remove(self.watch); + self.pty.deinit(); + self.stream.deinit(); + self.term.deinit(self.alloc); + self.alloc.destroy(self); +} + +/// Resolve the user's shell, falling back to something that always exists. +fn defaultShell(alloc: std.mem.Allocator) ![:0]const u8 { + if (std.c.getenv("SHELL")) |sh| { + const span = std.mem.span(sh); + if (span.len > 0) return alloc.dupeZ(u8, span); + } + return alloc.dupeZ(u8, "/bin/sh"); +} + +/// Main loop callback: the PTY has data (or hung up). +fn onReadable( + fd: c_int, + condition: glib.IOCondition, + data: ?*anyopaque, +) callconv(.c) c_int { + const self: *Session = @ptrCast(@alignCast(data.?)); + + _ = fd; + + if (condition.in) { + var buf: [read_buf_size]u8 = undefined; + if (self.pty.read(&buf)) |n| { + self.stream.nextSlice(buf[0..n]); + self.on_damage(self.ctx); + return 1; + } + } + + // Zero-length read, HUP or error all mean the child is gone. + self.exited = true; + self.watch = 0; + self.on_exit(self.ctx); + return 0; +} + +/// Effect callback: the terminal wants to send bytes back to the child. +fn effectWritePty(handler: *vt.TerminalStream.Handler, data: [:0]const u8) void { + const self = fromHandler(handler); + self.pty.writeAll(data); +} + +/// Effect callback: OSC 0/2 changed the window title. +fn effectTitleChanged(handler: *vt.TerminalStream.Handler) void { + const self = fromHandler(handler); + self.on_title(self.ctx, self.term.title.items); +} + +/// Recover the owning Session from a stream callback. The handler holds a +/// pointer to our embedded `term` field, so we can walk back from it. +fn fromHandler(handler: *vt.TerminalStream.Handler) *Session { + return @fieldParentPtr("term", handler.terminal); +} + +/// Send user input to the child. +pub fn write(self: *Session, bytes: []const u8) void { + self.pty.writeAll(bytes); +} + +/// Resize the terminal grid and tell the child about it. +pub fn resize(self: *Session, cols: u16, rows: u16, cell_w: u32, cell_h: u32) !void { + if (cols == self.term.cols and rows == self.term.rows) return; + + try self.stream.handler.resize(.{ + .cols = cols, + .rows = rows, + .cell_size_px = .{ .width = cell_w, .height = cell_h }, + }); + + self.pty.setSize(.{ + .ws_row = rows, + .ws_col = cols, + .ws_xpixel = @intCast(cell_w * cols), + .ws_ypixel = @intCast(cell_h * rows), + }); +} diff --git a/src/Terminal.zig b/src/Terminal.zig new file mode 100644 index 0000000..5109f95 --- /dev/null +++ b/src/Terminal.zig @@ -0,0 +1,634 @@ +//! The terminal widget: draws a libghostty-vt screen with Cairo/Pango and +//! feeds user input back to the session. +//! +//! Rendering is deliberately simple. Ghostty itself uses a GPU renderer with +//! a glyph atlas; here we walk the visible rows every frame and hand runs of +//! same-styled text to Pango. That is far slower in principle, but a terminal +//! grid is small and this keeps the proof of concept readable. + +const std = @import("std"); +const cairo = @import("cairo"); +const gdk = @import("gdk"); +const gtk = @import("gtk"); +const pango = @import("pango"); +const pangocairo = @import("pangocairo"); +const vt = @import("ghostty-vt"); + +const keymap = @import("key.zig"); +const theme = @import("theme.zig"); +const Session = @import("Session.zig"); + +const Terminal = @This(); + +/// Pango measures in 1/1024ths of a device unit. +const pango_scale: f64 = @floatFromInt(pango.SCALE); + +/// The font we render with. Any monospace family the system provides. +const font_spec = "monospace 11"; + +/// Padding between the grid and the widget edge, in pixels. +const pad: f64 = 8; + +/// Corner rounding of the terminal pane. +const corner_radius: f64 = 10; + +alloc: std.mem.Allocator, +session: *Session, + +area: *gtk.DrawingArea, +font: *pango.FontDescription, + +/// Cell geometry derived from the font metrics. +cell_w: f64 = 8, +cell_h: f64 = 16, +ascent: f64 = 12, + +/// Scratch buffer for building the UTF-8 of a single text run. +run_buf: std.ArrayListUnmanaged(u8) = .empty, + +/// Called when the session's title changes, so the owner can retitle the tab. +on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void, +on_exit: *const fn (ctx: ?*anyopaque) void, +ctx: ?*anyopaque = null, + +pub fn create( + alloc: std.mem.Allocator, + cbs: struct { + on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void, + on_exit: *const fn (ctx: ?*anyopaque) void, + ctx: ?*anyopaque, + }, +) !*Terminal { + const self = try alloc.create(Terminal); + errdefer alloc.destroy(self); + + const area = gtk.DrawingArea.new(); + const font = pango.FontDescription.fromString(font_spec); + + self.* = .{ + .alloc = alloc, + .session = undefined, + .area = area, + .font = font, + .on_title = cbs.on_title, + .on_exit = cbs.on_exit, + .ctx = cbs.ctx, + }; + + self.measureFont(); + + // The grid size follows the widget size, but we need a starting point + // for the session before the widget has ever been allocated. + self.session = try .create(alloc, 80, 24, .{ + .on_damage = &onDamage, + .on_title = &onSessionTitle, + .on_exit = &onSessionExit, + .ctx = self, + }); + errdefer self.session.destroy(); + + const w = area.as(gtk.Widget); + w.setHexpand(1); + w.setVexpand(1); + // Without this the drawing area can never hold keyboard focus, and all + // key events would go to the sidebar instead. + w.setFocusable(1); + w.setCanFocus(1); + + area.setDrawFunc(&drawFunc, self, null); + _ = gtk.DrawingArea.signals.resize.connect(area, *Terminal, &onResize, self, .{}); + + const keys = gtk.EventControllerKey.new(); + _ = gtk.EventControllerKey.signals.key_pressed.connect( + keys, + *Terminal, + &onKeyPressed, + self, + .{}, + ); + w.addController(keys.as(gtk.EventController)); + + const scroll = gtk.EventControllerScroll.new(.{ .vertical = true }); + _ = gtk.EventControllerScroll.signals.scroll.connect( + scroll, + *Terminal, + &onScroll, + self, + .{}, + ); + w.addController(scroll.as(gtk.EventController)); + + // Clicking the terminal should focus it. + const click = gtk.GestureClick.new(); + _ = gtk.GestureClick.signals.pressed.connect( + click, + *Terminal, + &onClick, + self, + .{}, + ); + w.addController(click.as(gtk.EventController)); + + return self; +} + +pub fn destroy(self: *Terminal) void { + self.session.destroy(); + self.run_buf.deinit(self.alloc); + self.font.free(); + self.alloc.destroy(self); +} + +pub fn widget(self: *Terminal) *gtk.Widget { + return self.area.as(gtk.Widget); +} + +pub fn grabFocus(self: *Terminal) void { + _ = self.widget().grabFocus(); +} + +/// Derive cell geometry from the font. A monospace font's "approximate +/// character width" is its advance, which is exactly our cell width. +fn measureFont(self: *Terminal) void { + const ctx = self.area.as(gtk.Widget).createPangoContext(); + defer ctx.unref(); + + const metrics = ctx.getMetrics(self.font, null); + defer metrics.unref(); + + const ascent = @as(f64, @floatFromInt(metrics.getAscent())) / pango_scale; + const descent = @as(f64, @floatFromInt(metrics.getDescent())) / pango_scale; + const char_w = @as(f64, @floatFromInt(metrics.getApproximateCharWidth())) / pango_scale; + + self.cell_w = @max(1, @ceil(char_w)); + self.cell_h = @max(1, @ceil(ascent + descent)); + self.ascent = ascent; +} + +fn onDamage(ctx: ?*anyopaque) void { + const self: *Terminal = @ptrCast(@alignCast(ctx.?)); + self.area.as(gtk.Widget).queueDraw(); +} + +fn onSessionTitle(ctx: ?*anyopaque, title: []const u8) void { + const self: *Terminal = @ptrCast(@alignCast(ctx.?)); + self.on_title(self.ctx, title); +} + +fn onSessionExit(ctx: ?*anyopaque) void { + const self: *Terminal = @ptrCast(@alignCast(ctx.?)); + self.on_exit(self.ctx); +} + +/// The widget was resized: recompute the grid and tell the child process. +fn onResize(_: *gtk.DrawingArea, width: c_int, height: c_int, self: *Terminal) callconv(.c) void { + const usable_w = @max(0.0, @as(f64, @floatFromInt(width)) - pad * 2); + const usable_h = @max(0.0, @as(f64, @floatFromInt(height)) - pad * 2); + + const cols: u16 = @intFromFloat(@max(1, @floor(usable_w / self.cell_w))); + const rows: u16 = @intFromFloat(@max(1, @floor(usable_h / self.cell_h))); + + self.session.resize( + cols, + rows, + @intFromFloat(self.cell_w), + @intFromFloat(self.cell_h), + ) catch |err| { + std.log.warn("resize failed: {s}", .{@errorName(err)}); + }; +} + +// ------------------------------------------------------------------------- +// Input + +fn onClick( + _: *gtk.GestureClick, + _: c_int, + _: f64, + _: f64, + self: *Terminal, +) callconv(.c) void { + self.grabFocus(); +} + +fn onScroll( + _: *gtk.EventControllerScroll, + _: f64, + dy: f64, + self: *Terminal, +) callconv(.c) c_int { + // Three rows per notch matches the conventional feel. + const delta: isize = @intFromFloat(dy * 3); + if (delta == 0) return 0; + + self.session.term.screens.active.pages.scroll(.{ .delta_row = delta }); + self.area.as(gtk.Widget).queueDraw(); + return 1; +} + +fn onKeyPressed( + _: *gtk.EventControllerKey, + keyval: c_uint, + _: c_uint, + state: gdk.ModifierType, + self: *Terminal, +) callconv(.c) c_int { + const mods = keymap.translateMods(state); + + var event: vt.input.KeyEvent = .{ + .action = .press, + .key = keymap.keyFromKeyval(keyval) orelse .unidentified, + .mods = mods, + }; + + // GDK has already applied the keyboard layout and shift level, so the + // unicode value of the keyval is the text this key produces. + var utf8_buf: [8]u8 = undefined; + const codepoint = gdk.keyvalToUnicode(keyval); + if (codepoint > 0) { + if (std.unicode.utf8Encode(@intCast(codepoint), &utf8_buf)) |n| { + event.utf8 = utf8_buf[0..n]; + // Shift is consumed producing the shifted character; ctrl/alt + // are not, and the encoder needs to know that to build e.g. + // ctrl sequences correctly. + event.consumed_mods = .{ .shift = mods.shift }; + } else |_| {} + + const lower = gdk.keyvalToLower(keyval); + const unshifted = gdk.keyvalToUnicode(lower); + if (unshifted > 0) event.unshifted_codepoint = @intCast(unshifted); + } + + var out: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&out); + const opts: vt.input.KeyEncodeOptions = .fromTerminal(&self.session.term); + vt.input.encodeKey(&writer, event, opts) catch |err| { + std.log.warn("key encode failed: {s}", .{@errorName(err)}); + return 0; + }; + + const encoded = writer.buffered(); + // Keys with no terminal representation (bare modifiers, unmapped keys) + // encode to nothing. Let GTK keep processing them. + if (encoded.len == 0) return 0; + + // Typing should always snap the view back to the prompt. + self.session.term.screens.active.pages.scroll(.active); + self.session.write(encoded); + self.area.as(gtk.Widget).queueDraw(); + return 1; +} + +// ------------------------------------------------------------------------- +// Rendering + +/// A cell's appearance after resolving palette indices and SGR attributes. +const Appearance = struct { + fg: theme.Rgb, + bg: ?theme.Rgb, + bold: bool, + italic: bool, + underline: bool, + strikethrough: bool, + + fn sameRun(a: Appearance, b: Appearance) bool { + return std.meta.eql(a.fg, b.fg) and + a.bold == b.bold and + a.italic == b.italic and + a.underline == b.underline and + a.strikethrough == b.strikethrough; + } +}; + +fn drawFunc( + _: *gtk.DrawingArea, + cr: *cairo.Context, + width: c_int, + height: c_int, + data: ?*anyopaque, +) callconv(.c) void { + const self: *Terminal = @ptrCast(@alignCast(data.?)); + self.render(cr, width, height) catch |err| { + std.log.warn("render failed: {s}", .{@errorName(err)}); + }; +} + +fn render(self: *Terminal, cr: *cairo.Context, width: c_int, height: c_int) !void { + const term = &self.session.term; + const screen = term.screens.active; + + // Background. Clipped to a rounded rectangle so the terminal reads as an + // inset pane next to the sidebar, the way Zen insets web content. + const default_bg: theme.Rgb = if (term.colors.background.get()) |c| + .from(c) + else + theme.bg; + { + roundedRect( + cr, + 0, + 0, + @floatFromInt(width), + @floatFromInt(height), + corner_radius, + ); + cr.clip(); + + const r, const g, const b = default_bg.cairoRgb(); + cr.setSourceRgb(r, g, b); + cr.paint(); + } + + const default_fg: theme.Rgb = if (term.colors.foreground.get()) |c| + .from(c) + else + theme.fg; + + const layout = pangocairo.createLayout(cr); + defer layout.unref(); + layout.setFontDescription(self.font); + + var y: u16 = 0; + while (y < term.rows) : (y += 1) { + const pin = screen.pages.pin(.{ .viewport = .{ .x = 0, .y = y } }) orelse continue; + const cells = pin.cells(.all); + const row_top = pad + @as(f64, @floatFromInt(y)) * self.cell_h; + + // Pass 1: backgrounds. Drawn as one rect per run so that a wide + // block of color doesn't turn into hundreds of tiny fills. + var x: usize = 0; + while (x < cells.len) { + const start_bg = appearance(pin, &cells[x], term, default_fg, default_bg).bg; + var end = x + 1; + while (end < cells.len) : (end += 1) { + const next = appearance(pin, &cells[end], term, default_fg, default_bg).bg; + if (!std.meta.eql(start_bg, next)) break; + } + + if (start_bg) |color| { + const r, const g, const b = color.cairoRgb(); + cr.setSourceRgb(r, g, b); + cr.rectangle( + pad + @as(f64, @floatFromInt(x)) * self.cell_w, + row_top, + @as(f64, @floatFromInt(end - x)) * self.cell_w, + self.cell_h, + ); + cr.fill(); + } + + x = end; + } + + // Pass 2: text runs. + x = 0; + while (x < cells.len) { + if (cells[x].wide == .spacer_tail) { + x += 1; + continue; + } + + const look = appearance(pin, &cells[x], term, default_fg, default_bg); + + self.run_buf.clearRetainingCapacity(); + const run_start = x; + while (x < cells.len) : (x += 1) { + const cell = &cells[x]; + if (cell.wide == .spacer_tail) continue; + + const cell_look = appearance(pin, cell, term, default_fg, default_bg); + if (x != run_start and !look.sameRun(cell_look)) break; + + try self.appendCell(pin, cell); + } + + if (self.run_buf.items.len > 0) { + try self.drawRun( + cr, + layout, + look, + pad + @as(f64, @floatFromInt(run_start)) * self.cell_w, + row_top, + ); + } + } + } + + self.drawCursor(cr, layout, term, default_bg); +} + +/// Append a cell's text to the current run. +fn appendCell(self: *Terminal, pin: vt.Pin, cell: *const vt.Cell) !void { + switch (cell.content_tag) { + .codepoint, .codepoint_grapheme => { + const cp = cell.content.codepoint.data; + // An empty cell still occupies a column, so emit a space to + // keep the run's characters aligned to the grid. + try self.appendCodepoint(if (cp == 0) ' ' else cp); + + if (cell.content_tag == .codepoint_grapheme) { + if (pin.grapheme(cell)) |extra| { + for (extra) |cp2| try self.appendCodepoint(cp2); + } + } + }, + // Background-only cells have no text. + .bg_color_palette, .bg_color_rgb => try self.appendCodepoint(' '), + } +} + +fn appendCodepoint(self: *Terminal, cp: u21) !void { + var buf: [4]u8 = undefined; + const n = std.unicode.utf8Encode(cp, &buf) catch return; + try self.run_buf.appendSlice(self.alloc, buf[0..n]); +} + +fn drawRun( + self: *Terminal, + cr: *cairo.Context, + layout: *pango.Layout, + look: Appearance, + x: f64, + y: f64, +) !void { + self.font.setWeight(if (look.bold) .bold else .normal); + self.font.setStyle(if (look.italic) .italic else .normal); + layout.setFontDescription(self.font); + + // Pango wants a NUL-terminated pointer even though we pass the length. + try self.run_buf.append(self.alloc, 0); + const text = self.run_buf.items[0 .. self.run_buf.items.len - 1 :0]; + layout.setText(text.ptr, @intCast(text.len)); + + const r, const g, const b = look.fg.cairoRgb(); + cr.setSourceRgb(r, g, b); + cr.moveTo(x, y); + pangocairo.showLayout(cr, layout); + + // Pango has no notion of our grid, so decorations are drawn by hand + // across the exact width of the run. + const run_w = runWidth(layout); + if (look.underline) { + cr.rectangle(x, y + self.ascent + 2, run_w, 1); + cr.fill(); + } + if (look.strikethrough) { + cr.rectangle(x, y + self.ascent * 0.6, run_w, 1); + cr.fill(); + } +} + +fn runWidth(layout: *pango.Layout) f64 { + var w: c_int = 0; + var h: c_int = 0; + layout.getPixelSize(&w, &h); + return @floatFromInt(w); +} + +fn drawCursor( + self: *Terminal, + cr: *cairo.Context, + layout: *pango.Layout, + term: *vt.Terminal, + default_bg: theme.Rgb, +) void { + // Only show the cursor when we're looking at the live screen; while + // scrolled back into history there is nothing meaningful to point at. + if (term.screens.active.pages.viewport != .active) return; + if (!term.modes.get(.cursor_visible)) return; + + const cursor = term.screens.active.cursor; + if (cursor.x >= term.cols or cursor.y >= term.rows) return; + + const x = pad + @as(f64, @floatFromInt(cursor.x)) * self.cell_w; + const y = pad + @as(f64, @floatFromInt(cursor.y)) * self.cell_h; + + const color: theme.Rgb = if (term.colors.cursor.get()) |c| .from(c) else theme.cursor; + const r, const g, const b = color.cairoRgb(); + cr.setSourceRgb(r, g, b); + + switch (cursor.cursor_style) { + .block => { + cr.rectangle(x, y, self.cell_w, self.cell_h); + cr.fill(); + + // Redraw the covered character in the background color so it + // stays legible through the block. + const pin = term.screens.active.pages.pin(.{ + .viewport = .{ .x = cursor.x, .y = cursor.y }, + }) orelse return; + const cell = pin.rowAndCell().cell; + if (cell.content_tag != .codepoint and cell.content_tag != .codepoint_grapheme) return; + const cp = cell.content.codepoint.data; + if (cp == 0 or cp == ' ') return; + + var buf: [5]u8 = @splat(0); + const n = std.unicode.utf8Encode(cp, buf[0..4]) catch return; + layout.setText(buf[0..n :0].ptr, @intCast(n)); + + const tr, const tg, const tb = default_bg.cairoRgb(); + cr.setSourceRgb(tr, tg, tb); + cr.moveTo(x, y); + pangocairo.showLayout(cr, layout); + }, + .bar => { + cr.rectangle(x, y, 2, self.cell_h); + cr.fill(); + }, + .underline => { + cr.rectangle(x, y + self.cell_h - 2, self.cell_w, 2); + cr.fill(); + }, + .block_hollow => { + cr.rectangle(x + 0.5, y + 0.5, self.cell_w - 1, self.cell_h - 1); + cr.setLineWidth(1); + cr.stroke(); + }, + } +} + +/// Trace a rounded rectangle as the current path. +fn roundedRect(cr: *cairo.Context, x: f64, y: f64, w: f64, h: f64, r: f64) void { + const radius = @min(r, @min(w, h) / 2); + const pi = std.math.pi; + + cr.newSubPath(); + cr.arc(x + w - radius, y + radius, radius, -pi / 2.0, 0); + cr.arc(x + w - radius, y + h - radius, radius, 0, pi / 2.0); + cr.arc(x + radius, y + h - radius, radius, pi / 2.0, pi); + cr.arc(x + radius, y + radius, radius, pi, 3.0 * pi / 2.0); + cr.closePath(); +} + +/// Resolve a cell's style into concrete colors and attributes. +fn appearance( + pin: vt.Pin, + cell: *const vt.Cell, + term: *vt.Terminal, + default_fg: theme.Rgb, + default_bg: theme.Rgb, +) Appearance { + const palette = &term.colors.palette.current; + + // Cells that carry only a background color have no style entry. + switch (cell.content_tag) { + .bg_color_palette => return .{ + .fg = default_fg, + .bg = .from(palette[cell.content.color_palette.data]), + .bold = false, + .italic = false, + .underline = false, + .strikethrough = false, + }, + .bg_color_rgb => { + const c = cell.content.color_rgb; + return .{ + .fg = default_fg, + .bg = .{ .r = c.r, .g = c.g, .b = c.b }, + .bold = false, + .italic = false, + .underline = false, + .strikethrough = false, + }; + }, + else => {}, + } + + const style = pin.style(cell); + + var fg: theme.Rgb = switch (style.fg_color) { + .none => default_fg, + .palette => |i| brightIfBold(palette, i, style.flags.bold), + .rgb => |c| .from(c), + }; + var bg: ?theme.Rgb = switch (style.bg_color) { + .none => null, + .palette => |i| .from(palette[i]), + .rgb => |c| .from(c), + }; + + if (style.flags.inverse) { + const new_fg = bg orelse default_bg; + const new_bg = fg; + fg = new_fg; + bg = new_bg; + } + + if (style.flags.invisible) fg = bg orelse default_bg; + + return .{ + .fg = fg, + .bg = bg, + .bold = style.flags.bold, + .italic = style.flags.italic, + .underline = style.flags.underline != .none, + .strikethrough = style.flags.strikethrough, + }; +} + +/// Bold text using one of the low 8 palette colors conventionally renders +/// with the matching bright color. +fn brightIfBold(palette: *const [256]vt.color.RGB, index: u8, bold: bool) theme.Rgb { + const effective = if (bold and index < 8) index + 8 else index; + return .from(palette[effective]); +} diff --git a/src/Window.zig b/src/Window.zig new file mode 100644 index 0000000..feea6ae --- /dev/null +++ b/src/Window.zig @@ -0,0 +1,454 @@ +//! The application window: a vertical tab strip down the left side and the +//! active terminal filling the rest. +//! +//! The layout follows Zen Browser's vertical tabs — a persistent sidebar +//! column holding the window controls, a "New Tab" affordance, and one row +//! per tab, with the content pane inset to its right. + +const std = @import("std"); +const adw = @import("adw"); +const gdk = @import("gdk"); +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); +const vt = @import("ghostty-vt"); + +const Terminal = @import("Terminal.zig"); + +const Window = @This(); + +const sidebar_width = 220; + +alloc: std.mem.Allocator, +window: *adw.ApplicationWindow, + +/// Holds one page per tab; the visible page is the active terminal. +stack: *gtk.Stack, + +/// One row per tab, in the same order as `tabs`. +list: *gtk.ListBox, + +tabs: std.ArrayListUnmanaged(*Tab) = .empty, + +/// Monotonic counter so every tab gets a distinct GtkStack page name. +next_id: u32 = 0, + +/// Set while we're programmatically changing the selection, so that the +/// resulting `row-selected` signal doesn't recurse. +updating: bool = false, + +/// Set once teardown has begun, so that a session exiting mid-teardown +/// doesn't try to close a tab we're already destroying. +closing: bool = false, + +/// A single tab: the terminal plus the sidebar row that selects it. +const Tab = struct { + window: *Window, + term: *Terminal, + row: *gtk.ListBoxRow, + label: *gtk.Label, + name: [16]u8, + name_len: usize, + + fn pageName(self: *const Tab) [:0]const u8 { + return self.name[0..self.name_len :0]; + } +}; + +pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window { + const self = try alloc.create(Window); + errdefer alloc.destroy(self); + + const window = adw.ApplicationWindow.new(app.as(gtk.Application)); + window.as(gtk.Window).setTitle("vtabs"); + window.as(gtk.Window).setDefaultSize(1100, 720); + + self.* = .{ + .alloc = alloc, + .window = window, + .stack = gtk.Stack.new(), + .list = gtk.ListBox.new(), + }; + + window.as(gtk.Widget).addCssClass("vtabs-window"); + + // ---- sidebar ------------------------------------------------------- + const sidebar = gtk.Box.new(.vertical, 0); + sidebar.as(gtk.Widget).addCssClass("vtabs-sidebar"); + sidebar.as(gtk.Widget).setSizeRequest(sidebar_width, -1); + + // The header bar lives inside the sidebar rather than spanning the + // window, which is what gives the Zen-style look. It also carries the + // window controls, which we still need since GTK draws its own + // decorations on Wayland. + const header = adw.HeaderBar.new(); + header.setShowTitle(0); + header.as(gtk.Widget).addCssClass("flat"); + + const new_tab_button = gtk.Button.newFromIconName("tab-new-symbolic"); + new_tab_button.as(gtk.Widget).setTooltipText("New tab (Ctrl+Shift+T)"); + _ = gtk.Button.signals.clicked.connect( + new_tab_button, + *Window, + &onNewTabClicked, + self, + .{}, + ); + header.packEnd(new_tab_button.as(gtk.Widget)); + sidebar.append(header.as(gtk.Widget)); + + self.list.setSelectionMode(.single); + self.list.as(gtk.Widget).addCssClass("navigation-sidebar"); + self.list.as(gtk.Widget).addCssClass("vtabs-list"); + _ = gtk.ListBox.signals.row_selected.connect( + self.list, + *Window, + &onRowSelected, + self, + .{}, + ); + + const scroller = gtk.ScrolledWindow.new(); + scroller.setPolicy(.never, .automatic); + scroller.as(gtk.Widget).setVexpand(1); + scroller.setChild(self.list.as(gtk.Widget)); + sidebar.append(scroller.as(gtk.Widget)); + + // ---- content ------------------------------------------------------- + self.stack.as(gtk.Widget).setHexpand(1); + self.stack.as(gtk.Widget).setVexpand(1); + self.stack.as(gtk.Widget).addCssClass("vtabs-content"); + + const content = gtk.Box.new(.horizontal, 0); + content.append(sidebar.as(gtk.Widget)); + content.append(self.stack.as(gtk.Widget)); + + window.setContent(content.as(gtk.Widget)); + + // Window-level shortcuts run in the capture phase so they are handled + // before the focused terminal turns the key into a VT sequence. + const shortcuts = gtk.EventControllerKey.new(); + shortcuts.as(gtk.EventController).setPropagationPhase(.capture); + _ = gtk.EventControllerKey.signals.key_pressed.connect( + shortcuts, + *Window, + &onShortcut, + self, + .{}, + ); + window.as(gtk.Widget).addController(shortcuts.as(gtk.EventController)); + + // Free our own state once GTK is done with the window. Doing this on + // `destroy` rather than `close-request` means no further events can + // arrive for widgets whose user data we're about to free. + _ = gtk.Widget.signals.destroy.connect( + window, + *Window, + &onDestroy, + self, + .{}, + ); + + try self.newTab(); + return self; +} + +pub fn present(self: *Window) void { + self.window.as(gtk.Window).present(); + + // Focus has to be grabbed after the window is presented. Calling + // grabFocus during construction silently does nothing because the + // widget is not yet realized, which would send the first keystroke to + // the sidebar instead of the terminal. + if (self.activeTab()) |tab| tab.term.grabFocus(); +} + +/// Open a new tab and switch to it. +pub fn newTab(self: *Window) !void { + const tab = try self.alloc.create(Tab); + errdefer self.alloc.destroy(tab); + + const term = try Terminal.create(self.alloc, .{ + .on_title = &onTabTitle, + .on_exit = &onTabExit, + .ctx = tab, + }); + errdefer term.destroy(); + + const id = self.next_id; + self.next_id += 1; + + tab.* = .{ + .window = self, + .term = term, + .row = gtk.ListBoxRow.new(), + .label = gtk.Label.new("shell"), + .name = undefined, + .name_len = 0, + }; + const printed = std.fmt.bufPrintZ(&tab.name, "tab{d}", .{id}) catch unreachable; + tab.name_len = printed.len; + + // ---- sidebar row --------------------------------------------------- + const row_box = gtk.Box.new(.horizontal, 6); + row_box.as(gtk.Widget).addCssClass("vtabs-row"); + + const icon = gtk.Image.newFromIconName("utilities-terminal-symbolic"); + row_box.append(icon.as(gtk.Widget)); + + tab.label.setXalign(0); + tab.label.setEllipsize(.end); + tab.label.as(gtk.Widget).setHexpand(1); + row_box.append(tab.label.as(gtk.Widget)); + + const close = gtk.Button.newFromIconName("window-close-symbolic"); + close.as(gtk.Widget).addCssClass("flat"); + close.as(gtk.Widget).addCssClass("vtabs-close"); + _ = gtk.Button.signals.clicked.connect(close, *Tab, &onCloseClicked, tab, .{}); + row_box.append(close.as(gtk.Widget)); + + tab.row.setChild(row_box.as(gtk.Widget)); + self.list.append(tab.row.as(gtk.Widget)); + + _ = self.stack.addNamed(term.widget(), tab.pageName()); + + try self.tabs.append(self.alloc, tab); + self.select(tab); +} + +/// Make `tab` the visible one. +fn select(self: *Window, tab: *Tab) void { + self.updating = true; + defer self.updating = false; + + self.stack.setVisibleChildName(tab.pageName()); + self.list.selectRow(tab.row); + tab.term.grabFocus(); +} + +fn indexOf(self: *Window, tab: *Tab) ?usize { + for (self.tabs.items, 0..) |t, i| if (t == tab) return i; + return null; +} + +/// Close a tab, and the window along with it if it was the last one. +fn closeTab(self: *Window, tab: *Tab) void { + if (self.closing) return; + const index = self.indexOf(tab) orelse return; + + self.stack.remove(tab.term.widget()); + self.list.remove(tab.row.as(gtk.Widget)); + _ = self.tabs.orderedRemove(index); + + tab.term.destroy(); + self.alloc.destroy(tab); + + if (self.tabs.items.len == 0) { + // Teardown of our own state happens in onDestroy. + self.window.as(gtk.Window).close(); + return; + } + + // Prefer the tab that took the closed one's place, else the new last. + const next = @min(index, self.tabs.items.len - 1); + self.select(self.tabs.items[next]); +} + +// ------------------------------------------------------------------------- +// Signal handlers + +fn onNewTabClicked(_: *gtk.Button, self: *Window) callconv(.c) void { + self.newTab() catch |err| { + std.log.err("failed to open tab: {s}", .{@errorName(err)}); + }; +} + +fn onCloseClicked(_: *gtk.Button, tab: *Tab) callconv(.c) void { + tab.window.closeTab(tab); +} + +fn onRowSelected(_: *gtk.ListBox, row: ?*gtk.ListBoxRow, self: *Window) callconv(.c) void { + if (self.updating) return; + const selected = row orelse return; + for (self.tabs.items) |tab| { + if (tab.row == selected) { + self.select(tab); + return; + } + } +} + +fn onTabTitle(ctx: ?*anyopaque, title: []const u8) void { + const tab: *Tab = @ptrCast(@alignCast(ctx.?)); + + // GTK needs a NUL-terminated string, and titles from the terminal are + // arbitrary length, so clamp to something a sidebar row can show. + var buf: [128]u8 = undefined; + const n = @min(title.len, buf.len - 1); + @memcpy(buf[0..n], title[0..n]); + buf[n] = 0; + + tab.label.setText(buf[0..n :0]); + tab.label.as(gtk.Widget).setTooltipText(buf[0..n :0]); +} + +fn onTabExit(ctx: ?*anyopaque) void { + const tab: *Tab = @ptrCast(@alignCast(ctx.?)); + tab.window.closeTab(tab); +} + +/// GTK has finished with the window: release everything we allocated. +fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void { + if (self.closing) return; + self.closing = true; + + // Each terminal owns a session, which owns a PTY and its child process. + // Dropping them here reaps the children rather than orphaning them. + for (self.tabs.items) |tab| { + tab.term.destroy(); + self.alloc.destroy(tab); + } + self.tabs.deinit(self.alloc); + self.alloc.destroy(self); +} + +// ------------------------------------------------------------------------- +// Shortcuts + +/// The tab whose terminal is currently visible. +fn activeTab(self: *Window) ?*Tab { + const name = self.stack.getVisibleChildName() orelse return null; + const span = std.mem.span(name); + for (self.tabs.items) |tab| { + if (std.mem.eql(u8, tab.pageName(), span)) return tab; + } + return null; +} + +fn selectIndex(self: *Window, index: usize) void { + if (index >= self.tabs.items.len) return; + self.select(self.tabs.items[index]); +} + +/// Move the selection by `delta`, wrapping around the ends. +fn cycle(self: *Window, delta: isize) void { + if (self.tabs.items.len == 0) return; + const current = self.indexOf(self.activeTab() orelse return) orelse return; + const len: isize = @intCast(self.tabs.items.len); + const next = @mod(@as(isize, @intCast(current)) + delta + len, len); + self.selectIndex(@intCast(next)); +} + +fn onShortcut( + _: *gtk.EventControllerKey, + keyval: c_uint, + _: c_uint, + state: gdk.ModifierType, + self: *Window, +) callconv(.c) c_int { + const ctrl = state.control_mask; + const shift = state.shift_mask; + const alt = state.alt_mask; + + if (ctrl and shift) { + switch (keyval) { + gdk.KEY_T, gdk.KEY_t => { + self.newTab() catch |err| { + std.log.err("failed to open tab: {s}", .{@errorName(err)}); + }; + return 1; + }, + gdk.KEY_W, gdk.KEY_w => { + if (self.activeTab()) |tab| self.closeTab(tab); + return 1; + }, + gdk.KEY_V, gdk.KEY_v => { + self.paste(); + return 1; + }, + else => {}, + } + } + + // Ctrl+PageUp/PageDown cycles tabs, matching most tabbed terminals. + if (ctrl and !shift) { + switch (keyval) { + gdk.KEY_Page_Up => { + self.cycle(-1); + return 1; + }, + gdk.KEY_Page_Down => { + self.cycle(1); + return 1; + }, + else => {}, + } + } + + // Alt+1..9 jumps straight to a tab; Alt+9 is "last tab" by convention. + if (alt and !ctrl) { + if (keyval >= gdk.KEY_1 and keyval <= gdk.KEY_9) { + const n = keyval - gdk.KEY_1; + if (n == 8) { + self.selectIndex(self.tabs.items.len -| 1); + } else { + self.selectIndex(@intCast(n)); + } + return 1; + } + } + + return 0; +} + +// ------------------------------------------------------------------------- +// Paste +// +// GTK4's clipboard API is asynchronous, so the read completes on a later +// main loop turn. We resolve the destination tab at completion time rather +// than capturing it, so closing a tab mid-paste can't leave a dangling +// pointer. + +fn paste(self: *Window) void { + const clipboard = self.window.as(gtk.Widget).getClipboard(); + clipboard.readTextAsync(null, &onPasteReady, self); +} + +fn onPasteReady( + source: ?*gobject.Object, + result: *gio.AsyncResult, + data: ?*anyopaque, +) callconv(.c) void { + const self: *Window = @ptrCast(@alignCast(data.?)); + const clipboard: *gdk.Clipboard = @ptrCast(@alignCast(source.?)); + + var err: ?*glib.Error = null; + const text = clipboard.readTextFinish(result, &err) orelse { + if (err) |e| { + std.log.warn("paste failed: {s}", .{e.f_message orelse "unknown"}); + e.free(); + } + return; + }; + defer glib.free(text); + + const tab = self.activeTab() orelse return; + const session = tab.term.session; + // Coerce to a plain slice: encodePaste dispatches on the exact type. + const span: []const u8 = std.mem.span(text); + + // Refuse pastes containing control characters that would execute on + // arrival (a newline in unbracketed mode runs the command immediately). + const opts: vt.input.PasteOptions = .fromTerminal(&session.term); + if (!vt.input.isSafePaste(span)) { + std.log.warn("refusing unsafe paste", .{}); + return; + } + + const parts = vt.input.encodePaste(span, opts) catch |e| { + std.log.warn("paste encode failed: {s}", .{@errorName(e)}); + return; + }; + for (parts) |part| session.write(part); +} diff --git a/src/key.zig b/src/key.zig new file mode 100644 index 0000000..96b99d1 --- /dev/null +++ b/src/key.zig @@ -0,0 +1,177 @@ +//! Translation from GDK key events to libghostty-vt key events. +//! +//! The keyval table is adapted from Ghostty's own GTK apprt +//! (src/apprt/gtk/key.zig), since it is the reference for how a GTK +//! application should map GDK keyvals onto libghostty's key enum. + +const std = @import("std"); +const gdk = @import("gdk"); +const vt = @import("ghostty-vt"); + +const Key = vt.input.Key; +const Mods = vt.input.KeyMods; + +/// Translate GDK modifier state into libghostty-vt modifiers. +pub fn translateMods(state: gdk.ModifierType) Mods { + return .{ + .shift = state.shift_mask, + .ctrl = state.control_mask, + .alt = state.alt_mask, + .super = state.super_mask, + }; +} + +/// Returns the libghostty key for a GDK keyval, or null if unmapped. +pub fn keyFromKeyval(keyval: c_uint) ?Key { + for (keymap) |entry| { + if (entry[0] == keyval) return entry[1]; + } + return null; +} + +const RawEntry = struct { c_uint, Key }; + +const keymap: []const RawEntry = &.{ + .{ gdk.KEY_a, .key_a }, + .{ gdk.KEY_b, .key_b }, + .{ gdk.KEY_c, .key_c }, + .{ gdk.KEY_d, .key_d }, + .{ gdk.KEY_e, .key_e }, + .{ gdk.KEY_f, .key_f }, + .{ gdk.KEY_g, .key_g }, + .{ gdk.KEY_h, .key_h }, + .{ gdk.KEY_i, .key_i }, + .{ gdk.KEY_j, .key_j }, + .{ gdk.KEY_k, .key_k }, + .{ gdk.KEY_l, .key_l }, + .{ gdk.KEY_m, .key_m }, + .{ gdk.KEY_n, .key_n }, + .{ gdk.KEY_o, .key_o }, + .{ gdk.KEY_p, .key_p }, + .{ gdk.KEY_q, .key_q }, + .{ gdk.KEY_r, .key_r }, + .{ gdk.KEY_s, .key_s }, + .{ gdk.KEY_t, .key_t }, + .{ gdk.KEY_u, .key_u }, + .{ gdk.KEY_v, .key_v }, + .{ gdk.KEY_w, .key_w }, + .{ gdk.KEY_x, .key_x }, + .{ gdk.KEY_y, .key_y }, + .{ gdk.KEY_z, .key_z }, + + .{ gdk.KEY_0, .digit_0 }, + .{ gdk.KEY_1, .digit_1 }, + .{ gdk.KEY_2, .digit_2 }, + .{ gdk.KEY_3, .digit_3 }, + .{ gdk.KEY_4, .digit_4 }, + .{ gdk.KEY_5, .digit_5 }, + .{ gdk.KEY_6, .digit_6 }, + .{ gdk.KEY_7, .digit_7 }, + .{ gdk.KEY_8, .digit_8 }, + .{ gdk.KEY_9, .digit_9 }, + + .{ gdk.KEY_semicolon, .semicolon }, + .{ gdk.KEY_space, .space }, + .{ gdk.KEY_apostrophe, .quote }, + .{ gdk.KEY_comma, .comma }, + .{ gdk.KEY_grave, .backquote }, + .{ gdk.KEY_period, .period }, + .{ gdk.KEY_slash, .slash }, + .{ gdk.KEY_minus, .minus }, + .{ gdk.KEY_equal, .equal }, + .{ gdk.KEY_bracketleft, .bracket_left }, + .{ gdk.KEY_bracketright, .bracket_right }, + .{ gdk.KEY_backslash, .backslash }, + + .{ gdk.KEY_Up, .arrow_up }, + .{ gdk.KEY_Down, .arrow_down }, + .{ gdk.KEY_Right, .arrow_right }, + .{ gdk.KEY_Left, .arrow_left }, + .{ gdk.KEY_Home, .home }, + .{ gdk.KEY_End, .end }, + .{ gdk.KEY_Insert, .insert }, + .{ gdk.KEY_Delete, .delete }, + .{ gdk.KEY_Caps_Lock, .caps_lock }, + .{ gdk.KEY_Scroll_Lock, .scroll_lock }, + .{ gdk.KEY_Num_Lock, .num_lock }, + .{ gdk.KEY_Page_Up, .page_up }, + .{ gdk.KEY_Page_Down, .page_down }, + .{ gdk.KEY_Escape, .escape }, + .{ gdk.KEY_Return, .enter }, + .{ gdk.KEY_Tab, .tab }, + .{ gdk.KEY_BackSpace, .backspace }, + .{ gdk.KEY_Print, .print_screen }, + .{ gdk.KEY_Pause, .pause }, + + .{ gdk.KEY_F1, .f1 }, + .{ gdk.KEY_F2, .f2 }, + .{ gdk.KEY_F3, .f3 }, + .{ gdk.KEY_F4, .f4 }, + .{ gdk.KEY_F5, .f5 }, + .{ gdk.KEY_F6, .f6 }, + .{ gdk.KEY_F7, .f7 }, + .{ gdk.KEY_F8, .f8 }, + .{ gdk.KEY_F9, .f9 }, + .{ gdk.KEY_F10, .f10 }, + .{ gdk.KEY_F11, .f11 }, + .{ gdk.KEY_F12, .f12 }, + .{ gdk.KEY_F13, .f13 }, + .{ gdk.KEY_F14, .f14 }, + .{ gdk.KEY_F15, .f15 }, + .{ gdk.KEY_F16, .f16 }, + .{ gdk.KEY_F17, .f17 }, + .{ gdk.KEY_F18, .f18 }, + .{ gdk.KEY_F19, .f19 }, + .{ gdk.KEY_F20, .f20 }, + .{ gdk.KEY_F21, .f21 }, + .{ gdk.KEY_F22, .f22 }, + .{ gdk.KEY_F23, .f23 }, + .{ gdk.KEY_F24, .f24 }, + .{ gdk.KEY_F25, .f25 }, + + .{ gdk.KEY_KP_0, .numpad_0 }, + .{ gdk.KEY_KP_1, .numpad_1 }, + .{ gdk.KEY_KP_2, .numpad_2 }, + .{ gdk.KEY_KP_3, .numpad_3 }, + .{ gdk.KEY_KP_4, .numpad_4 }, + .{ gdk.KEY_KP_5, .numpad_5 }, + .{ gdk.KEY_KP_6, .numpad_6 }, + .{ gdk.KEY_KP_7, .numpad_7 }, + .{ gdk.KEY_KP_8, .numpad_8 }, + .{ gdk.KEY_KP_9, .numpad_9 }, + .{ gdk.KEY_KP_Decimal, .numpad_decimal }, + .{ gdk.KEY_KP_Divide, .numpad_divide }, + .{ gdk.KEY_KP_Multiply, .numpad_multiply }, + .{ gdk.KEY_KP_Subtract, .numpad_subtract }, + .{ gdk.KEY_KP_Add, .numpad_add }, + .{ gdk.KEY_KP_Enter, .numpad_enter }, + .{ gdk.KEY_KP_Equal, .numpad_equal }, + + .{ gdk.KEY_KP_Separator, .numpad_separator }, + .{ gdk.KEY_KP_Left, .numpad_left }, + .{ gdk.KEY_KP_Right, .numpad_right }, + .{ gdk.KEY_KP_Up, .numpad_up }, + .{ gdk.KEY_KP_Down, .numpad_down }, + .{ gdk.KEY_KP_Page_Up, .numpad_page_up }, + .{ gdk.KEY_KP_Page_Down, .numpad_page_down }, + .{ gdk.KEY_KP_Home, .numpad_home }, + .{ gdk.KEY_KP_End, .numpad_end }, + .{ gdk.KEY_KP_Insert, .numpad_insert }, + .{ gdk.KEY_KP_Delete, .numpad_delete }, + .{ gdk.KEY_KP_Begin, .numpad_begin }, + + .{ gdk.KEY_Copy, .copy }, + .{ gdk.KEY_Cut, .cut }, + .{ gdk.KEY_Paste, .paste }, + + .{ gdk.KEY_Shift_L, .shift_left }, + .{ gdk.KEY_Control_L, .control_left }, + .{ gdk.KEY_Alt_L, .alt_left }, + .{ gdk.KEY_Super_L, .meta_left }, + .{ gdk.KEY_Shift_R, .shift_right }, + .{ gdk.KEY_Control_R, .control_right }, + .{ gdk.KEY_Alt_R, .alt_right }, + .{ gdk.KEY_Super_R, .meta_right }, + + // TODO: media keys +}; diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..121f27d --- /dev/null +++ b/src/main.zig @@ -0,0 +1,62 @@ +//! vtabs: a terminal with vertical tabs, built on libghostty-vt. +//! +//! libghostty-vt supplies the terminal emulator core (escape sequence +//! parsing, screen and scrollback state, key/mouse encoding). Everything +//! else — process management, rendering, and the GTK4 UI — lives here. + +const std = @import("std"); +const adw = @import("adw"); +const gdk = @import("gdk"); +const gio = @import("gio"); +const gtk = @import("gtk"); + +const Window = @import("Window.zig"); + +/// libghostty-vt logs unimplemented sequences at debug level, which is very +/// chatty against a real shell. Keep the app's own warnings and errors. +pub const std_options: std.Options = .{ + .log_level = .info, +}; + +const css = @embedFile("style.css"); + +var gpa: std.heap.DebugAllocator(.{}) = .init; + +pub fn main() u8 { + defer _ = gpa.deinit(); + + // Non-unique so every launch is its own process. The default GApplication + // behavior hands off to an already-running instance over D-Bus, which for + // a terminal means a second launch silently does nothing visible here and + // opens a window in whatever session owns the first one. + const app = adw.Application.new("dev.greyson.vtabs", .{ .non_unique = true }); + defer app.unref(); + + _ = gio.Application.signals.activate.connect(app, ?*anyopaque, &onActivate, null, .{}); + + const status = gio.Application.run(app.as(gio.Application), 0, null); + return @intCast(status); +} + +fn onActivate(app: *adw.Application, _: ?*anyopaque) callconv(.c) void { + loadCss(); + + const window = Window.create(gpa.allocator(), app) catch |err| { + std.log.err("failed to create window: {s}", .{@errorName(err)}); + return; + }; + window.present(); +} + +fn loadCss() void { + const display = gdk.Display.getDefault() orelse return; + const provider = gtk.CssProvider.new(); + defer provider.unref(); + + provider.loadFromString(css); + gtk.StyleContext.addProviderForDisplay( + display, + provider.as(gtk.StyleProvider), + gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ); +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..4f29cb6 --- /dev/null +++ b/src/style.css @@ -0,0 +1,66 @@ +/* Zen-style vertical tabs: a dark sidebar column with the terminal inset + to its right. */ + +.vtabs-window { + background-color: #0f0d14; +} + +.vtabs-sidebar { + background-color: #1b1823; + border-right: 1px solid #2a2536; +} + +.vtabs-sidebar headerbar { + background: none; + box-shadow: none; + min-height: 38px; +} + +.vtabs-list { + background: none; + padding: 4px 6px; +} + +.vtabs-list > row { + border-radius: 8px; + margin: 1px 0; + padding: 5px 8px; + color: #b6afc7; + transition: background-color 120ms ease; +} + +.vtabs-list > row:hover { + background-color: #262133; +} + +.vtabs-list > row:selected { + background-color: #342c4a; + color: #f0ecf8; +} + +.vtabs-list > row:selected image { + color: #b29df5; +} + +/* Keep the close button unobtrusive until the row is hovered or current. */ +.vtabs-close { + opacity: 0; + min-width: 20px; + min-height: 20px; + padding: 0; +} + +.vtabs-list > row:hover .vtabs-close, +.vtabs-list > row:selected .vtabs-close { + opacity: 0.65; +} + +.vtabs-close:hover { + opacity: 1; +} + +.vtabs-content { + background-color: #16141c; + border-radius: 10px; + margin: 6px 6px 6px 0; +} diff --git a/src/theme.zig b/src/theme.zig new file mode 100644 index 0000000..157f0d3 --- /dev/null +++ b/src/theme.zig @@ -0,0 +1,35 @@ +//! Colors for the parts of the UI that libghostty-vt has no opinion about. +//! +//! The 256-color palette itself comes from the terminal's own color state +//! (`Terminal.colors.palette`), which libghostty-vt initializes to the +//! standard xterm palette and keeps updated as programs change it via OSC. + +const vt = @import("ghostty-vt"); + +pub const Rgb = struct { + r: u8, + g: u8, + b: u8, + + pub fn from(c: vt.color.RGB) Rgb { + return .{ .r = c.r, .g = c.g, .b = c.b }; + } + + /// Cairo takes color channels as 0..1 doubles. + pub fn cairoRgb(self: Rgb) struct { f64, f64, f64 } { + return .{ + @as(f64, @floatFromInt(self.r)) / 255.0, + @as(f64, @floatFromInt(self.g)) / 255.0, + @as(f64, @floatFromInt(self.b)) / 255.0, + }; + } +}; + +/// Terminal default background, used when the program hasn't set one. +pub const bg: Rgb = .{ .r = 0x16, .g = 0x14, .b = 0x1c }; + +/// Terminal default foreground. +pub const fg: Rgb = .{ .r = 0xe2, .g = 0xde, .b = 0xea }; + +/// Cursor block color. +pub const cursor: Rgb = .{ .r = 0xb2, .g = 0x9d, .b = 0xf5 };