Rebrand as Playpen.

This commit is contained in:
Greyson Parrelli
2026-08-11 14:26:12 -04:00
parent 3026bdcd4c
commit 73a0187db5
17 changed files with 152 additions and 146 deletions
+9 -6
View File
@@ -1,10 +1,13 @@
# vtabs
# Playpen
A proof-of-concept terminal emulator with **vertical tabs**, built on
[libghostty-vt](https://github.com/ghostty-org/ghostty) and GTK4/libadwaita.
A proof-of-concept workspace built on
[libghostty-vt](https://github.com/ghostty-org/ghostty) and GTK4/libadwaita:
**vertical tabs**, each holding a split tree of terminal and web panes, with
**saved layouts** that open a whole arrangement — directories, scripts and all —
in one go.
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
the layout Zen Browser uses for vertical tabs — with the content inset to its
right.
## Quick start
@@ -23,7 +26,7 @@ mise run install # builds release, installs into ~/.local
mise run uninstall
```
That puts the binary in `~/.local/libexec/vtabs`, a launcher on `~/.local/bin`,
That puts the binary in `~/.local/libexec/playpen`, a launcher on `~/.local/bin`,
a desktop entry in `~/.local/share/applications`, and the icon in the hicolor
theme, then refreshes the desktop and icon caches. `mise tasks` lists the rest
(`build`, `run`, `fmt`, `screenshot`).
@@ -200,7 +203,7 @@ because shells that set up line editing discard whatever was buffered while
they were initializing. The shell's first output is the signal that it is
reading, so that is when the script goes in.
Layouts live in `~/.config/vtabs/layouts.json` (or `$XDG_CONFIG_HOME`), and the
Layouts live in `~/.config/playpen/layouts.json` (or `$XDG_CONFIG_HOME`), and the
file is meant to be edited by hand as well — *Reload from disk* picks up
changes. It is JSON because the app writes it too, and a format that
round-trips without a hand-written emitter is worth more here than a prettier
+1 -1
View File
@@ -21,7 +21,7 @@ pub fn build(b: *std.Build) void {
});
const exe = b.addExecutable(.{
.name = "vtabs",
.name = "playpen",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
+4 -2
View File
@@ -1,7 +1,9 @@
.{
.name = .vtabs,
.name = .playpen,
.version = "0.1.0",
.fingerprint = 0x833f5d0dd064e02a,
// Half package-name hash, half per-package constant, so renaming the
// package invalidates it. Regenerated when vtabs became playpen.
.fingerprint = 0x5a26ca72bb4d2c53,
.minimum_zig_version = "0.16.0",
.paths = .{
"build.zig",
@@ -1,16 +1,16 @@
[Desktop Entry]
Type=Application
Name=vtabs
Name=Playpen
GenericName=Terminal
Comment=Terminal emulator with vertical tabs
Comment=Terminal with vertical tabs, web panes and saved layouts
# Substituted with an absolute path by `mise run install`, because desktop
# launchers do not reliably have ~/.local/bin on PATH.
Exec=@EXEC@
Icon=dev.greyson.vtabs
Icon=dev.greyson.playpen
Terminal=false
Categories=System;TerminalEmulator;
Keywords=shell;prompt;command;commandline;cmd;terminal;tabs;
StartupNotify=true
# Lets the compositor match the running window to this entry, which is what
# gives the window its icon. Must equal the GApplication id.
StartupWMClass=dev.greyson.vtabs
StartupWMClass=dev.greyson.playpen
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
vtabs app icon: a terminal pane with the vertical tab strip down its left
Playpen app icon: a terminal pane with the vertical tab strip down its left
edge. Shapes are kept large and high-contrast so the sidebar is still
readable when the icon is scaled down to 32px in a launcher.
-->

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

+14 -14
View File
@@ -1,5 +1,5 @@
{
description = "Vertical-tab terminal built on libghostty-vt";
description = "Playpen: vertical-tab terminal with web panes and saved layouts, built on libghostty-vt";
inputs = {
# Ghostty tracks nixpkgs-unstable to get GTK 4.20 / GNOME 49. We follow
@@ -42,7 +42,7 @@
in {
devShells = forAllSystems (pkgs: system: {
default = pkgs.mkShell {
name = "vtabs";
name = "playpen";
nativeBuildInputs = [
zig.packages.${system}."0.16.0"
@@ -106,16 +106,16 @@
# an RPATH into the Nix store and runs fine outside this shell, but
# it still has to be pointed at this data or GTK aborts on a missing
# schema and falls back to broken-image icons.
export VTABS_GSETTINGS_SCHEMA_DIR="${pkgs.gtk4}/share/gsettings-schemas/${pkgs.gtk4.name}/glib-2.0/schemas"
export VTABS_RUNTIME_DATA_DIRS="${pkgs.gtk4}/share/gsettings-schemas/${pkgs.gtk4.name}:${pkgs.adwaita-icon-theme}/share:${pkgs.hicolor-icon-theme}/share"
export PLAYPEN_GSETTINGS_SCHEMA_DIR="${pkgs.gtk4}/share/gsettings-schemas/${pkgs.gtk4.name}/glib-2.0/schemas"
export PLAYPEN_RUNTIME_DATA_DIRS="${pkgs.gtk4}/share/gsettings-schemas/${pkgs.gtk4.name}:${pkgs.adwaita-icon-theme}/share:${pkgs.hicolor-icon-theme}/share"
# The GIO module holding the TLS backend. Same story as the schemas:
# it is found by path at runtime, so the installer bakes it in too.
export VTABS_GIO_MODULE_DIR="${pkgs.glib-networking}/lib/gio/modules"
export PLAYPEN_GIO_MODULE_DIR="${pkgs.glib-networking}/lib/gio/modules"
export GSETTINGS_SCHEMA_DIR="$VTABS_GSETTINGS_SCHEMA_DIR"
export XDG_DATA_DIRS="$VTABS_RUNTIME_DATA_DIRS:$XDG_DATA_DIRS"
export GIO_EXTRA_MODULES="$VTABS_GIO_MODULE_DIR''${GIO_EXTRA_MODULES:+:$GIO_EXTRA_MODULES}"
export GSETTINGS_SCHEMA_DIR="$PLAYPEN_GSETTINGS_SCHEMA_DIR"
export XDG_DATA_DIRS="$PLAYPEN_RUNTIME_DATA_DIRS:$XDG_DATA_DIRS"
export GIO_EXTRA_MODULES="$PLAYPEN_GIO_MODULE_DIR''${GIO_EXTRA_MODULES:+:$GIO_EXTRA_MODULES}"
# WebKit's web process renders through mesa, and every piece of that
# path has to come from the *same* mesa. Two things go wrong on a
@@ -133,13 +133,13 @@
# Pointing all three at this mesa keeps the stack self-consistent.
# It still uses the real GPU through /dev/dri; this is not a
# software-rendering fallback.
export VTABS_EGL_VENDOR_DIR="${pkgs.mesa}/share/glvnd/egl_vendor.d"
export VTABS_DRI_DRIVERS_PATH="${pkgs.mesa}/lib/dri"
export VTABS_GBM_BACKENDS_PATH="${pkgs.mesa}/lib/gbm"
export PLAYPEN_EGL_VENDOR_DIR="${pkgs.mesa}/share/glvnd/egl_vendor.d"
export PLAYPEN_DRI_DRIVERS_PATH="${pkgs.mesa}/lib/dri"
export PLAYPEN_GBM_BACKENDS_PATH="${pkgs.mesa}/lib/gbm"
export __EGL_VENDOR_LIBRARY_DIRS="$VTABS_EGL_VENDOR_DIR"
export LIBGL_DRIVERS_PATH="$VTABS_DRI_DRIVERS_PATH"
export GBM_BACKENDS_PATH="$VTABS_GBM_BACKENDS_PATH"
export __EGL_VENDOR_LIBRARY_DIRS="$PLAYPEN_EGL_VENDOR_DIR"
export LIBGL_DRIVERS_PATH="$PLAYPEN_DRI_DRIVERS_PATH"
export GBM_BACKENDS_PATH="$PLAYPEN_GBM_BACKENDS_PATH"
'';
};
});
+26 -26
View File
@@ -10,7 +10,7 @@ description = "Build a debug binary (leak checking, safety checks)"
run = "nix develop --command zig build"
[tasks.run]
description = "Build and run vtabs"
description = "Build and run playpen"
run = "nix develop --command zig build run"
[tasks.fmt]
@@ -18,12 +18,12 @@ description = "Format all Zig source"
run = "nix develop --command zig fmt build.zig src"
[tasks.screenshot]
description = "Screenshot vtabs in a throwaway headless compositor"
description = "Screenshot playpen in a throwaway headless compositor"
depends = ["build-debug"]
run = "nix develop --command ./shot.sh"
[tasks.install]
description = "Install vtabs into ~/.local with a desktop entry and icon"
description = "Install playpen into ~/.local with a desktop entry and icon"
depends = ["build"]
run = '''
#!/usr/bin/env bash
@@ -31,10 +31,10 @@ set -euo pipefail
data="${XDG_DATA_HOME:-$HOME/.local/share}"
bindir="$HOME/.local/bin"
libexec="$HOME/.local/libexec/vtabs"
libexec="$HOME/.local/libexec/playpen"
apps="$data/applications"
icons="$data/icons/hicolor/scalable/apps"
gcroots="$data/vtabs/gcroots"
gcroots="$data/playpen/gcroots"
# The binary carries an RPATH into the Nix store, so it runs anywhere, but it
# still has to be told where GTK's GSettings schemas and icon themes live or
@@ -42,21 +42,21 @@ gcroots="$data/vtabs/gcroots"
# https:// page in a web pane fails, and which mesa to render web panes
# through or WebKit's web process dies on startup. Read those paths back out
# of the dev shell that built it rather than hardcoding store hashes.
schemas="$(nix develop --command printenv VTABS_GSETTINGS_SCHEMA_DIR)"
datadirs="$(nix develop --command printenv VTABS_RUNTIME_DATA_DIRS)"
giomodules="$(nix develop --command printenv VTABS_GIO_MODULE_DIR)"
eglvendor="$(nix develop --command printenv VTABS_EGL_VENDOR_DIR)"
dridrivers="$(nix develop --command printenv VTABS_DRI_DRIVERS_PATH)"
gbmbackends="$(nix develop --command printenv VTABS_GBM_BACKENDS_PATH)"
schemas="$(nix develop --command printenv PLAYPEN_GSETTINGS_SCHEMA_DIR)"
datadirs="$(nix develop --command printenv PLAYPEN_RUNTIME_DATA_DIRS)"
giomodules="$(nix develop --command printenv PLAYPEN_GIO_MODULE_DIR)"
eglvendor="$(nix develop --command printenv PLAYPEN_EGL_VENDOR_DIR)"
dridrivers="$(nix develop --command printenv PLAYPEN_DRI_DRIVERS_PATH)"
gbmbackends="$(nix develop --command printenv PLAYPEN_GBM_BACKENDS_PATH)"
mkdir -p "$bindir" "$libexec" "$apps" "$icons" "$gcroots"
install -m755 zig-out/bin/vtabs "$libexec/vtabs"
install -m755 zig-out/bin/playpen "$libexec/playpen"
# A launcher script rather than a symlink: a desktop launcher starts the app
# with a minimal environment, so the GTK runtime paths must be set here.
# Existing values are preserved so this composes with the rest of the session.
cat > "$bindir/vtabs" <<EOF
cat > "$bindir/playpen" <<EOF
#!/bin/sh
# Generated by \`mise run install\` — re-run that instead of editing.
export GSETTINGS_SCHEMA_DIR="$schemas\${GSETTINGS_SCHEMA_DIR:+:\$GSETTINGS_SCHEMA_DIR}"
@@ -70,13 +70,13 @@ export __EGL_VENDOR_LIBRARY_DIRS="$eglvendor"
export LIBGL_DRIVERS_PATH="$dridrivers"
export GBM_BACKENDS_PATH="$gbmbackends"
exec "$libexec/vtabs" "\$@"
exec "$libexec/playpen" "\$@"
EOF
chmod 755 "$bindir/vtabs"
chmod 755 "$bindir/playpen"
sed "s|@EXEC@|$bindir/vtabs|" dist/dev.greyson.vtabs.desktop.in \
> "$apps/dev.greyson.vtabs.desktop"
install -m644 dist/dev.greyson.vtabs.svg "$icons/dev.greyson.vtabs.svg"
sed "s|@EXEC@|$bindir/playpen|" dist/dev.greyson.playpen.desktop.in \
> "$apps/dev.greyson.playpen.desktop"
install -m644 dist/dev.greyson.playpen.svg "$icons/dev.greyson.playpen.svg"
# Pin the store paths the installed binary links against. Without this a
# later `nix-collect-garbage` deletes GTK and the installed app stops
@@ -86,7 +86,7 @@ rm -f "$gcroots"/*
{
# What the dynamic linker needs, read straight out of the binary.
if command -v readelf >/dev/null; then
readelf -d "$libexec/vtabs" \
readelf -d "$libexec/playpen" \
| sed -n 's/.*R\(UN\)\?PATH.*\[\(.*\)\]/\2/p' \
| tr ':' '\n'
else
@@ -112,7 +112,7 @@ echo "pinned $(find "$gcroots" -maxdepth 1 -type l | wc -l) store paths against
command -v update-desktop-database >/dev/null && update-desktop-database -q "$apps" || true
command -v gtk-update-icon-cache >/dev/null && gtk-update-icon-cache -qtf "$data/icons/hicolor" 2>/dev/null || true
echo "installed $bindir/vtabs"
echo "installed $bindir/playpen"
case ":$PATH:" in
*":$bindir:"*) ;;
*) echo "note: $bindir is not on your PATH; the desktop entry still works" >&2 ;;
@@ -128,15 +128,15 @@ set -euo pipefail
data="${XDG_DATA_HOME:-$HOME/.local/share}"
apps="$data/applications"
rm -f "$HOME/.local/bin/vtabs"
rm -rf "$HOME/.local/libexec/vtabs"
rm -f "$apps/dev.greyson.vtabs.desktop"
rm -f "$data/icons/hicolor/scalable/apps/dev.greyson.vtabs.svg"
rm -f "$HOME/.local/bin/playpen"
rm -rf "$HOME/.local/libexec/playpen"
rm -f "$apps/dev.greyson.playpen.desktop"
rm -f "$data/icons/hicolor/scalable/apps/dev.greyson.playpen.svg"
# Dropping the roots lets Nix reclaim the runtime closure on its next GC.
rm -rf "$data/vtabs"
rm -rf "$data/playpen"
command -v update-desktop-database >/dev/null && update-desktop-database -q "$apps" || true
command -v gtk-update-icon-cache >/dev/null && gtk-update-icon-cache -qtf "$data/icons/hicolor" 2>/dev/null || true
echo "uninstalled vtabs"
echo "uninstalled playpen"
'''
+6 -6
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Run vtabs inside a throwaway headless Sway and screenshot it.
# Run playpen 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.
@@ -8,7 +8,7 @@
set -u
cd "$(dirname "$0")"
OUT="${1:-/tmp/vtabs-shot.png}"
OUT="${1:-/tmp/playpen-shot.png}"
KEYS="${2:-}"
SETTLE="${3:-2}"
@@ -23,14 +23,14 @@ 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.
# have nothing to do with playpen, 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"
exec "$PLAYPEN_BIN"
LAUNCH
chmod +x "$RUNDIR/launch.sh"
export VTABS_BIN="$PWD/zig-out/bin/vtabs"
export PLAYPEN_BIN="$PWD/zig-out/bin/playpen"
export WLR_BACKENDS=headless
export WLR_LIBINPUT_NO_DEVICES=1
@@ -96,7 +96,7 @@ if [ -n "$KEYS" ]; then
sleep 1.5
fi
if [ -n "${VTABS_SHOT_DEBUG:-}" ]; then
if [ -n "${PLAYPEN_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
+3 -3
View File
@@ -177,11 +177,11 @@ pub fn navigate(self: *Browser, input: []const u8) void {
fn buildNav(self: *Browser) *gtk.Widget {
const nav = gtk.Box.new(.horizontal, 2);
nav.as(gtk.Widget).addCssClass("vtabs-nav");
nav.as(gtk.Widget).addCssClass("playpen-nav");
for ([_]*gtk.Button{ self.back, self.forward, self.reload }) |button| {
button.as(gtk.Widget).addCssClass("flat");
button.as(gtk.Widget).addCssClass("vtabs-nav-button");
button.as(gtk.Widget).addCssClass("playpen-nav-button");
nav.append(button.as(gtk.Widget));
}
@@ -190,7 +190,7 @@ fn buildNav(self: *Browser) *gtk.Widget {
_ = gtk.Button.signals.clicked.connect(self.reload, *Browser, &onReload, self, .{});
self.entry.setPlaceholderText("Enter address or search");
self.entry.as(gtk.Widget).addCssClass("vtabs-nav-entry");
self.entry.as(gtk.Widget).addCssClass("playpen-nav-entry");
self.entry.as(gtk.Widget).setHexpand(1);
_ = gtk.Entry.signals.activate.connect(self.entry, *Browser, &onEntryActivate, self, .{});
nav.append(self.entry.as(gtk.Widget));
+3 -3
View File
@@ -115,17 +115,17 @@ pub fn find(self: *Layouts, name: []const u8) ?*Layout {
// -------------------------------------------------------------------------
// Paths
/// `$XDG_CONFIG_HOME/vtabs/layouts.json`, or `~/.config/vtabs/layouts.json`
/// `$XDG_CONFIG_HOME/playpen/layouts.json`, or `~/.config/playpen/layouts.json`
/// when that isn't set — whichever GLib reports as the user's config dir.
pub fn configPath(buf: []u8) ?[:0]const u8 {
const dir = std.mem.span(glib.getUserConfigDir());
return std.fmt.bufPrintZ(buf, "{s}/vtabs/layouts.json", .{dir}) catch null;
return std.fmt.bufPrintZ(buf, "{s}/playpen/layouts.json", .{dir}) catch null;
}
/// The directory `configPath` lives in.
fn configDir(buf: []u8) ?[:0]const u8 {
const dir = std.mem.span(glib.getUserConfigDir());
return std.fmt.bufPrintZ(buf, "{s}/vtabs", .{dir}) catch null;
return std.fmt.bufPrintZ(buf, "{s}/playpen", .{dir}) catch null;
}
// -------------------------------------------------------------------------
+3 -3
View File
@@ -58,10 +58,10 @@ pub fn present(
self.window.setTransientFor(parent);
self.window.setModal(1);
self.window.setDefaultSize(480, -1);
self.window.as(gtk.Widget).addCssClass("vtabs-dialog");
self.window.as(gtk.Widget).addCssClass("playpen-dialog");
const content = gtk.Box.new(.vertical, 12);
content.as(gtk.Widget).addCssClass("vtabs-dialog-content");
content.as(gtk.Widget).addCssClass("playpen-dialog-content");
const fields = gtk.Grid.new();
fields.setRowSpacing(8);
@@ -75,7 +75,7 @@ pub fn present(
const text = if (param.description.len > 0) param.description else param.name;
label.setText(std.fmt.bufPrintZ(&label_buf, "{s}", .{text}) catch "parameter");
label.setXalign(0);
label.as(gtk.Widget).addCssClass("vtabs-dialog-label");
label.as(gtk.Widget).addCssClass("playpen-dialog-label");
fields.attach(label.as(gtk.Widget), 0, @intCast(i), 1, 1);
const entry = gtk.Entry.new();
+8 -8
View File
@@ -166,7 +166,7 @@ pub fn create(alloc: std.mem.Allocator, view: *View, spec: Spec) !*Pane {
_ = self.box.as(gobject.Object).refSink();
const box_widget = self.box.as(gtk.Widget);
box_widget.addCssClass("vtabs-pane");
box_widget.addCssClass("playpen-pane");
box_widget.setHexpand(1);
box_widget.setVexpand(1);
// Clip the content to the pane's rounded corners. A terminal paints a
@@ -224,39 +224,39 @@ pub fn setActive(self: *Pane, active: bool) void {
fn buildHeader(self: *Pane) void {
const header = self.header;
header.as(gtk.Widget).addCssClass("vtabs-pane-header");
header.as(gtk.Widget).addCssClass("playpen-pane-header");
// The whole strip is the drag handle, so advertise that with the cursor.
header.as(gtk.Widget).setCursorFromName("grab");
// With two kinds of pane in a view, the title alone no longer says what
// you're looking at, so the header leads with the kind.
const icon = gtk.Image.newFromIconName(self.kind.iconName());
icon.as(gtk.Widget).addCssClass("vtabs-pane-icon");
icon.as(gtk.Widget).addCssClass("playpen-pane-icon");
header.append(icon.as(gtk.Widget));
self.label.setXalign(0);
self.label.setEllipsize(.end);
self.label.as(gtk.Widget).setHexpand(1);
self.label.as(gtk.Widget).addCssClass("vtabs-pane-title");
self.label.as(gtk.Widget).addCssClass("playpen-pane-title");
header.append(self.label.as(gtk.Widget));
const split = gtk.Button.newFromIconName("list-add-symbolic");
split.as(gtk.Widget).addCssClass("flat");
split.as(gtk.Widget).addCssClass("vtabs-pane-button");
split.as(gtk.Widget).addCssClass("playpen-pane-button");
split.as(gtk.Widget).setTooltipText("New terminal in this view (Ctrl+Shift+E)");
_ = gtk.Button.signals.clicked.connect(split, *Pane, &onSplitClicked, self, .{});
header.append(split.as(gtk.Widget));
const web = gtk.Button.newFromIconName("web-browser-symbolic");
web.as(gtk.Widget).addCssClass("flat");
web.as(gtk.Widget).addCssClass("vtabs-pane-button");
web.as(gtk.Widget).addCssClass("playpen-pane-button");
web.as(gtk.Widget).setTooltipText("New web view in this view (Ctrl+Shift+B)");
_ = gtk.Button.signals.clicked.connect(web, *Pane, &onWebClicked, self, .{});
header.append(web.as(gtk.Widget));
const close = gtk.Button.newFromIconName("window-close-symbolic");
close.as(gtk.Widget).addCssClass("flat");
close.as(gtk.Widget).addCssClass("vtabs-pane-button");
close.as(gtk.Widget).addCssClass("playpen-pane-button");
close.as(gtk.Widget).setTooltipText("Close pane (Ctrl+Shift+W)");
_ = gtk.Button.signals.clicked.connect(close, *Pane, &onCloseClicked, self, .{});
header.append(close.as(gtk.Widget));
@@ -269,7 +269,7 @@ fn buildHeader(self: *Pane) void {
// pane being dragged is recorded on the view instead: drags never leave this
// process, and passing a raw pointer through a GValue buys nothing.
const drag_payload = "vtabs-pane";
const drag_payload = "playpen-pane";
fn installDragSource(self: *Pane) void {
const source = gtk.DragSource.new();
+9 -9
View File
@@ -118,10 +118,10 @@ pub fn present(
self.window.setTransientFor(parent);
self.window.setModal(1);
self.window.setDefaultSize(560, 620);
self.window.as(gtk.Widget).addCssClass("vtabs-dialog");
self.window.as(gtk.Widget).addCssClass("playpen-dialog");
const content = gtk.Box.new(.vertical, 12);
content.as(gtk.Widget).addCssClass("vtabs-dialog-content");
content.as(gtk.Widget).addCssClass("playpen-dialog-content");
// ---- name ----------------------------------------------------------
content.append(heading("Name"));
@@ -169,7 +169,7 @@ pub fn present(
// ---- footer ----------------------------------------------------------
self.error_label.setXalign(0);
self.error_label.as(gtk.Widget).addCssClass("vtabs-dialog-error");
self.error_label.as(gtk.Widget).addCssClass("playpen-dialog-error");
self.error_label.as(gtk.Widget).setVisible(0);
content.append(self.error_label.as(gtk.Widget));
@@ -180,7 +180,7 @@ pub fn present(
const buttons = gtk.Box.new(.horizontal, 8);
buttons.as(gtk.Widget).setHalign(.end);
buttons.as(gtk.Widget).addCssClass("vtabs-dialog-actions");
buttons.as(gtk.Widget).addCssClass("playpen-dialog-actions");
const cancel = gtk.Button.newWithLabel("Cancel");
_ = gtk.Button.signals.clicked.connect(cancel, *SaveLayoutDialog, &onCancel, self, .{});
@@ -214,7 +214,7 @@ fn buildPaneSection(self: *SaveLayoutDialog, index: usize, leaf: *Layouts.Node)
const spec = leaf.pane;
const box = gtk.Box.new(.vertical, 4);
box.as(gtk.Widget).addCssClass("vtabs-dialog-pane");
box.as(gtk.Widget).addCssClass("playpen-dialog-pane");
var title_buf: [64]u8 = undefined;
const title = std.fmt.bufPrintZ(&title_buf, "Pane {d} — {s}", .{
@@ -223,7 +223,7 @@ fn buildPaneSection(self: *SaveLayoutDialog, index: usize, leaf: *Layouts.Node)
}) catch "Pane";
const label = gtk.Label.new(title);
label.setXalign(0);
label.as(gtk.Widget).addCssClass("vtabs-dialog-label");
label.as(gtk.Widget).addCssClass("playpen-dialog-label");
box.append(label.as(gtk.Widget));
const grid = gtk.Grid.new();
@@ -256,7 +256,7 @@ fn field(
) *gtk.Entry {
const label = gtk.Label.new(label_text);
label.setXalign(0);
label.as(gtk.Widget).addCssClass("vtabs-dialog-sublabel");
label.as(gtk.Widget).addCssClass("playpen-dialog-sublabel");
grid.attach(label.as(gtk.Widget), 0, row, 1, 1);
const entry = gtk.Entry.new();
@@ -462,7 +462,7 @@ fn onDestroy(_: *gtk.Window, self: *SaveLayoutDialog) callconv(.c) void {
fn heading(text: [:0]const u8) *gtk.Widget {
const label = gtk.Label.new(text);
label.setXalign(0);
label.as(gtk.Widget).addCssClass("vtabs-dialog-heading");
label.as(gtk.Widget).addCssClass("playpen-dialog-heading");
return label.as(gtk.Widget);
}
@@ -470,7 +470,7 @@ fn hint(text: [:0]const u8) *gtk.Widget {
const label = gtk.Label.new(text);
label.setXalign(0);
label.setWrap(1);
label.as(gtk.Widget).addCssClass("vtabs-dialog-hint");
label.as(gtk.Widget).addCssClass("playpen-dialog-hint");
return label.as(gtk.Widget);
}
+1 -1
View File
@@ -93,7 +93,7 @@ pub fn create(alloc: std.mem.Allocator, cbs: Callbacks) !*View {
.ctx = cbs.ctx,
};
self.box.as(gtk.Widget).addCssClass("vtabs-view");
self.box.as(gtk.Widget).addCssClass("playpen-view");
self.box.as(gtk.Widget).setHexpand(1);
self.box.as(gtk.Widget).setVexpand(1);
+10 -10
View File
@@ -82,7 +82,7 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
errdefer alloc.destroy(self);
const window = adw.ApplicationWindow.new(app.as(gtk.Application));
window.as(gtk.Window).setTitle("vtabs");
window.as(gtk.Window).setTitle("Playpen");
window.as(gtk.Window).setDefaultSize(1100, 720);
self.* = .{
@@ -96,11 +96,11 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
self.layouts.load();
if (self.layouts.load_error) |message| std.log.warn("{s}", .{message});
window.as(gtk.Widget).addCssClass("vtabs-window");
window.as(gtk.Widget).addCssClass("playpen-window");
// ---- sidebar -------------------------------------------------------
const sidebar = gtk.Box.new(.vertical, 0);
sidebar.as(gtk.Widget).addCssClass("vtabs-sidebar");
sidebar.as(gtk.Widget).addCssClass("playpen-sidebar");
sidebar.as(gtk.Widget).setSizeRequest(sidebar_width, -1);
// The header bar lives inside the sidebar rather than spanning the
@@ -128,14 +128,14 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
layout_button.setIconName("view-grid-symbolic");
layout_button.as(gtk.Widget).setTooltipText("Open a saved layout");
layout_button.setPopover(self.layout_popover);
self.layout_popover.as(gtk.Widget).addCssClass("vtabs-layout-popover");
self.layout_popover.as(gtk.Widget).addCssClass("playpen-layout-popover");
self.refreshLayoutMenu();
header.packEnd(layout_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");
self.list.as(gtk.Widget).addCssClass("playpen-list");
_ = gtk.ListBox.signals.row_selected.connect(
self.list,
*Window,
@@ -153,7 +153,7 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
// ---- content -------------------------------------------------------
self.stack.as(gtk.Widget).setHexpand(1);
self.stack.as(gtk.Widget).setVexpand(1);
self.stack.as(gtk.Widget).addCssClass("vtabs-content");
self.stack.as(gtk.Widget).addCssClass("playpen-content");
const content = gtk.Box.new(.horizontal, 0);
content.append(sidebar.as(gtk.Widget));
@@ -247,7 +247,7 @@ fn newTabEmpty(self: *Window) !*Tab {
// ---- sidebar row ---------------------------------------------------
const row_box = gtk.Box.new(.horizontal, 6);
row_box.as(gtk.Widget).addCssClass("vtabs-row");
row_box.as(gtk.Widget).addCssClass("playpen-row");
row_box.append(tab.icon.as(gtk.Widget));
@@ -258,7 +258,7 @@ fn newTabEmpty(self: *Window) !*Tab {
const close = gtk.Button.newFromIconName("window-close-symbolic");
close.as(gtk.Widget).addCssClass("flat");
close.as(gtk.Widget).addCssClass("vtabs-close");
close.as(gtk.Widget).addCssClass("playpen-close");
_ = gtk.Button.signals.clicked.connect(close, *Tab, &onCloseClicked, tab, .{});
row_box.append(close.as(gtk.Widget));
@@ -288,14 +288,14 @@ fn refreshLayoutMenu(self: *Window) void {
self.freeLayoutRows();
const box = gtk.Box.new(.vertical, 2);
box.as(gtk.Widget).addCssClass("vtabs-layout-menu");
box.as(gtk.Widget).addCssClass("playpen-layout-menu");
if (self.layouts.items.items.len == 0) {
const empty = gtk.Label.new(if (self.layouts.load_error != null)
"Layouts file could not be read"
else
"No saved layouts yet");
empty.as(gtk.Widget).addCssClass("vtabs-layout-empty");
empty.as(gtk.Widget).addCssClass("playpen-layout-empty");
box.append(empty.as(gtk.Widget));
}
+3 -2
View File
@@ -1,4 +1,5 @@
//! vtabs: a terminal with vertical tabs, built on libghostty-vt.
//! Playpen: a terminal with vertical tabs, web panes and saved layouts, built
//! on libghostty-vt.
//!
//! libghostty-vt supplies the terminal emulator core (escape sequence
//! parsing, screen and scrollback state, key/mouse encoding). Everything
@@ -29,7 +30,7 @@ pub fn main() u8 {
// 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 });
const app = adw.Application.new("dev.greyson.playpen", .{ .non_unique = true });
defer app.unref();
_ = gio.Application.signals.activate.connect(app, ?*anyopaque, &onActivate, null, .{});
+47 -47
View File
@@ -1,27 +1,27 @@
/* Zen-style vertical tabs: a dark sidebar column with the terminal inset
to its right. */
.vtabs-window {
.playpen-window {
background-color: #0f0d14;
}
.vtabs-sidebar {
.playpen-sidebar {
background-color: #1b1823;
border-right: 1px solid #2a2536;
}
.vtabs-sidebar headerbar {
.playpen-sidebar headerbar {
background: none;
box-shadow: none;
min-height: 38px;
}
.vtabs-list {
.playpen-list {
background: none;
padding: 4px 6px;
}
.vtabs-list > row {
.playpen-list > row {
border-radius: 8px;
margin: 1px 0;
padding: 5px 8px;
@@ -29,126 +29,126 @@
transition: background-color 120ms ease;
}
.vtabs-list > row:hover {
.playpen-list > row:hover {
background-color: #262133;
}
.vtabs-list > row:selected {
.playpen-list > row:selected {
background-color: #342c4a;
color: #f0ecf8;
}
.vtabs-list > row:selected image {
.playpen-list > row:selected image {
color: #b29df5;
}
/* Keep the close button unobtrusive until the row is hovered or current. */
.vtabs-close {
.playpen-close {
opacity: 0;
min-width: 20px;
min-height: 20px;
padding: 0;
}
.vtabs-list > row:hover .vtabs-close,
.vtabs-list > row:selected .vtabs-close {
.playpen-list > row:hover .playpen-close,
.playpen-list > row:selected .playpen-close {
opacity: 0.65;
}
.vtabs-close:hover {
.playpen-close:hover {
opacity: 1;
}
.vtabs-content {
.playpen-content {
background-color: #0f0d14;
}
/* A view is the container for one tab's terminals. */
.vtabs-view {
.playpen-view {
padding: 6px 6px 6px 0;
}
/* Paned itself draws nothing; the panes inside it carry the styling. */
.vtabs-view paned {
.playpen-view paned {
background: none;
}
/* Each terminal sits in its own rounded frame so multiple panes in a view
read as distinct surfaces. */
.vtabs-pane {
.playpen-pane {
background-color: #16141c;
border-radius: 10px;
border: 1px solid #2a2536;
}
.vtabs-pane.active {
.playpen-pane.active {
border-color: #5b4d80;
}
.vtabs-pane-header {
.playpen-pane-header {
padding: 2px 4px 2px 10px;
background-color: #1c1926;
border-bottom: 1px solid #262133;
}
.vtabs-pane.active .vtabs-pane-header {
.playpen-pane.active .playpen-pane-header {
background-color: #241f33;
}
.vtabs-pane-title {
.playpen-pane-title {
font-size: 0.82em;
color: #8f87a3;
}
.vtabs-pane.active .vtabs-pane-title {
.playpen-pane.active .playpen-pane-title {
color: #ded7ef;
}
.vtabs-pane-button {
.playpen-pane-button {
min-width: 22px;
min-height: 22px;
padding: 0;
opacity: 0;
}
.vtabs-pane:hover .vtabs-pane-button,
.vtabs-pane.active .vtabs-pane-button {
.playpen-pane:hover .playpen-pane-button,
.playpen-pane.active .playpen-pane-button {
opacity: 0.55;
}
.vtabs-pane-button:hover {
.playpen-pane-button:hover {
opacity: 1;
}
.vtabs-pane-icon {
.playpen-pane-icon {
color: #6f6784;
-gtk-icon-size: 14px;
}
.vtabs-pane.active .vtabs-pane-icon {
.playpen-pane.active .playpen-pane-icon {
color: #b29df5;
}
/* A web pane's navigation bar, below the pane header. Kept visually quieter
than the header so the two rows don't compete. */
.vtabs-nav {
.playpen-nav {
padding: 4px 6px;
background-color: #16141c;
border-bottom: 1px solid #262133;
}
.vtabs-nav-button {
.playpen-nav-button {
min-width: 24px;
min-height: 24px;
padding: 0;
color: #b6afc7;
}
.vtabs-nav-button:disabled {
.playpen-nav-button:disabled {
opacity: 0.3;
}
.vtabs-nav-entry {
.playpen-nav-entry {
min-height: 24px;
margin-left: 4px;
padding: 2px 8px;
@@ -160,85 +160,85 @@
box-shadow: none;
}
.vtabs-nav-entry:focus-within {
.playpen-nav-entry:focus-within {
border-color: #5b4d80;
}
/* The pane being dragged. There is no separate drop indicator: the layout
rearranges live during the drag, so the view itself is the preview. */
.vtabs-pane.dragging {
.playpen-pane.dragging {
opacity: 0.65;
border-color: #b29df5;
}
/* Saved-layout menu, hanging off the sidebar header. */
.vtabs-layout-menu {
.playpen-layout-menu {
min-width: 260px;
}
.vtabs-layout-menu button {
.playpen-layout-menu button {
padding: 4px 8px;
border-radius: 6px;
}
.vtabs-layout-empty {
.playpen-layout-empty {
padding: 8px;
color: #8f87a3;
font-size: 0.9em;
}
/* Layout dialogs: opening one, and saving the current tab as one. */
.vtabs-dialog {
.playpen-dialog {
background-color: #16141c;
}
.vtabs-dialog-content {
.playpen-dialog-content {
padding: 16px;
}
.vtabs-dialog-actions {
.playpen-dialog-actions {
padding: 12px 16px;
border-top: 1px solid #262133;
}
.vtabs-dialog-heading {
.playpen-dialog-heading {
margin-top: 8px;
font-weight: bold;
color: #ded7ef;
}
.vtabs-dialog-label {
.playpen-dialog-label {
color: #b6afc7;
}
.vtabs-dialog-sublabel {
.playpen-dialog-sublabel {
font-size: 0.88em;
color: #8f87a3;
}
.vtabs-dialog-hint {
.playpen-dialog-hint {
font-size: 0.85em;
color: #8f87a3;
}
.vtabs-dialog-error {
.playpen-dialog-error {
color: #f2a0a0;
}
/* Each captured pane in the save dialog, so the sections read apart. */
.vtabs-dialog-pane {
.playpen-dialog-pane {
padding: 8px;
border: 1px solid #2a2536;
border-radius: 8px;
}
/* Divider between panes. Wide enough to grab without hunting for it. */
.vtabs-view paned > separator {
.playpen-view paned > separator {
background-color: #0f0d14;
min-width: 6px;
min-height: 6px;
}
.vtabs-view paned > separator:hover {
.playpen-view paned > separator:hover {
background-color: #4a3f6b;
}