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.
This commit is contained in:
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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'
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
Generated
+85
@@ -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
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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" <<CONF
|
||||||
|
output HEADLESS-1 resolution 1200x800
|
||||||
|
default_border none
|
||||||
|
exec "'$RUNDIR/launch.sh' >'$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
|
||||||
+205
@@ -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;
|
||||||
|
}
|
||||||
+203
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
+454
@@ -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);
|
||||||
|
}
|
||||||
+177
@@ -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
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user