From 738cead680f0d4bf97bf639681396a51b06c4c6c Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Tue, 11 Aug 2026 08:36:10 -0400 Subject: [PATCH] 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. --- .gitignore | 6 + README.md | 129 ++++++++++ b.sh | 7 + build.zig | 61 +++++ build.zig.zon | 21 ++ flake.lock | 85 +++++++ flake.nix | 97 ++++++++ shot.sh | 117 +++++++++ src/Pty.zig | 205 +++++++++++++++ src/Session.zig | 203 +++++++++++++++ src/Terminal.zig | 634 +++++++++++++++++++++++++++++++++++++++++++++++ src/Window.zig | 454 +++++++++++++++++++++++++++++++++ src/key.zig | 177 +++++++++++++ src/main.zig | 62 +++++ src/style.css | 66 +++++ src/theme.zig | 35 +++ 16 files changed, 2359 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 b.sh create mode 100644 build.zig create mode 100644 build.zig.zon create mode 100644 flake.lock create mode 100644 flake.nix create mode 100755 shot.sh create mode 100644 src/Pty.zig create mode 100644 src/Session.zig create mode 100644 src/Terminal.zig create mode 100644 src/Window.zig create mode 100644 src/key.zig create mode 100644 src/main.zig create mode 100644 src/style.css create mode 100644 src/theme.zig 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 };