Merge pull request #1 from fuddlesworth/qt-build-libghostty-opengl

Ghastty: Qt 6 frontend + libghostty embedded-OpenGL
pull/12846/head
Nathan 2026-05-20 19:46:29 -05:00 committed by GitHub
commit 7354bf7c36
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 6240 additions and 61 deletions

58
.dockerignore Normal file
View File

@ -0,0 +1,58 @@
# Keep the build context small. Anything Docker doesn't need to see
# during `docker build` belongs here — the image's libghostty stage
# COPYs the entire repo, so untracked dev cruft would otherwise bloat
# the layer and bust the build cache on every run.
# Version control + IDE/editor metadata.
.git
.gitignore
.gitattributes
.github
.vscode
.direnv
.envrc
.envrc.local
# Build outputs (we run zig/cmake fresh inside the container).
zig-cache
.zig-cache
zig-out
build
build-cmake
qt/build
CMakeCache.txt
CMakeFiles
*.o
*.so
*.dylib
# Test artefacts and crash dumps.
test/ghostty
test/cases/**/*.actual.png
vgcore.*
/Box_test.ppm
/Box_test_diff.ppm
/ghostty.qcow2
# Flatpak/Nix scratch — not needed in the image.
.flatpak-builder
flatpak/builddir
flatpak/repo
result
result-*
# Per-user agent state from this repo's session, not part of the build.
.claude
.claude-flow
.hive-mind
.mcp.json
.swarm
CLAUDE.md
MEMORY.md
# OS noise.
.DS_Store
*~
*.swp
.*.swp
*.log

136
Dockerfile Normal file
View File

@ -0,0 +1,136 @@
# syntax=docker/dockerfile:1.7
#
# Reproducible build for libghostty + the Qt frontend (ghastty).
#
# Usage:
# docker build -t ghastty .
# docker run --rm -v "$PWD/out:/host-out" ghastty \
# sh -c 'cp -a /out/. /host-out/'
#
# The runtime container does not ship a usable terminal — the Qt
# frontend wants a Wayland/X11 socket from the host. This image is for
# building (and CI testing) only.
#
# Stage layout:
# - base : Debian trixie + the Qt/Wayland deps both stages need
# - zig : pinned Zig toolchain (kept separate so a deps-only
# rebuild doesn't re-fetch Zig)
# - libghostty : zig build of libghostty (-Dapp-runtime=none) + tests
# - qt : cmake build of qt/ against the libghostty artifact
# - out : minimal final stage holding only the built binaries
ARG DEBIAN_VERSION=trixie
# Pinned to the project's minimum_zig_version (build.zig.zon).
ARG ZIG_VERSION=0.15.2
# ---------------------------------------------------------------------
# base — system packages shared across the build stages.
# ---------------------------------------------------------------------
FROM debian:${DEBIAN_VERSION}-slim AS base
ENV DEBIAN_FRONTEND=noninteractive
# Single apt layer so the package cache is dropped before the next
# stage. The list mixes:
# - build tooling (cmake, ninja, pkg-config, gcc, libstdc++-dev)
# - libghostty build deps via Zig (most are vendored; libxml2-dev is
# pulled in by the Sentry/breakpad path on Linux)
# - Qt 6 modules the frontend uses (Gui, Widgets, OpenGL, DBus,
# Multimedia, Svg) plus LayerShellQt
# - native-protocol deps the frontend hits directly (xkbcommon,
# wayland-client, wayland-scanner, xcb)
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
xz-utils \
git \
pkg-config \
cmake \
ninja-build \
build-essential \
libstdc++-14-dev \
qt6-base-dev \
qt6-base-private-dev \
qt6-multimedia-dev \
qt6-svg-dev \
liblayershellqtinterface-dev \
libxkbcommon-dev \
libwayland-dev \
wayland-protocols \
libxcb1-dev \
libxml2-dev \
&& rm -rf /var/lib/apt/lists/*
# ---------------------------------------------------------------------
# zig — fetch and unpack the pinned Zig toolchain.
#
# Kept separate from `base` so changing apt deps does not invalidate
# the (large) Zig download layer.
# ---------------------------------------------------------------------
FROM base AS zig
ARG ZIG_VERSION
RUN set -eux; \
arch="$(dpkg --print-architecture)"; \
case "$arch" in \
amd64) zig_arch=x86_64 ;; \
arm64) zig_arch=aarch64 ;; \
*) echo "unsupported arch: $arch" >&2; exit 1 ;; \
esac; \
tarball="zig-${zig_arch}-linux-${ZIG_VERSION}.tar.xz"; \
curl -fsSL -o "/tmp/${tarball}" \
"https://ziglang.org/download/${ZIG_VERSION}/${tarball}"; \
mkdir -p /opt/zig; \
tar -xJf "/tmp/${tarball}" -C /opt/zig --strip-components=1; \
rm "/tmp/${tarball}"; \
ln -s /opt/zig/zig /usr/local/bin/zig; \
zig version
# ---------------------------------------------------------------------
# libghostty — Zig build of the libghostty shared library + tests.
#
# We mount the source tree rather than COPY so a `docker build` run
# does not bake the entire repo into this layer. Caches:
# - /root/.cache/zig : Zig's per-user cache (compiled deps)
# - /src/.zig-cache : project-local cache (incremental rebuilds)
# ---------------------------------------------------------------------
FROM zig AS libghostty
WORKDIR /src
COPY . /src
# `-Dapp-runtime=none` makes the Zig build emit libghostty (the .so
# our Qt frontend links against) and skips the GTK frontend. Tests
# run first so a regression in libghostty fails the build cleanly,
# rather than later in the slower Qt stage.
RUN --mount=type=cache,target=/root/.cache/zig \
--mount=type=cache,target=/src/.zig-cache \
set -eux; \
zig build test -Dapp-runtime=none -Doptimize=Debug; \
zig build -Dapp-runtime=none -Doptimize=ReleaseFast
# ---------------------------------------------------------------------
# qt — CMake build of the Qt frontend against the libghostty artifact.
# ---------------------------------------------------------------------
FROM libghostty AS qt
WORKDIR /src/qt
# The CMake project links against zig-out/lib/ghostty-internal.so and
# materialises libghostty.so as a build-tree symlink (see qt/CMakeLists.txt).
# `--install` lays the binary, .so, .desktop entry and icon into /out
# under the standard FHS layout (bin/, lib/, share/...).
RUN set -eux; \
cmake -S /src/qt -B /src/qt/build -G Ninja \
-DCMAKE_BUILD_TYPE=Release; \
cmake --build /src/qt/build --parallel "$(nproc)"; \
cmake --install /src/qt/build --prefix /out
# ---------------------------------------------------------------------
# out — only the final artifacts. Run this image to extract them.
# ---------------------------------------------------------------------
FROM debian:${DEBIAN_VERSION}-slim AS out
COPY --from=qt /out /out
# Default command lists the artifacts so `docker run --rm ghastty`
# is informative without --entrypoint heroics.
CMD ["sh", "-c", "find /out -type f -o -type l | sort"]

1
embed-test/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
harness

29
embed-test/build.sh Executable file
View File

@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Build the libghostty OpenGL embedding harness (Workstream A4).
#
# Requires:
# - libghostty built first: zig build -Dapp-runtime=none
# (produces ../zig-out/lib/ghostty-internal.so)
# - GLFW 3 development files (pkg-config glfw3)
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(cd "$here/.." && pwd)"
lib="$root/zig-out/lib/ghostty-internal.so"
if [[ ! -f "$lib" ]]; then
echo "error: $lib not found — run 'zig build -Dapp-runtime=none' first" >&2
exit 1
fi
"${CC:-cc}" -std=c11 -Wall -Wextra -g \
-o "$here/harness" \
"$here/main.c" \
-I "$root/include" \
$(pkg-config --cflags glfw3) \
"$lib" \
$(pkg-config --libs glfw3) \
-lpthread -lm -ldl \
-Wl,-rpath,"$root/zig-out/lib"
echo "built: $here/harness"

282
embed-test/main.c Normal file
View File

@ -0,0 +1,282 @@
// libghostty OpenGL embedding harness (Workstream A4).
//
// A minimal GLFW host that drives libghostty through the
// GHOSTTY_PLATFORM_OPENGL embedded API. Its only purpose is to verify
// that the OpenGL embedded render path (A1 + A2) actually produces a
// rendered terminal. This is throwaway verification scaffolding, not a
// real terminal frontend.
//
// Build: see build.sh in this directory.
#include <stdatomic.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <GLFW/glfw3.h>
#include "ghostty.h"
// State shared with the libghostty callbacks.
typedef struct {
GLFWwindow *window;
} host_t;
// The single surface, so GLFW input callbacks can reach it.
static ghostty_surface_t g_surface = NULL;
// Set when libghostty asks for a redraw; the main loop then draws.
// libghostty action callbacks may run on a worker thread, so this
// must be atomic to pair the write in on_action with the read in
// the main loop.
static atomic_int g_needs_draw = 1;
// Count of presented frames. A nonzero value confirms the OpenGL
// embedded render path is producing frames.
static atomic_int g_frames = 0;
// --- ghostty_platform_opengl_s callbacks -----------------------------
//
// libghostty draws on the app thread (must_draw_from_app_thread), so
// these run on the main thread.
static void *gl_get_proc_address(void *userdata, const char *name) {
(void)userdata;
return (void *)glfwGetProcAddress(name);
}
static void gl_make_current(void *userdata) {
host_t *h = userdata;
glfwMakeContextCurrent(h->window);
}
static void gl_release_current(void *userdata) {
(void)userdata;
glfwMakeContextCurrent(NULL);
}
static void gl_present(void *userdata) {
host_t *h = userdata;
glfwSwapBuffers(h->window);
atomic_fetch_add(&g_frames, 1);
}
// --- ghostty_runtime_config_s callbacks ------------------------------
static void on_wakeup(void *userdata) {
(void)userdata;
// Called from another thread; nudge the main loop so it ticks.
glfwPostEmptyEvent();
}
static bool on_action(ghostty_app_t app, ghostty_target_s target,
ghostty_action_s action) {
(void)app;
(void)target;
// libghostty requests a redraw via the render action; the main loop
// services it. Other actions are ignored by this harness.
if (action.tag == GHOSTTY_ACTION_RENDER) {
atomic_store(&g_needs_draw, 1);
return true;
}
return false;
}
static bool on_read_clipboard(void *userdata, ghostty_clipboard_e loc,
void *state) {
(void)userdata;
(void)loc;
(void)state;
return false;
}
static void on_confirm_read_clipboard(void *userdata, const char *str,
void *state,
ghostty_clipboard_request_e req) {
(void)userdata;
(void)str;
(void)state;
(void)req;
}
static void on_write_clipboard(void *userdata, ghostty_clipboard_e loc,
const ghostty_clipboard_content_s *content,
size_t n, bool confirm) {
(void)userdata;
(void)loc;
(void)content;
(void)n;
(void)confirm;
}
static void on_close_surface(void *userdata, bool process_active) {
(void)userdata;
(void)process_active;
}
// --- GLFW input -> libghostty ----------------------------------------
static void on_char(GLFWwindow *win, unsigned int cp) {
(void)win;
if (!g_surface) return;
// Encode the UTF-32 codepoint to UTF-8 and feed it as text.
char buf[4];
int len = 0;
if (cp < 0x80) {
buf[len++] = (char)cp;
} else if (cp < 0x800) {
buf[len++] = (char)(0xC0 | (cp >> 6));
buf[len++] = (char)(0x80 | (cp & 0x3F));
} else if (cp < 0x10000) {
buf[len++] = (char)(0xE0 | (cp >> 12));
buf[len++] = (char)(0x80 | ((cp >> 6) & 0x3F));
buf[len++] = (char)(0x80 | (cp & 0x3F));
} else {
buf[len++] = (char)(0xF0 | (cp >> 18));
buf[len++] = (char)(0x80 | ((cp >> 12) & 0x3F));
buf[len++] = (char)(0x80 | ((cp >> 6) & 0x3F));
buf[len++] = (char)(0x80 | (cp & 0x3F));
}
ghostty_surface_text(g_surface, buf, (uintptr_t)len);
}
static void on_framebuffer_size(GLFWwindow *win, int w, int h) {
(void)win;
if (g_surface && w > 0 && h > 0) {
ghostty_surface_set_size(g_surface, (uint32_t)w, (uint32_t)h);
atomic_store(&g_needs_draw, 1);
}
}
int main(int argc, char **argv) {
if (ghostty_init((uintptr_t)argc, argv) != GHOSTTY_SUCCESS) {
fprintf(stderr, "ghostty_init failed\n");
return 1;
}
if (!glfwInit()) {
fprintf(stderr, "glfwInit failed (no display?)\n");
return 1;
}
// Ghostty's OpenGL renderer requires at least OpenGL 4.3 core.
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE);
GLFWwindow *window =
glfwCreateWindow(800, 600, "libghostty OpenGL harness", NULL, NULL);
if (!window) {
fprintf(stderr, "glfwCreateWindow failed\n");
glfwTerminate();
return 1;
}
// libghostty draws on the app thread, so the GL context stays current
// on this (the main) thread, where glfwCreateWindow left it.
host_t host = {.window = window};
// App-level runtime config.
ghostty_runtime_config_s rt = {0};
rt.userdata = &host;
rt.supports_selection_clipboard = false;
rt.wakeup_cb = on_wakeup;
rt.action_cb = on_action;
rt.read_clipboard_cb = on_read_clipboard;
rt.confirm_read_clipboard_cb = on_confirm_read_clipboard;
rt.write_clipboard_cb = on_write_clipboard;
rt.close_surface_cb = on_close_surface;
ghostty_config_t cfg = ghostty_config_new();
ghostty_config_finalize(cfg);
ghostty_app_t app = ghostty_app_new(&rt, cfg);
if (!app) {
fprintf(stderr, "ghostty_app_new failed\n");
return 1;
}
float xscale = 1.0f, yscale = 1.0f;
glfwGetWindowContentScale(window, &xscale, &yscale);
// Force a widely-available TERM since the libghostty built with
// -Dapp-runtime=none does not install Ghostty's terminfo.
ghostty_env_var_s env[] = {
{"TERM", "xterm-256color"},
};
// Surface config: hand libghostty our OpenGL context callbacks.
ghostty_surface_config_s sc = ghostty_surface_config_new();
sc.platform_tag = GHOSTTY_PLATFORM_OPENGL;
sc.platform.opengl = (ghostty_platform_opengl_s){
.userdata = &host,
.get_proc_address = gl_get_proc_address,
.make_current = gl_make_current,
.release_current = gl_release_current,
.present = gl_present,
};
sc.userdata = &host;
sc.scale_factor = xscale;
sc.env_vars = env;
sc.env_var_count = sizeof(env) / sizeof(env[0]);
ghostty_surface_t surface = ghostty_surface_new(app, &sc);
if (!surface) {
fprintf(stderr, "ghostty_surface_new failed\n");
return 1;
}
g_surface = surface;
int fbw, fbh;
glfwGetFramebufferSize(window, &fbw, &fbh);
ghostty_surface_set_content_scale(surface, xscale, yscale);
ghostty_surface_set_size(surface, (uint32_t)fbw, (uint32_t)fbh);
ghostty_surface_set_focus(surface, true);
glfwSetCharCallback(window, on_char);
glfwSetFramebufferSizeCallback(window, on_framebuffer_size);
printf("harness running — a terminal should render. "
"Close the window to exit.\n");
fflush(stdout);
// Main loop: pump GLFW events, tick libghostty, and draw when asked.
double next_report = glfwGetTime() + 1.0;
while (!glfwWindowShouldClose(window)) {
glfwWaitEventsTimeout(0.1);
ghostty_app_tick(app);
if (ghostty_surface_process_exited(surface)) {
glfwSetWindowShouldClose(window, GLFW_TRUE);
break;
}
// libghostty requested a draw (via the render action); service it
// on this thread. atomic_exchange clears the flag and reads it in
// one step, so a wakeup setting it again between the read and
// the draw is preserved for the next iteration.
if (atomic_exchange(&g_needs_draw, 0)) {
ghostty_surface_draw(surface);
}
// Report presented-frame count once per second.
double now = glfwGetTime();
if (now >= next_report) {
printf("frames presented: %d\n", atomic_load(&g_frames));
fflush(stdout);
next_report = now + 1.0;
}
}
printf("exiting — total frames presented: %d\n", atomic_load(&g_frames));
ghostty_surface_free(surface);
ghostty_app_free(app);
ghostty_config_free(cfg);
glfwDestroyWindow(window);
glfwTerminate();
return 0;
}

View File

@ -66,6 +66,7 @@ typedef enum {
GHOSTTY_PLATFORM_INVALID,
GHOSTTY_PLATFORM_MACOS,
GHOSTTY_PLATFORM_IOS,
GHOSTTY_PLATFORM_OPENGL,
} ghostty_platform_e;
typedef enum {
@ -453,9 +454,37 @@ typedef struct {
void* uiview;
} ghostty_platform_ios_s;
// Platform configuration for a host that provides its own OpenGL
// context (e.g. a Qt, X11, or Wayland application embedding libghostty).
//
// The host owns the OpenGL context and windowing. libghostty draws on
// the app (GUI) thread for the OpenGL renderer (the embedded apprt
// sets must_draw_from_app_thread for OpenGL), so these callbacks all
// run on the same thread that calls ghostty_surface_new and
// ghostty_surface_draw. The context only needs to be usable from that
// thread; it does not need to be thread-portable.
typedef struct {
// Userdata passed as the first argument to every callback below.
void* userdata;
// Return the address of the named OpenGL function, or NULL if it is
// not available. Used to load the GL function pointers.
void* (*get_proc_address)(void* userdata, const char* name);
// Make the host's OpenGL context current on the calling thread.
void (*make_current)(void* userdata);
// Release the host's OpenGL context from the calling thread.
void (*release_current)(void* userdata);
// Present the most recently rendered frame (e.g. swap buffers).
void (*present)(void* userdata);
} ghostty_platform_opengl_s;
typedef union {
ghostty_platform_macos_s macos;
ghostty_platform_ios_s ios;
ghostty_platform_opengl_s opengl;
} ghostty_platform_u;
typedef enum {
@ -488,6 +517,13 @@ typedef struct {
uint32_t cell_height_px;
} ghostty_surface_size_s;
typedef struct {
uint32_t x;
uint32_t y;
uint32_t width;
uint32_t height;
} ghostty_surface_cursor_position_s;
// Config types
// config.Path
@ -831,7 +867,7 @@ typedef enum {
// apprt.surface.Message.ChildExited
typedef struct {
uint32_t exit_code;
uint64_t timetime_ms;
uint64_t runtime_ms;
} ghostty_surface_message_childexited_s;
// terminal.osc.Command.ProgressReport.State
@ -1039,7 +1075,7 @@ typedef union {
typedef struct {
ghostty_ipc_target_tag_e tag;
ghostty_ipc_target_u target;
} chostty_ipc_target_s;
} ghostty_ipc_target_s;
// apprt.ipc.Action.NewWindow
typedef struct {
@ -1115,6 +1151,8 @@ GHOSTTY_API void ghostty_surface_set_focus(ghostty_surface_t, bool);
GHOSTTY_API void ghostty_surface_set_occlusion(ghostty_surface_t, bool);
GHOSTTY_API void ghostty_surface_set_size(ghostty_surface_t, uint32_t, uint32_t);
GHOSTTY_API ghostty_surface_size_s ghostty_surface_size(ghostty_surface_t);
GHOSTTY_API ghostty_surface_cursor_position_s
ghostty_surface_cursor_position(ghostty_surface_t);
GHOSTTY_API uint64_t ghostty_surface_foreground_pid(ghostty_surface_t);
GHOSTTY_API ghostty_string_s ghostty_surface_tty_name(ghostty_surface_t);
GHOSTTY_API void ghostty_surface_set_color_scheme(ghostty_surface_t,
@ -1194,6 +1232,18 @@ GHOSTTY_API void ghostty_inspector_metal_render(ghostty_inspector_t, void*, void
GHOSTTY_API bool ghostty_inspector_metal_shutdown(ghostty_inspector_t);
#endif
// Inspector rendering for the embedded OpenGL platform. init/shutdown
// manage the ImGui OpenGL backend; render draws the inspector into the
// currently bound GL framebuffer (the host makes its context current
// and binds the target framebuffer first).
//
// On Apple targets these symbols are present but no-op: Apple builds
// use the Metal inspector path above and the ImGui OpenGL3 backend is
// not compiled into libghostty there.
GHOSTTY_API bool ghostty_inspector_opengl_init(ghostty_inspector_t);
GHOSTTY_API void ghostty_inspector_opengl_render(ghostty_inspector_t);
GHOSTTY_API void ghostty_inspector_opengl_shutdown(ghostty_inspector_t);
// APIs I'd like to get rid of eventually but are still needed for now.
// Don't use these unless you know what you're doing.
GHOSTTY_API void ghostty_set_window_background_blur(ghostty_app_t, void*);

View File

@ -1646,9 +1646,9 @@ extension Ghostty {
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return false }
guard let surfaceView = self.surfaceView(from: surface) else { return false }
// We handle this when the window is visible and timetime_ms is greater than 0,
// We handle this when the window is visible and runtime_ms is greater than 0,
// which will rule out exit codes on launch
guard surfaceView.window != nil, v.timetime_ms > 0 else { return false }
guard surfaceView.window != nil, v.runtime_ms > 0 else { return false }
guard let config = (NSApplication.shared.delegate as? AppDelegate)?.ghostty.config else { return false }
surfaceView.setChildExitedMessage(.init(v, threshold: config.abnormalCommandExitRuntime))
return true

View File

@ -21,11 +21,11 @@ extension Ghostty {
// See: Surface.zig/childExited
// If our runtime was below some threshold then we assume that this
// was an abnormal exit and we show an error message.
if abnormalCommandExitRuntime >= .milliseconds(message.timetime_ms) {
if abnormalCommandExitRuntime >= .milliseconds(message.runtime_ms) {
level = .error
let measure = Measurement.init(value: Double(message.timetime_ms), unit: UnitDuration.milliseconds)
let measure = Measurement.init(value: Double(message.runtime_ms), unit: UnitDuration.milliseconds)
let formatter = MeasurementFormatter()
if message.timetime_ms > 1000 {
if message.runtime_ms > 1000 {
formatter.unitOptions = .naturalScale
} else {
formatter.unitOptions = .providedUnit

1
qt/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
build/

190
qt/CMakeLists.txt Normal file
View File

@ -0,0 +1,190 @@
cmake_minimum_required(VERSION 3.16)
# C is needed for the wayland-scanner-generated protocol code.
project(ghastty LANGUAGES CXX C)
# Ghastty: a Qt6 frontend for Ghostty that embeds libghostty through the
# GHOSTTY_PLATFORM_OPENGL C API. libghostty's OpenGL renderer draws each
# terminal into an offscreen framebuffer (a private QOpenGLContext); the
# frame is read back into a QImage and painted on an ordinary translucent
# QWidget. That keeps the terminal a plain widget it embeds in the
# tab/split tree and its transparent background composites like any other
# widget. libghostty draws on the GUI thread (the embedded apprt sets
# must_draw_from_app_thread for the OpenGL renderer).
#
# Build libghostty first, from the repo root. Use ReleaseFast: a Debug
# build of libghostty leaves the whole terminal core unoptimized and
# makes rendering and surface creation noticeably sluggish.
#
# zig build -Dapp-runtime=none -Doptimize=ReleaseFast
#
# Then build, run, and (optionally) install this app:
#
# cmake -S qt -B qt/build && cmake --build qt/build
# ./qt/build/ghastty
# cmake --install qt/build --prefix ~/.local
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
include(GNUInstallDirs)
find_package(Qt6 REQUIRED COMPONENTS Gui Widgets OpenGL DBus
Multimedia Svg)
# WindowBlur uses qpa/qplatformnativeinterface.h to reach the wl_display
# / wl_surface / xcb_connection_t for the native compositor blur calls.
# Qt's official path is `find_package(Qt6 COMPONENTS GuiPrivate)`, but
# Debian's qt6-base-private-dev ships the private headers without the
# matching Qt6GuiPrivateConfig.cmake. Detect the component when present;
# otherwise fall back to wiring the private include dir by hand.
find_package(Qt6 QUIET OPTIONAL_COMPONENTS GuiPrivate)
# LayerShellQt: the quick terminal is a wlr-layer-shell dropdown window.
find_package(LayerShellQt REQUIRED)
# KWin background-blur is applied through the native compositor: the
# org_kde_kwin_blur Wayland protocol and the _KDE_NET_WM_BLUR atom on
# X11. Qt6::GuiPrivate gives the QPA native-handle accessors.
find_package(PkgConfig REQUIRED)
pkg_check_modules(WAYLAND_CLIENT REQUIRED IMPORTED_TARGET wayland-client)
pkg_check_modules(XCB REQUIRED IMPORTED_TARGET xcb)
# libxkbcommon: derive the unshifted Unicode codepoint for a key event
# from its XKB keycode, so libghostty's kitty encoder finds an entry for
# punctuation keys (Qt's ev->key() reports the SHIFTED symbol, e.g.
# Qt::Key_Colon for Shift+;).
pkg_check_modules(XKBCOMMON REQUIRED IMPORTED_TARGET xkbcommon)
find_program(WAYLAND_SCANNER wayland-scanner REQUIRED)
# Generate client glue for the org_kde_kwin_blur protocol.
set(BLUR_XML "${CMAKE_CURRENT_SOURCE_DIR}/protocols/blur.xml")
set(BLUR_HEADER "${CMAKE_CURRENT_BINARY_DIR}/blur-client-protocol.h")
set(BLUR_CODE "${CMAKE_CURRENT_BINARY_DIR}/blur-protocol.c")
add_custom_command(OUTPUT "${BLUR_HEADER}"
COMMAND "${WAYLAND_SCANNER}" client-header "${BLUR_XML}" "${BLUR_HEADER}"
DEPENDS "${BLUR_XML}" VERBATIM)
add_custom_command(OUTPUT "${BLUR_CODE}"
COMMAND "${WAYLAND_SCANNER}" private-code "${BLUR_XML}" "${BLUR_CODE}"
DEPENDS "${BLUR_XML}" VERBATIM)
# libghostty is built out-of-tree by Zig.
get_filename_component(GHOSTTY_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/.." ABSOLUTE)
set(GHOSTTY_LIB_DIR "${GHOSTTY_ROOT}/zig-out/lib")
set(GHOSTTY_SO "${GHOSTTY_LIB_DIR}/ghostty-internal.so")
if(NOT EXISTS "${GHOSTTY_SO}")
message(FATAL_ERROR
"libghostty not found at ${GHOSTTY_SO}\n"
"Build it first from the repo root: zig build -Dapp-runtime=none")
endif()
# The .so's SONAME is libghostty.so but it is installed as
# ghostty-internal.so. Create a symlink in the build tree (NOT in the
# Zig output dir, which is the source tree from CMake's perspective)
# so the loader finds it under the SONAME the binary links against.
set(GHOSTTY_LINK_DIR "${CMAKE_CURRENT_BINARY_DIR}/lib")
set(GHOSTTY_LINK_SO "${GHOSTTY_LINK_DIR}/libghostty.so")
add_custom_command(OUTPUT "${GHOSTTY_LINK_SO}"
COMMAND "${CMAKE_COMMAND}" -E make_directory "${GHOSTTY_LINK_DIR}"
COMMAND "${CMAKE_COMMAND}" -E create_symlink
"${GHOSTTY_SO}" "${GHOSTTY_LINK_SO}"
DEPENDS "${GHOSTTY_SO}" VERBATIM)
add_custom_target(ghostty_link DEPENDS "${GHOSTTY_LINK_SO}")
add_executable(ghastty
src/main.cpp
src/CommandPalette.cpp
src/GhosttySurface.cpp
src/GlobalShortcuts.cpp
src/InspectorWindow.cpp
src/MainWindow.cpp
src/OverlayScrollbar.cpp
src/SearchBar.cpp
src/TabWidget.cpp
src/Util.cpp
src/WindowBlur.cpp
"${BLUR_CODE}"
"${BLUR_HEADER}"
)
# Embed the app icon so it is available even running from the build tree.
qt_add_resources(ghastty "appicon"
PREFIX "/"
BASE dist
FILES dist/ghastty.svg)
target_include_directories(ghastty PRIVATE
"${GHOSTTY_ROOT}/include"
"${CMAKE_CURRENT_BINARY_DIR}" # generated blur-client-protocol.h
)
add_dependencies(ghastty ghostty_link)
target_link_libraries(ghastty PRIVATE
Qt6::Gui
Qt6::Widgets
Qt6::OpenGL
Qt6::DBus
Qt6::Multimedia
Qt6::Svg
PkgConfig::WAYLAND_CLIENT
PkgConfig::XCB
PkgConfig::XKBCOMMON
LayerShellQt::Interface
"${GHOSTTY_LINK_SO}"
)
# Hook up the private QPA headers (see find_package above).
if(TARGET Qt6::GuiPrivate)
target_link_libraries(ghastty PRIVATE Qt6::GuiPrivate)
else()
# Debian fallback: locate qplatformnativeinterface.h and add its
# parent dir as a private include (the header lives at
# <prefix>/QtGui/<version>/QtGui/qpa/qplatformnativeinterface.h, so we
# add the dir containing the `qpa` subfolder).
# Multi-arch Debian uses ${CMAKE_LIBRARY_ARCHITECTURE} (e.g.
# aarch64-linux-gnu) for include and lib subdirs. Hardcoding a list
# missed armhf/riscv64; deriving from the toolchain handles every
# arch the Qt 6 packages support.
find_path(QT_QPA_INCLUDE_DIR
NAMES qpa/qplatformnativeinterface.h
HINTS
"${Qt6Gui_PRIVATE_INCLUDE_DIRS}"
"${Qt6_DIR}/../../../include/${CMAKE_LIBRARY_ARCHITECTURE}/qt6/QtGui/${Qt6_VERSION}/QtGui"
"${Qt6_DIR}/../../../include/qt6/QtGui/${Qt6_VERSION}/QtGui"
PATH_SUFFIXES
"qt6/QtGui/${Qt6_VERSION}/QtGui"
)
if(NOT QT_QPA_INCLUDE_DIR)
message(FATAL_ERROR
"Qt6 private QPA headers not found.\n"
"Install qt6-base-private-dev (Debian/Ubuntu) or the equivalent "
"Qt 6 private headers package.")
endif()
message(STATUS "Using Qt private include dir: ${QT_QPA_INCLUDE_DIR}")
target_include_directories(ghastty PRIVATE "${QT_QPA_INCLUDE_DIR}")
endif()
# RPATH:
# - Build tree: libghostty.so lives in our build dir (a symlink to the
# actual zig-out artifact), and the .so's NEEDED entries also point
# into zig-out/lib for transitive deps.
# - Installed: libghostty.so lives next to the binary ($ORIGIN/../lib).
set_target_properties(ghastty PROPERTIES
BUILD_RPATH "${GHOSTTY_LINK_DIR};${GHOSTTY_LIB_DIR}"
INSTALL_RPATH "$ORIGIN/../${CMAKE_INSTALL_LIBDIR}"
)
# --- install ---------------------------------------------------------
install(TARGETS ghastty RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}")
# libghostty.so the binary links against (SONAME is libghostty.so).
install(FILES "${GHOSTTY_SO}"
DESTINATION "${CMAKE_INSTALL_LIBDIR}"
RENAME libghostty.so)
install(FILES dist/ghastty.desktop
DESTINATION "${CMAKE_INSTALL_DATADIR}/applications")
# The custom scalable app icon.
install(FILES dist/ghastty.svg
DESTINATION "${CMAKE_INSTALL_DATADIR}/icons/hicolor/scalable/apps")

13
qt/dist/ghastty.desktop vendored Normal file
View File

@ -0,0 +1,13 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=Ghastty
GenericName=Terminal
Comment=A terminal emulator
Exec=ghastty
Icon=ghastty
Categories=System;TerminalEmulator;
Keywords=terminal;tty;pty;
StartupNotify=true
StartupWMClass=ghastty
Terminal=false

90
qt/dist/ghastty.svg vendored Normal file
View File

@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- V1: Original dome ghost + 4 wavy feet, on the new purple CRT.
Wide-set >_ prompt eyes (upstream Ghostty lineage). No glitch
effects. Cleanest small-size legibility. -->
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024"
viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bezel" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#3a2c5e"/>
<stop offset="0.5" stop-color="#1c1430"/>
<stop offset="1" stop-color="#08060f"/>
</linearGradient>
<linearGradient id="bezelSheen" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#7d6cb0" stop-opacity="0.4"/>
<stop offset="0.15" stop-color="#7d6cb0" stop-opacity="0"/>
</linearGradient>
<radialGradient id="screen" cx="0.5" cy="0.35" r="0.85">
<stop offset="0" stop-color="#221d52"/>
<stop offset="0.6" stop-color="#0f0c2a"/>
<stop offset="1" stop-color="#040312"/>
</radialGradient>
<radialGradient id="screenBloom" cx="0.5" cy="0.3" r="0.55">
<stop offset="0" stop-color="#8a4ad8" stop-opacity="0.30"/>
<stop offset="1" stop-color="#8a4ad8" stop-opacity="0"/>
</radialGradient>
<linearGradient id="screenGloss" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.10"/>
<stop offset="0.45" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="ghostBody" gradientUnits="userSpaceOnUse"
x1="512" y1="280" x2="512" y2="780">
<stop offset="0" stop-color="#f4f1ea"/>
<stop offset="0.55" stop-color="#e3ddd0"/>
<stop offset="1" stop-color="#a89fa8"/>
</linearGradient>
<radialGradient id="mouthGlow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#ff4838" stop-opacity="0.55"/>
<stop offset="1" stop-color="#ff4838" stop-opacity="0"/>
</radialGradient>
</defs>
<rect x="0" y="0" width="1024" height="1024" rx="200" fill="url(#bezel)"/>
<rect x="0" y="0" width="1024" height="1024" rx="200" fill="url(#bezelSheen)"/>
<rect x="88" y="88" width="848" height="848" rx="120" fill="url(#screen)"/>
<rect x="88" y="88" width="848" height="848" rx="120" fill="url(#screenBloom)"/>
<rect x="88" y="88" width="848" height="848" rx="120" fill="url(#screenGloss)"/>
<!-- Dome ghost: rounded top (radius 190) + straight sides + 4 wavy
feet at bottom. Path goes counter-clockwise from top center. -->
<path d="
M 322 658
L 322 460
A 190 190 0 0 1 702 460
L 702 658
Q 654 720 607 658
Q 559 720 512 658
Q 464 720 417 658
Q 369 720 322 658 Z
" fill="url(#ghostBody)"/>
<path d="
M 322 658
L 322 460
A 190 190 0 0 1 702 460
L 702 658
Q 654 720 607 658
Q 559 720 512 658
Q 464 720 417 658
Q 369 720 322 658 Z
" fill="none" stroke="#0a0c14" stroke-opacity="0.55"
stroke-width="9" stroke-linejoin="round"/>
<!-- > _ prompt eyes, centered on ghost x=512. Pair geometry:
chevron 66 wide (L-arm tip to apex), gap 68, cursor 98 wide.
Total 232; leftmost = 512 - 116 = 396.
chevron: x=396..462 (apex at 462)
cursor: x=530..628
Pair midpoint = (396+628)/2 = 512 ✓
Vertical center y=496. -->
<ellipse cx="512" cy="496" rx="200" ry="80" fill="url(#mouthGlow)"/>
<path d="M 396 458 L 462 496 L 396 534"
fill="none" stroke="#e63232" stroke-width="28"
stroke-linecap="round" stroke-linejoin="round"/>
<rect x="530" y="478" width="98" height="36" rx="6" fill="#e63232"/>
<!-- Small mouth: sits just below the eyes, above the feet trough.
The dome's straight-side region ends at y=658 where the feet
begin; placing the mouth at y=580..612 keeps it on the body
proper, not in the foot scallops. -->
<ellipse cx="512" cy="596" rx="56" ry="32" fill="url(#mouthGlow)"/>
<rect x="492" y="580" width="40" height="32" rx="4" fill="#e63232"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

28
qt/protocols/blur.xml Normal file
View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="blur">
<copyright><![CDATA[
SPDX-FileCopyrightText: 2015 Martin Gräßlin
SPDX-FileCopyrightText: 2015 Marco Martin
SPDX-License-Identifier: LGPL-2.1-or-later
]]></copyright>
<interface name="org_kde_kwin_blur_manager" version="1">
<request name="create">
<arg name="id" type="new_id" interface="org_kde_kwin_blur"/>
<arg name="surface" type="object" interface="wl_surface"/>
</request>
<request name="unset">
<arg name="surface" type="object" interface="wl_surface"/>
</request>
</interface>
<interface name="org_kde_kwin_blur" version="1">
<request name="commit">
</request>
<request name="set_region">
<arg name="region" type="object" interface="wl_region" allow-null="true"/>
</request>
<request name="release" type="destructor">
<description summary="release the blur object"/>
</request>
</interface>
</protocol>

159
qt/src/CommandPalette.cpp Normal file
View File

@ -0,0 +1,159 @@
#include "CommandPalette.h"
#include <QAbstractItemView>
#include <QByteArray>
#include <QEvent>
#include <QKeyEvent>
#include <QLineEdit>
#include <QListView>
#include <QSortFilterProxyModel>
#include <QStandardItemModel>
#include <QTimer>
#include <QVBoxLayout>
#include "GhosttySurface.h"
#include "MainWindow.h"
#include "Util.h"
namespace {
// Item data roles: the keybind action to run, and the text the filter
// matches against (title plus the bare action name).
constexpr int kActionRole = Qt::UserRole;
constexpr int kFilterRole = Qt::UserRole + 1;
} // namespace
CommandPalette::CommandPalette(QWidget *owner)
: QWidget(owner, Qt::Popup), m_owner(owner) {
resize(620, 420);
m_search = new QLineEdit(this);
m_search->setPlaceholderText(QStringLiteral("Run a command…"));
m_search->setClearButtonEnabled(true);
m_search->installEventFilter(this);
m_model = new QStandardItemModel(this);
m_filter = new QSortFilterProxyModel(this);
m_filter->setSourceModel(m_model);
m_filter->setFilterRole(kFilterRole);
m_filter->setFilterCaseSensitivity(Qt::CaseInsensitive);
m_list = new QListView(this);
m_list->setModel(m_filter);
m_list->setEditTriggers(QAbstractItemView::NoEditTriggers);
m_list->setSelectionMode(QAbstractItemView::SingleSelection);
m_list->setUniformItemSizes(true);
m_list->setFocusPolicy(Qt::NoFocus); // keep typing in the search box
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(8, 8, 8, 8);
layout->addWidget(m_search);
layout->addWidget(m_list);
// Debounce filter updates so a fast typist on a long
// command-palette-entry list doesn't thrash QSortFilterProxyModel.
// 80ms feels live without flooring slow systems.
m_filterDebounce = new QTimer(this);
m_filterDebounce->setSingleShot(true);
m_filterDebounce->setInterval(80);
connect(m_filterDebounce, &QTimer::timeout, this, [this]() {
m_filter->setFilterFixedString(m_search->text());
selectFirstRow();
});
connect(m_search, &QLineEdit::textChanged, this,
[this]() { m_filterDebounce->start(); });
connect(m_list, &QListView::activated, this,
[this](const QModelIndex &) { runSelected(); });
hide();
}
void CommandPalette::toggleFor(GhosttySurface *surface) {
if (isVisible()) {
hide();
return;
}
// If the owner window is gone (closed while we were hidden), the
// QPointer auto-nulled — we can't place ourselves sensibly without
// it, so bail. The next initialize on a new window creates a fresh
// palette.
if (!m_owner) return;
m_surface = surface;
populate();
m_search->clear();
m_filter->setFilterFixedString(QString());
// Centre over the owner, biased toward the top. As a Qt::Popup the
// position is interpreted relative to the parent window, so this
// places correctly on Wayland too.
const QPoint p((m_owner->width() - width()) / 2, m_owner->height() / 6);
move(m_owner->mapToGlobal(p));
show();
m_search->setFocus();
selectFirstRow();
}
void CommandPalette::populate() {
m_model->clear();
if (!m_surface || !m_surface->owner()) return;
ghostty_config_t cfg = m_surface->owner()->config();
if (!cfg) return;
// command-palette-entry defaults to a large built-in command set.
ghostty_config_command_list_s list = {};
if (!configGet(cfg, &list, "command-palette-entry")) return;
for (size_t i = 0; i < list.len; ++i) {
const ghostty_command_s &c = list.commands[i];
const QString title = QString::fromUtf8(c.title ? c.title : "");
const QString action = QString::fromUtf8(c.action ? c.action : "");
if (title.isEmpty() || action.isEmpty()) continue;
auto *item = new QStandardItem(title);
item->setData(action, kActionRole);
item->setData(title + QLatin1Char(' ') +
QString::fromUtf8(c.action_key ? c.action_key : ""),
kFilterRole);
if (c.description && *c.description)
item->setToolTip(QString::fromUtf8(c.description));
m_model->appendRow(item);
}
}
void CommandPalette::selectFirstRow() {
if (m_filter->rowCount() > 0)
m_list->setCurrentIndex(m_filter->index(0, 0));
}
void CommandPalette::moveSelection(int delta) {
const int n = m_filter->rowCount();
if (n == 0) return;
int row = m_list->currentIndex().row();
row = qBound(0, (row < 0 ? 0 : row) + delta, n - 1);
m_list->setCurrentIndex(m_filter->index(row, 0));
}
void CommandPalette::runSelected() {
const QModelIndex idx = m_list->currentIndex();
if (!idx.isValid()) return;
const QString action = idx.data(kActionRole).toString();
GhosttySurface *surface = m_surface;
hide(); // close before executing, matching the GTK palette
if (surface && surface->surface() && !action.isEmpty()) {
const QByteArray a = action.toUtf8();
ghostty_surface_binding_action(surface->surface(), a.constData(),
a.size());
}
}
bool CommandPalette::eventFilter(QObject *obj, QEvent *event) {
if (obj == m_search && event->type() == QEvent::KeyPress) {
auto *ke = static_cast<QKeyEvent *>(event);
switch (ke->key()) {
case Qt::Key_Up: moveSelection(-1); return true;
case Qt::Key_Down: moveSelection(1); return true;
case Qt::Key_Return:
case Qt::Key_Enter: runSelected(); return true;
case Qt::Key_Escape: hide(); return true;
default: break;
}
}
return QWidget::eventFilter(obj, event);
}

49
qt/src/CommandPalette.h Normal file
View File

@ -0,0 +1,49 @@
#pragma once
#include <QPointer>
#include <QWidget>
class GhosttySurface;
class QLineEdit;
class QListView;
class QSortFilterProxyModel;
class QStandardItemModel;
class QTimer;
// A searchable command palette (the TOGGLE_COMMAND_PALETTE action).
//
// It lists the commands from the `command-palette-entry` config (a
// large built-in default set plus any user additions) and runs the
// chosen command's keybind action on the active surface. Shown as a
// Qt::Popup over its owner — Qt anchors it to the parent window (so it
// places correctly on Wayland) and dismisses it on an outside click.
class CommandPalette : public QWidget {
Q_OBJECT
public:
explicit CommandPalette(QWidget *owner);
// Show the palette for `surface` (populating from the live config),
// or hide it if already visible.
void toggleFor(GhosttySurface *surface);
protected:
bool eventFilter(QObject *obj, QEvent *event) override;
private:
void populate(); // (re)load the command list from config
void runSelected(); // execute the highlighted command
void moveSelection(int delta);
void selectFirstRow();
// Owner window the palette centres over. QPointer so a window
// closed while the palette is hidden doesn't leave us with a
// dangling raw pointer (toggleFor dereferences it for placement).
QPointer<QWidget> m_owner;
QLineEdit *m_search = nullptr;
QListView *m_list = nullptr;
QStandardItemModel *m_model = nullptr;
QSortFilterProxyModel *m_filter = nullptr;
QTimer *m_filterDebounce = nullptr; // coalesces rapid keystrokes
QPointer<GhosttySurface> m_surface; // active surface; may go away
};

1024
qt/src/GhosttySurface.cpp Normal file

File diff suppressed because it is too large Load Diff

207
qt/src/GhosttySurface.h Normal file
View File

@ -0,0 +1,207 @@
#pragma once
#include <atomic>
#include <QImage>
#include <QPointer>
#include <QStringList>
#include <QWidget>
#include "ghostty.h"
class MainWindow;
class QContextMenuEvent;
class QDragEnterEvent;
class QDropEvent;
class QEnterEvent;
class QTimer;
class InspectorWindow;
class SearchBar;
class QInputMethodEvent;
class QKeySequence;
class QLabel;
class QOffscreenSurface;
class QOpenGLContext;
class QOpenGLFramebufferObject;
class QOpenGLShaderProgram;
class QOpenGLVertexArrayObject;
class OverlayScrollbar;
// One Ghostty terminal pane.
//
// libghostty's OpenGL renderer draws the terminal into an offscreen
// framebuffer owned by a private QOpenGLContext (there is no on-screen
// GL surface). Each frame is read back into a QImage and painted with
// QPainter. That keeps this an ordinary translucent QWidget, so it
// embeds in the QTabWidget / QSplitter tree and its transparent
// background composites to the desktop exactly like the rest of the
// widget chrome — avoiding QOpenGLWidget (composites opaque on Wayland)
// and an embedded QOpenGLWindow (does not present when embedded).
class GhosttySurface : public QWidget {
Q_OBJECT
public:
// `parent_surface` (may be null) is the surface whose working
// directory etc. a new surface should inherit.
GhosttySurface(ghostty_app_t app, MainWindow *owner,
ghostty_surface_t parent_surface);
~GhosttySurface() override;
ghostty_surface_t surface() const { return m_surface; }
MainWindow *owner() const { return m_owner; }
// Reassign the owning window (used when a tab is torn off into one).
void setOwner(MainWindow *owner) { m_owner = owner; }
// Show a dismissable "process exited" overlay over the terminal. The
// surface stays open until the user dismisses it (key or click).
void showChildExited(int exitCode);
// Arm a one-shot desktop notification for the next command to finish
// (context-menu item); consumeCommandNotify reads-and-clears the flag.
void armCommandNotify() { m_notifyOnCommand = true; }
bool consumeCommandNotify() {
const bool armed = m_notifyOnCommand;
m_notifyOnCommand = false;
return armed;
}
public:
// Render coalescing: markDirty() flags the surface (called from the
// RENDER action, possibly off-thread); renderIfDirty(), called once a
// frame by MainWindow, does the actual render.
void markDirty() { m_dirty.store(true); }
void renderIfDirty();
// Reflect a libghostty SCROLLBAR action: total scrollback rows, the
// viewport-top row, and the visible row count.
void updateScrollbar(uint64_t total, uint64_t offset, uint64_t len);
// Open the title-change dialog (PROMPT_TITLE action / context menu);
// `tabScope` picks the tab vs surface title.
void promptTitle(bool tabScope);
// Pending-keybind chord overlay, driven by the KEY_SEQUENCE action:
// pushKeySequence appends a chord, endKeySequence clears the overlay.
void pushKeySequence(const QString &chord);
void endKeySequence();
// Show/hide/toggle the terminal inspector window (INSPECTOR action).
void toggleInspector(ghostty_action_inspector_e mode);
// In-terminal search (the *_SEARCH actions): openSearch shows the
// search bar (optionally pre-filled), closeSearch hides it, and the
// setSearch* calls mirror libghostty's reported match counters.
void openSearch(const QString &prefill);
void closeSearch();
void setSearchTotal(int total);
void setSearchSelected(int selected);
// Bell `border` feature: briefly flash a border over the terminal.
void flashBorder();
// Bell `title` feature: mark/unmark an unacknowledged bell. MainWindow
// prefixes the tab title while any surface in the tab is marked.
void setBellTitle(bool marked) { m_bellTitle = marked; }
bool bellTitle() const { return m_bellTitle; }
protected:
bool event(QEvent *) override;
void paintEvent(QPaintEvent *) override;
void resizeEvent(QResizeEvent *) override;
// Disable Qt's Tab/Backtab focus traversal so those keys reach
// keyPressEvent and can be forwarded to the terminal.
bool focusNextPrevChild(bool) override { return false; }
void keyPressEvent(QKeyEvent *) override;
void keyReleaseEvent(QKeyEvent *) override;
void mousePressEvent(QMouseEvent *) override;
void mouseReleaseEvent(QMouseEvent *) override;
void mouseMoveEvent(QMouseEvent *) override;
void contextMenuEvent(QContextMenuEvent *) override;
void dragEnterEvent(QDragEnterEvent *) override;
void dropEvent(QDropEvent *) override;
void wheelEvent(QWheelEvent *) override;
void enterEvent(QEnterEvent *) override; // focus-follows-mouse
void focusInEvent(QFocusEvent *) override;
void focusOutEvent(QFocusEvent *) override;
// IME composition: preedit text is forwarded to libghostty for inline
// display; committed text is inserted as input.
void inputMethodEvent(QInputMethodEvent *) override;
QVariant inputMethodQuery(Qt::InputMethodQuery) const override;
private:
bool makeCurrent();
void syncSurfaceSize();
void renderTerminal();
void layoutScrollbar(); // position the scrollbar at the edge
bool scrollbarAllowed() const; // false when `scrollbar = never`
void flashScrollbar(); // reveal the overlay scrollbar, arm hide
void buildExitOverlay(int exitCode);
void showResizeOverlay(); // transient grid-size overlay on resize
void layoutSearchBar(); // position the search bar at the top edge
void sendKey(QKeyEvent *, ghostty_input_action_e action);
void commitText(const QString &text);
void sendMouseButton(QMouseEvent *, ghostty_input_mouse_state_e state);
bool rightClickOpensMenu(QMouseEvent *ev) const;
// The keybind currently bound to `action` (for context-menu hints),
// or an empty sequence if none / not displayable.
QKeySequence shortcutFor(const char *action) const;
// Premultiply the framebuffer's alpha; only used when a custom shader
// is configured (see GhosttySurface.cpp).
void initPremultiply();
void premultiplyFramebuffer();
// libghostty GL platform callbacks (all run on the GUI thread).
static void *glGetProcAddress(void *ud, const char *name);
static void glMakeCurrent(void *ud);
static void glReleaseCurrent(void *ud);
static void glPresent(void *ud);
ghostty_app_t m_app; // shared; owned by MainWindow
MainWindow *m_owner; // not owned
ghostty_surface_t m_parentSurface; // inherited-config source; may be null
ghostty_surface_t m_surface = nullptr;
// Private offscreen GL context libghostty renders into.
QOpenGLContext *m_context = nullptr;
QOffscreenSurface *m_offscreen = nullptr;
QOpenGLFramebufferObject *m_fbo = nullptr;
QImage m_image; // last frame, read back from m_fbo
// GL objects for the alpha-premultiply pass.
QOpenGLShaderProgram *m_premultProg = nullptr;
QOpenGLVertexArrayObject *m_premultVao = nullptr;
int m_fbw = 0; // framebuffer size, device pixels
int m_fbh = 0;
double m_fbDpr = 1.0; // DPR the framebuffer was sized at
QLabel *m_exitOverlay = nullptr; // "process exited" banner; lazily made
QLabel *m_keySeqOverlay = nullptr; // pending keybind chord; lazily made
QStringList m_keySeq; // accumulated pending chords
QLabel *m_resizeOverlay = nullptr; // transient "cols x rows"; lazily made
QTimer *m_resizeHideTimer = nullptr; // auto-hides m_resizeOverlay
bool m_firstGridSeen = false; // for `resize-overlay = after-first`
int m_lastCols = 0; // last grid size, to detect changes
int m_lastRows = 0;
SearchBar *m_searchBar = nullptr; // in-terminal search; lazily made
// Terminal inspector window; lazily made. QPointer so a WM-driven
// close (treated as hide) or a parent-destroyed cascade leaves the
// pointer null instead of dangling.
QPointer<InspectorWindow> m_inspectorWindow;
OverlayScrollbar *m_scrollbar = nullptr; // floating scrollback scrollbar
bool m_scrollAtBottom = true; // viewport is following the buffer tail
bool m_notifyOnCommand = false; // one-shot: notify on next cmd finish
bool m_bellFlash = false; // bell `border` flash in progress
bool m_bellTitle = false; // unacknowledged bell `title` mark
// Tracks whether the prior inputMethodEvent reported active preedit.
// Used to distinguish a real post-composition commit (forward to the
// terminal) from the duplicate ASCII commit that Wayland's
// text-input-v3 fires alongside a keyPressEvent (drop it — the key
// event will deliver the same text).
bool m_hadPreedit = false;
std::atomic<bool> m_dirty{false}; // a frame render is pending
};

157
qt/src/GlobalShortcuts.cpp Normal file
View File

@ -0,0 +1,157 @@
#include "GlobalShortcuts.h"
#include <cstdio>
#include <QDBusArgument>
#include <QDBusConnection>
#include <QDBusError>
#include <QDBusMessage>
#include <QDBusMetaType>
#include <QDBusObjectPath>
#include <QDBusPendingCallWatcher>
#include <QDBusPendingReply>
#include <QVariant>
#include <QVariantList>
namespace {
constexpr const char *kService = "org.freedesktop.portal.Desktop";
constexpr const char *kPath = "/org/freedesktop/portal/desktop";
constexpr const char *kInterface = "org.freedesktop.portal.GlobalShortcuts";
constexpr const char *kRequest = "org.freedesktop.portal.Request";
} // namespace
// One declared shortcut, marshalled as the portal's `(sa{sv})`.
struct PortalShortcut {
QString id;
QVariantMap props;
};
Q_DECLARE_METATYPE(PortalShortcut)
QDBusArgument &operator<<(QDBusArgument &arg, const PortalShortcut &s) {
arg.beginStructure();
arg << s.id << s.props;
arg.endStructure();
return arg;
}
const QDBusArgument &operator>>(const QDBusArgument &arg, PortalShortcut &s) {
arg.beginStructure();
arg >> s.id >> s.props;
arg.endStructure();
return arg;
}
GlobalShortcuts::GlobalShortcuts(QObject *parent) : QObject(parent) {
qDBusRegisterMetaType<PortalShortcut>();
qDBusRegisterMetaType<QList<PortalShortcut>>();
QDBusConnection bus = QDBusConnection::sessionBus();
// One broad subscription, registered now (before the event loop), so
// no portal Response can outrun its match rule. An empty path makes
// it match every Request object.
bus.connect(QString::fromLatin1(kService), QString(),
QString::fromLatin1(kRequest), QStringLiteral("Response"), this,
SLOT(onResponse(QDBusMessage)));
bus.connect(QString::fromLatin1(kService), QString::fromLatin1(kPath),
QString::fromLatin1(kInterface), QStringLiteral("Activated"),
this, SLOT(onActivated(QDBusMessage)));
// Create a portal session; shortcut binding follows in its response.
QVariantMap options;
options[QStringLiteral("session_handle_token")] = nextToken();
portalCall(QStringLiteral("CreateSession"), {}, options);
}
QString GlobalShortcuts::nextToken() {
return QStringLiteral("ghostty%1").arg(m_tokenCounter++);
}
QString GlobalShortcuts::requestPath(const QString &token) const {
// Per the portal Request docs: the path is derived from the caller's
// unique bus name with the leading ':' dropped and '.' -> '_'.
QString unique = QDBusConnection::sessionBus().baseService();
if (unique.startsWith(QLatin1Char(':'))) unique.remove(0, 1);
unique.replace(QLatin1Char('.'), QLatin1Char('_'));
return QStringLiteral("/org/freedesktop/portal/desktop/request/%1/%2")
.arg(unique, token);
}
void GlobalShortcuts::portalCall(const QString &method, QVariantList args,
QVariantMap options) {
const QString token = nextToken();
options[QStringLiteral("handle_token")] = token;
args.append(QVariant(options)); // the trailing a{sv} every method takes
const QString path = requestPath(token);
m_requests.insert(path, method);
QDBusMessage msg = QDBusMessage::createMethodCall(
QString::fromLatin1(kService), QString::fromLatin1(kPath),
QString::fromLatin1(kInterface), method);
msg.setArguments(args);
// The real result arrives via the Response signal; watch the call
// itself so a failed invocation is not silently swallowed AND the
// m_requests entry is dropped (otherwise an errored portal call
// would leak a Request entry forever).
auto *watcher = new QDBusPendingCallWatcher(
QDBusConnection::sessionBus().asyncCall(msg), this);
connect(watcher, &QDBusPendingCallWatcher::finished, this,
[this, method, path](QDBusPendingCallWatcher *w) {
QDBusPendingReply<QDBusObjectPath> reply = *w;
if (reply.isError()) {
std::fprintf(stderr, "[ghastty] portal %s failed: %s\n",
method.toUtf8().constData(),
reply.error().message().toUtf8().constData());
m_requests.remove(path);
}
w->deleteLater();
});
}
void GlobalShortcuts::onResponse(const QDBusMessage &message) {
const QString method = m_requests.take(message.path());
if (method.isEmpty()) return; // not one of ours
const QVariantList args = message.arguments();
if (args.isEmpty()) return;
const uint code = args.at(0).toUInt();
const QVariantMap results =
args.size() > 1 ? qdbus_cast<QVariantMap>(args.at(1)) : QVariantMap();
if (code != 0)
std::fprintf(stderr, "[ghastty] portal %s response code=%u\n",
method.toUtf8().constData(), code);
if (method == QLatin1String("CreateSession"))
handleCreateSession(code, results);
}
void GlobalShortcuts::handleCreateSession(uint code,
const QVariantMap &results) {
if (code != 0) return;
m_sessionHandle = results.value(QStringLiteral("session_handle")).toString();
if (m_sessionHandle.isEmpty()) return;
// Declare the shortcuts; the desktop owns the actual key assignment
// (KDE System Settings -> Shortcuts).
// preferred_trigger uses MOD+keysym form (LOGO == Super); the desktop
// may honor it as the default key or let the user rebind it.
QList<PortalShortcut> shortcuts;
shortcuts.append(
{QStringLiteral("toggle-quick-terminal"),
{{QStringLiteral("description"),
QStringLiteral("Toggle the Ghastty quick terminal")},
{QStringLiteral("preferred_trigger"), QStringLiteral("LOGO+grave")}}});
shortcuts.append(
{QStringLiteral("toggle-visibility"),
{{QStringLiteral("description"),
QStringLiteral("Toggle Ghastty window visibility")}}});
portalCall(QStringLiteral("BindShortcuts"),
{QVariant::fromValue(QDBusObjectPath(m_sessionHandle)),
QVariant::fromValue(shortcuts), QString()},
{});
}
void GlobalShortcuts::onActivated(const QDBusMessage &message) {
// Activated(o session_handle, s shortcut_id, t timestamp, a{sv} options)
const QVariantList args = message.arguments();
if (args.size() < 2) return;
emit activated(args.at(1).toString());
}

46
qt/src/GlobalShortcuts.h Normal file
View File

@ -0,0 +1,46 @@
#pragma once
#include <QHash>
#include <QObject>
#include <QString>
#include <QVariantMap>
class QDBusMessage;
// Registers global shortcuts through the org.freedesktop.portal
// GlobalShortcuts XDG portal, so actions like the quick terminal can be
// triggered while Ghostty is unfocused (a normal Wayland client cannot
// see keys when unfocused).
//
// The portal model: the app declares named shortcuts; the desktop
// (KDE System Settings -> Shortcuts) owns the actual key assignment.
// activated() fires with the shortcut id when one is triggered.
class GlobalShortcuts : public QObject {
Q_OBJECT
public:
explicit GlobalShortcuts(QObject *parent = nullptr);
signals:
void activated(const QString &id);
private slots:
// Every portal Request's Response lands here; m_requests maps the
// request path back to the method that started it.
void onResponse(const QDBusMessage &message);
void onActivated(const QDBusMessage &message);
private:
// Invoke a GlobalShortcuts method on org.freedesktop.portal.Desktop.
// `options` gets a fresh handle_token and is appended as the trailing
// argument every portal method expects.
void portalCall(const QString &method, QVariantList args,
QVariantMap options);
void handleCreateSession(uint code, const QVariantMap &results);
QString requestPath(const QString &token) const;
QString nextToken();
QString m_sessionHandle; // the portal session object path
QHash<QString, QString> m_requests; // request path -> method name
int m_tokenCounter = 0;
};

230
qt/src/InspectorWindow.cpp Normal file
View File

@ -0,0 +1,230 @@
#include "InspectorWindow.h"
#include <cstdio>
#include <QCloseEvent>
#include <QKeyEvent>
#include <QMouseEvent>
#include <QShowEvent>
#include <QOffscreenSurface>
#include <QOpenGLContext>
#include <QOpenGLFramebufferObject>
#include <QOpenGLFunctions>
#include <QPainter>
#include <QSurfaceFormat>
#include <QTimer>
#include <QWheelEvent>
#include "Util.h"
namespace {
// The editing/navigation keys an ImGui text field needs; other keys
// arrive as text via ghostty_inspector_text.
ghostty_input_key_e translateKey(int key) {
switch (key) {
case Qt::Key_Backspace: return GHOSTTY_KEY_BACKSPACE;
case Qt::Key_Delete: return GHOSTTY_KEY_DELETE;
case Qt::Key_Return:
case Qt::Key_Enter: return GHOSTTY_KEY_ENTER;
case Qt::Key_Tab: return GHOSTTY_KEY_TAB;
case Qt::Key_Escape: return GHOSTTY_KEY_ESCAPE;
case Qt::Key_Home: return GHOSTTY_KEY_HOME;
case Qt::Key_End: return GHOSTTY_KEY_END;
case Qt::Key_Left: return GHOSTTY_KEY_ARROW_LEFT;
case Qt::Key_Right: return GHOSTTY_KEY_ARROW_RIGHT;
case Qt::Key_Up: return GHOSTTY_KEY_ARROW_UP;
case Qt::Key_Down: return GHOSTTY_KEY_ARROW_DOWN;
default: return GHOSTTY_KEY_UNIDENTIFIED;
}
}
} // namespace
InspectorWindow::InspectorWindow(ghostty_surface_t surface)
: m_surface(surface) {
setWindowTitle(QStringLiteral("Ghastty Inspector"));
setFocusPolicy(Qt::StrongFocus);
setMouseTracking(true);
resize(800, 600);
m_inspector = ghostty_surface_inspector(m_surface);
// ~30fps: ImGui is immediate-mode, so it must re-render to reflect
// hover and animation, not just on explicit RENDER_INSPECTOR actions.
m_timer = new QTimer(this);
connect(m_timer, &QTimer::timeout, this, &InspectorWindow::renderFrame);
m_timer->start(33);
}
InspectorWindow::~InspectorWindow() {
// ghostty_inspector_free runs the ImGui OpenGL backend shutdown
// (ImGui_ImplOpenGL3_ShutdownWithLoaderCleanup), which needs the GL
// context current. Keep the context current across that call, then
// release it.
//
// If makeCurrent fails (display already gone on Wayland teardown,
// GPU reset, or the ctor failed before m_context was created) and
// the OpenGL backend had been initialized (m_glReady), the backend
// shutdown will run without a current context — accepting a
// GL-side resource leak. The process is exiting; the OS reclaims.
const bool current = m_inspector && makeCurrent();
if (current) {
ghostty_inspector_opengl_shutdown(m_inspector);
delete m_fbo;
} else if (m_glReady) {
std::fprintf(stderr,
"[ghastty] inspector dtor: GL context unavailable; "
"leaking inspector GL resources at shutdown\n");
}
if (m_surface) ghostty_inspector_free(m_surface);
if (current) m_context->doneCurrent();
delete m_offscreen;
}
bool InspectorWindow::makeCurrent() {
if (!m_context) {
m_context = new QOpenGLContext(this);
m_context->setFormat(QSurfaceFormat::defaultFormat());
if (!m_context->create()) return false;
m_offscreen = new QOffscreenSurface;
m_offscreen->setFormat(m_context->format());
m_offscreen->create();
}
return m_context->makeCurrent(m_offscreen);
}
void InspectorWindow::syncSize() {
if (!m_inspector) return;
const qreal dpr = devicePixelRatioF();
// updateContentScale rebuilds the ImGui style, so only push it when
// the scale actually changes; set_size already ignores no-op resizes.
if (dpr != m_lastDpr) {
ghostty_inspector_set_content_scale(m_inspector, dpr, dpr);
m_lastDpr = dpr;
}
ghostty_inspector_set_size(m_inspector,
static_cast<uint32_t>(width() * dpr),
static_cast<uint32_t>(height() * dpr));
}
void InspectorWindow::renderFrame() {
if (!isVisible() || !m_inspector || !makeCurrent()) return;
syncSize();
const qreal dpr = devicePixelRatioF();
const int w = qMax(1, static_cast<int>(width() * dpr));
const int h = qMax(1, static_cast<int>(height() * dpr));
if (!m_fbo || m_fbo->width() != w || m_fbo->height() != h) {
delete m_fbo;
m_fbo = new QOpenGLFramebufferObject(w, h);
}
m_fbo->bind();
QOpenGLFunctions *gl = m_context->functions();
gl->glViewport(0, 0, w, h);
gl->glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
gl->glClear(GL_COLOR_BUFFER_BIT);
if (!m_glReady) m_glReady = ghostty_inspector_opengl_init(m_inspector);
if (m_glReady) ghostty_inspector_opengl_render(m_inspector);
m_image = m_fbo->toImage();
m_image.setDevicePixelRatio(dpr);
m_fbo->release();
m_context->doneCurrent();
update();
}
void InspectorWindow::paintEvent(QPaintEvent *) {
if (m_image.isNull()) return;
QPainter painter(this);
painter.drawImage(QPointF(0, 0), m_image);
}
void InspectorWindow::resizeEvent(QResizeEvent *) { syncSize(); }
void InspectorWindow::closeEvent(QCloseEvent *e) {
// Hide rather than destroy: the owning GhosttySurface keeps a
// QPointer to this window across show/hide cycles. The window is
// deleted only when the surface is destroyed. Stop the redraw
// timer too — a hidden inspector has no work to do.
if (m_timer) m_timer->stop();
hide();
e->ignore();
}
void InspectorWindow::showEvent(QShowEvent *e) {
QWidget::showEvent(e);
if (m_timer && !m_timer->isActive()) m_timer->start(33);
}
void InspectorWindow::sendMouseButton(QMouseEvent *ev,
ghostty_input_mouse_state_e state) {
if (!m_inspector) return;
ghostty_input_mouse_button_e button;
switch (ev->button()) {
case Qt::LeftButton: button = GHOSTTY_MOUSE_LEFT; break;
case Qt::RightButton: button = GHOSTTY_MOUSE_RIGHT; break;
case Qt::MiddleButton: button = GHOSTTY_MOUSE_MIDDLE; break;
default: button = GHOSTTY_MOUSE_UNKNOWN; break;
}
ghostty_inspector_mouse_button(m_inspector, state, button,
translateMods(ev->modifiers()));
}
void InspectorWindow::mouseMoveEvent(QMouseEvent *ev) {
if (m_inspector)
ghostty_inspector_mouse_pos(m_inspector, ev->position().x(),
ev->position().y());
}
void InspectorWindow::mousePressEvent(QMouseEvent *ev) {
sendMouseButton(ev, GHOSTTY_MOUSE_PRESS);
}
void InspectorWindow::mouseReleaseEvent(QMouseEvent *ev) {
sendMouseButton(ev, GHOSTTY_MOUSE_RELEASE);
}
void InspectorWindow::wheelEvent(QWheelEvent *ev) {
if (!m_inspector) return;
const QPoint d = ev->angleDelta();
ghostty_inspector_mouse_scroll(m_inspector, d.x() / 120.0, d.y() / 120.0,
0);
}
void InspectorWindow::keyPressEvent(QKeyEvent *ev) {
if (!m_inspector) return;
const ghostty_input_key_e key = translateKey(ev->key());
if (key != GHOSTTY_KEY_UNIDENTIFIED)
ghostty_inspector_key(m_inspector, GHOSTTY_ACTION_PRESS, key,
translateMods(ev->modifiers()));
// Printable text drives ImGui's text input. Reject the whole string
// if any byte is a C0 control or DEL — checking only the first byte
// would let multi-char text starting with a printable then a newline
// sneak control codes through.
const QByteArray text = ev->text().toUtf8();
if (text.isEmpty()) return;
for (char c : text) {
const auto u = static_cast<unsigned char>(c);
if (u < 0x20 || u == 0x7f) return;
}
ghostty_inspector_text(m_inspector, text.constData());
}
void InspectorWindow::keyReleaseEvent(QKeyEvent *ev) {
if (!m_inspector) return;
const ghostty_input_key_e key = translateKey(ev->key());
if (key != GHOSTTY_KEY_UNIDENTIFIED)
ghostty_inspector_key(m_inspector, GHOSTTY_ACTION_RELEASE, key,
translateMods(ev->modifiers()));
}
void InspectorWindow::focusInEvent(QFocusEvent *) {
if (m_inspector) ghostty_inspector_set_focus(m_inspector, true);
}
void InspectorWindow::focusOutEvent(QFocusEvent *) {
if (m_inspector) ghostty_inspector_set_focus(m_inspector, false);
}

67
qt/src/InspectorWindow.h Normal file
View File

@ -0,0 +1,67 @@
#pragma once
#include <QImage>
#include <QWidget>
#include "ghostty.h"
class QOffscreenSurface;
class QOpenGLContext;
class QOpenGLFramebufferObject;
class QTimer;
// A window hosting libghostty's terminal inspector — a Dear ImGui debug
// UI. libghostty renders the inspector through ghostty_inspector_opengl_*
// into an offscreen framebuffer owned by a private QOpenGLContext; each
// frame is read back into a QImage and painted, mirroring how
// GhosttySurface composites the terminal.
//
// The inspector window is shown via a normal Qt::Widget close (WM
// close button), which is treated as "hide", not "destroy" — the
// owning surface keeps a QPointer to it and toggles visibility. The
// window only deletes when its owning GhosttySurface is destroyed.
class InspectorWindow : public QWidget {
Q_OBJECT
public:
// `surface` is the terminal surface being inspected.
explicit InspectorWindow(ghostty_surface_t surface);
~InspectorWindow() override;
protected:
// Treat the WM close button as a hide rather than a destroy. The
// GhosttySurface owns the inspector's lifetime; closing here would
// dangle its QPointer and skip libghostty inspector teardown.
void closeEvent(QCloseEvent *) override;
// Restart the redraw timer when the inspector becomes visible
// again; closeEvent stops it to avoid CPU waste while hidden.
void showEvent(QShowEvent *) override;
void paintEvent(QPaintEvent *) override;
void resizeEvent(QResizeEvent *) override;
void mouseMoveEvent(QMouseEvent *) override;
void mousePressEvent(QMouseEvent *) override;
void mouseReleaseEvent(QMouseEvent *) override;
void wheelEvent(QWheelEvent *) override;
void keyPressEvent(QKeyEvent *) override;
void keyReleaseEvent(QKeyEvent *) override;
void focusInEvent(QFocusEvent *) override;
void focusOutEvent(QFocusEvent *) override;
private:
bool makeCurrent();
void renderFrame(); // render the inspector, read it back
void syncSize(); // push the size/scale to libghostty
void sendMouseButton(QMouseEvent *, ghostty_input_mouse_state_e state);
ghostty_surface_t m_surface;
ghostty_inspector_t m_inspector = nullptr;
// Private offscreen GL context the inspector renders into.
QOpenGLContext *m_context = nullptr;
QOffscreenSurface *m_offscreen = nullptr;
QOpenGLFramebufferObject *m_fbo = nullptr;
QImage m_image; // last frame, read back from m_fbo
QTimer *m_timer = nullptr; // drives ~30fps redraws while visible
bool m_glReady = false; // ghostty_inspector_opengl_init done
double m_lastDpr = 0; // last pushed content scale
};

1697
qt/src/MainWindow.cpp Normal file

File diff suppressed because it is too large Load Diff

236
qt/src/MainWindow.h Normal file
View File

@ -0,0 +1,236 @@
#pragma once
#include <atomic>
#include <QList>
#include <QSize>
#include <QWidget>
#include "ghostty.h"
class QAudioOutput;
class QCloseEvent;
class QMediaPlayer;
class QShowEvent;
class QSplitter;
class TabWidget;
class QTimer;
class CommandPalette;
class GhosttySurface;
// A top-level window presenting terminal surfaces as tabs; each tab may
// be subdivided into splits. The libghostty app and config are shared
// process-wide across every window (the static s_* members below).
//
// Widget tree: QTabWidget -> tab page (QWidget) -> split tree, where a
// node is either a GhosttySurface (a QOpenGLWidget) or a QSplitter of
// two such nodes.
class MainWindow : public QWidget {
Q_OBJECT
public:
MainWindow();
~MainWindow() override;
// Per-window setup. The first call also creates the shared libghostty
// app and config; later windows reuse them. Call once before show().
bool initialize();
// Open a new top-level window, sharing the libghostty app, with one
// tab whose surface inherits from `parent` (may be null).
static MainWindow *newWindow(ghostty_surface_t parent);
// Show or hide every window at once (TOGGLE_VISIBILITY).
static void toggleVisibility();
// Show/hide the dropdown quick terminal, creating it on first use
// (TOGGLE_QUICK_TERMINAL). There is at most one per process.
static void toggleQuickTerminal();
// Open a new tab. `parent` (may be null) is the surface whose working
// directory etc. the new surface should inherit.
GhosttySurface *newTab(ghostty_surface_t parent);
// Split `target`'s pane in two, adding a new surface beside it.
GhosttySurface *splitSurface(GhosttySurface *target,
ghostty_action_split_direction_e dir);
// Remove a single surface: collapses its split, or closes the tab if
// it was the tab's only surface (and the window if it was the last).
void removeSurface(GhosttySurface *surface);
// Update the tab label and window title for `surface`.
void setSurfaceTitle(GhosttySurface *surface, const QString &title);
// The live libghostty config (for keybind lookups, etc.).
ghostty_config_t config() const { return s_config; }
// Whether a custom shader is configured. With one, libghostty's final
// framebuffer is non-premultiplied and surfaces must premultiply it
// before Qt composites (see GhosttySurface::premultiplyFramebuffer).
bool needsPremultiply() const { return s_needsPremultiply; }
// Whether `focus-follows-mouse` is enabled — a GhosttySurface grabs
// focus when the pointer enters it.
bool focusFollowsMouse() const;
protected:
bool event(QEvent *) override;
void showEvent(QShowEvent *) override;
// Honors `confirm-close-surface`: prompts if a surface has a running
// process, and ignores the event if the user declines.
void closeEvent(QCloseEvent *) override;
// Drives quick-terminal autohide on loss of activation.
void changeEvent(QEvent *) override;
private slots:
void onTabCloseRequested(int index);
void onCurrentChanged(int index);
private:
// Create the first tab once the device pixel ratio has settled.
void createFirstTab();
// 60fps frame timer body. Static because there is only one timer
// per process — N windows pointing at the same shared ghostty_app_t.
// Ticks libghostty once and renders any dirty surface across every
// window.
static void frame();
void closeTab(int index);
// Tear tab `index` out into a new window (tabTornOff signal).
void detachTab(int index);
// Move `page` (a tab and its surfaces) from `src` into this window.
void adoptTab(MainWindow *src, QWidget *page);
GhosttySurface *surfaceAt(int index) const;
int tabIndexForSurface(GhosttySurface *surface) const;
QList<GhosttySurface *> surfacesInTab(int index) const;
// Keybind-driven navigation between tabs and split panes.
void gotoTab(ghostty_action_goto_tab_e tab);
void gotoSplit(GhosttySurface *from, ghostty_action_goto_split_e dir);
void resizeSplit(GhosttySurface *from, ghostty_action_resize_split_s rs);
void equalizeSplits(GhosttySurface *from);
void moveTab(int amount); // reorder the current tab by `amount`
// Ring the terminal bell, honoring the `bell-features` config.
void ringBell(GhosttySurface *surface);
void playBellAudio();
// Bell `title` feature: prefix a tab's title while any surface in it
// has an unacknowledged bell.
bool tabBellMarked(int tab) const;
// Recompute a tab's displayed text from its stored base (terminal)
// title and manual override, plus any bell mark. Tab data holds a
// {base, override} QStringList.
void updateTabText(int tab);
// Set/clear a tab's manual title override (empty string clears it);
// while set, SET_TITLE no longer changes the tab text.
void setTabTitleOverride(GhosttySurface *surface, const QString &title);
// Copy the current tab's effective title to the clipboard.
void copyTitleToClipboard();
// Rebuild the config from disk and push it to libghostty.
void reloadConfig();
// Refresh every window's chrome from the current config (used after a
// reload and on the CONFIG_CHANGE notification).
static void refreshChrome();
// Typed wrappers over ghostty_config_get. configString also serves
// enum keys — libghostty returns an enum as its tag name string.
QString configString(const char *key) const;
bool configBool(const char *key, bool fallback) const;
// Apply config-driven window settings that may change on reload: the
// tab-bar visibility policy and the light/dark colour scheme.
void applyWindowConfig();
// Apply the `background-blur` config to this window via the KWin
// compositor (see WindowBlur).
void applyBlur();
// Turn this window into a layer-shell dropdown anchored to a screen
// edge, per the `quick-terminal-*` config. Quick-terminal only.
void setupLayerShell();
// Show/hide the command palette (TOGGLE_COMMAND_PALETTE), scoped to
// `surface` for executing the chosen command.
void toggleCommandPalette(GhosttySurface *surface);
// Prompt (per `confirm-close-surface`) before closing `surfaces`.
// Returns true if the close may proceed.
bool confirmCloseSurfaces(const QList<GhosttySurface *> &surfaces);
// Close every window, optionally quitting the process; prompts once
// via ghostty_app_needs_confirm_quit.
static void closeAllWindows();
// Wire the libghostty quit_timer action to a delayed QApplication
// quit, gated on `quit-after-last-window-closed`.
static void handleQuitTimer(bool start);
// Toggle a split pane filling its tab. Re-parents the surface out of
// / back into the splitter tree.
void toggleSplitZoom(GhosttySurface *surface);
// Runtime callbacks dispatched by libghostty. wakeup/action are
// app-level (routed via the target surface or s_windows); clipboard/
// close carry the surface userdata.
static void onWakeup(void *ud);
static bool onAction(ghostty_app_t, ghostty_target_s, ghostty_action_s);
static bool onReadClipboard(void *ud, ghostty_clipboard_e, void *state);
static void onConfirmReadClipboard(void *ud, const char *, void *state,
ghostty_clipboard_request_e);
static void onWriteClipboard(void *ud, ghostty_clipboard_e,
const ghostty_clipboard_content_s *, size_t,
bool);
static void onCloseSurface(void *ud, bool process_active);
// True if `s` is still owned by some live MainWindow. The surface
// userdata callbacks above use this to validate a libghostty-supplied
// pointer before dereferencing — a worker-thread callback can race
// the GhosttySurface destructor.
static bool surfaceAlive(GhosttySurface *s);
TabWidget *m_tabs = nullptr;
QList<GhosttySurface *> m_surfaces; // every live surface in this window
bool m_firstTabPending = true; // first tab is created on show()
ghostty_surface_t m_firstTabParent = nullptr; // inherited by the 1st tab
bool m_skipCloseConfirm = false; // close already confirmed elsewhere
bool m_quickTerminal = false; // this is the dropdown quick terminal
QSize m_defaultWindowSize; // for RESET_WINDOW_SIZE; from INITIAL_SIZE
// Process-shared libghostty state: one app and config drive every
// window. Created by the first initialize(), freed with the last
// window. s_windows tracks every live window.
static ghostty_app_t s_app;
static ghostty_config_t s_config;
static bool s_needsPremultiply; // a custom shader is configured
static QList<MainWindow *> s_windows;
static QTimer *s_quitTimer; // delayed quit-after-last-window
static int s_quitDelayMs; // 0 = no delay configured
static MainWindow *s_quickTerminal; // the one quick terminal, if any
// Process-wide 60Hz frame timer. Replaces a per-window timer, so N
// windows do not produce N ghostty_app_tick calls every 16ms for the
// same shared app.
static QTimer *s_frameTimer;
// Coalesces wakeup-driven ticks: a tick is queued at most once at a
// time, so a busy surface can't flood the event loop.
static std::atomic<bool> s_tickPending;
// Split-zoom state: the surface temporarily filling its tab, the
// splitter it came from, its index there, and the stashed tree root.
GhosttySurface *m_zoomed = nullptr;
QWidget *m_zoomRoot = nullptr;
QSplitter *m_zoomSplitter = nullptr;
int m_zoomIndex = 0;
// Bell audio playback; created lazily on the first audio bell.
QMediaPlayer *m_bellPlayer = nullptr;
QAudioOutput *m_bellAudio = nullptr;
// The command palette; created lazily on first use.
CommandPalette *m_commandPalette = nullptr;
};

173
qt/src/OverlayScrollbar.cpp Normal file
View File

@ -0,0 +1,173 @@
#include "OverlayScrollbar.h"
#include <algorithm>
#include <QEnterEvent>
#include <QMouseEvent>
#include <QPainter>
#include <QPropertyAnimation>
#include <QTimer>
namespace {
constexpr int kMargin = 3; // handle inset from the strip edges
constexpr int kMinHandle = 36; // minimum handle height
constexpr int kIdleWidth = 6; // handle width when idle
constexpr int kHoverWidth = 9; // handle width when hovered
constexpr int kHideDelayMs = 1400;
} // namespace
OverlayScrollbar::OverlayScrollbar(QWidget *parent) : QWidget(parent) {
setMouseTracking(true);
m_fade = new QPropertyAnimation(this, "opacity", this);
connect(m_fade, &QPropertyAnimation::finished, this, [this]() {
if (m_opacity <= 0.0) hide();
});
m_hideTimer = new QTimer(this);
m_hideTimer->setSingleShot(true);
m_hideTimer->setInterval(kHideDelayMs);
connect(m_hideTimer, &QTimer::timeout, this, [this]() {
if (m_dragging || m_hover) {
m_hideTimer->start(); // still in use — check again later
return;
}
fadeTo(0.0, 280);
});
hide();
}
void OverlayScrollbar::setOpacity(qreal o) {
o = std::clamp(o, 0.0, 1.0);
if (o == m_opacity) return;
m_opacity = o;
update();
}
void OverlayScrollbar::setHandleColor(const QColor &color) {
if (color == m_handleColor) return;
m_handleColor = color;
update();
}
void OverlayScrollbar::setMetrics(quint64 total, quint64 offset,
quint64 len) {
m_total = total;
m_offset = offset;
m_len = len;
// Repaint when visible OR while a fade-out is in flight; the handle
// position changes constantly with output, and skipping the update
// makes the fading scrollbar lag behind the actual scrollback.
if (isVisible() || m_opacity > 0.0) update();
}
void OverlayScrollbar::fadeTo(qreal target, int ms) {
m_fade->stop();
m_fade->setDuration(ms);
m_fade->setStartValue(m_opacity);
m_fade->setEndValue(target);
m_fade->start();
}
void OverlayScrollbar::reveal() {
if (m_total <= m_len) return; // nothing to scroll
show();
raise();
fadeTo(1.0, 110);
m_hideTimer->start();
}
QRect OverlayScrollbar::handleRect() const {
if (m_total <= m_len) return {};
const int trackH = height() - 2 * kMargin;
if (trackH <= 0) return {};
int handleH = static_cast<int>(static_cast<double>(trackH) * m_len /
static_cast<double>(m_total));
handleH = std::clamp(handleH, std::min(kMinHandle, trackH), trackH);
const quint64 scrollable = m_total - m_len;
const int travel = trackH - handleH;
const int handleY =
kMargin + (scrollable ? static_cast<int>(static_cast<double>(travel) *
m_offset / scrollable)
: 0);
const int w = m_hover || m_dragging ? kHoverWidth : kIdleWidth;
return QRect(width() - w - kMargin, handleY, w, handleH);
}
void OverlayScrollbar::paintEvent(QPaintEvent *) {
if (m_opacity <= 0.0) return;
const QRect handle = handleRect();
if (handle.isEmpty()) return;
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing, true);
QColor c = m_handleColor;
// Idle is fairly subtle; hover/drag brighten it. The fade scales it all.
const qreal base = m_dragging ? 0.80 : m_hover ? 0.62 : 0.42;
c.setAlphaF(base * m_opacity);
painter.setPen(Qt::NoPen);
painter.setBrush(c);
const qreal radius = handle.width() / 2.0;
painter.drawRoundedRect(handle, radius, radius);
}
void OverlayScrollbar::emitRowForHandleTop(int top) {
if (m_total <= m_len) return;
const int trackH = height() - 2 * kMargin;
const int travel = trackH - handleRect().height();
double frac = travel > 0
? static_cast<double>(top - kMargin) / travel
: 0.0;
frac = std::clamp(frac, 0.0, 1.0);
emit scrollToRow(static_cast<int>(frac * (m_total - m_len)));
}
void OverlayScrollbar::mousePressEvent(QMouseEvent *ev) {
if (ev->button() != Qt::LeftButton || m_total <= m_len) {
ev->ignore();
return;
}
const QPoint pos = ev->position().toPoint();
const QRect handle = handleRect();
if (handle.contains(pos)) {
m_dragging = true;
m_dragGrab = pos.y() - handle.top();
} else {
// Trough click: page toward the cursor.
const qint64 page = static_cast<qint64>(m_len);
qint64 row = static_cast<qint64>(m_offset) +
(pos.y() < handle.top() ? -page : page);
row = std::clamp<qint64>(row, 0,
static_cast<qint64>(m_total - m_len));
emit scrollToRow(static_cast<int>(row));
}
m_hideTimer->start();
update();
}
void OverlayScrollbar::mouseMoveEvent(QMouseEvent *ev) {
if (!m_dragging) return;
emitRowForHandleTop(ev->position().toPoint().y() - m_dragGrab);
m_hideTimer->start();
}
void OverlayScrollbar::mouseReleaseEvent(QMouseEvent *) {
m_dragging = false;
m_hideTimer->start();
update();
}
void OverlayScrollbar::enterEvent(QEnterEvent *) {
m_hover = true;
reveal(); // re-reveal in case it was mid fade-out
update();
}
void OverlayScrollbar::leaveEvent(QEvent *) {
m_hover = false;
m_hideTimer->start();
update();
}

63
qt/src/OverlayScrollbar.h Normal file
View File

@ -0,0 +1,63 @@
#pragma once
#include <QColor>
#include <QWidget>
class QPropertyAnimation;
class QTimer;
// A thin scrollback scrollbar that floats over the terminal.
//
// Qt's QScrollBar has no overlay/auto-hide mode (unlike the native GTK4
// and AppKit scrollbars the other Ghostty frontends get for free), so
// this is a small purpose-built widget: it custom-paints a rounded-pill
// handle, fades in on scroll activity and out when idle, and expands
// slightly on hover. It is driven by the libghostty SCROLLBAR action.
class OverlayScrollbar : public QWidget {
Q_OBJECT
Q_PROPERTY(qreal opacity READ opacity WRITE setOpacity)
public:
// Logical width of the strip the widget occupies at the window edge.
static constexpr int kWidth = 16;
explicit OverlayScrollbar(QWidget *parent);
// Scrollback metrics from the SCROLLBAR action (rows).
void setMetrics(quint64 total, quint64 offset, quint64 len);
// Fade the scrollbar in and (re)arm the idle-hide timer.
void reveal();
// Handle colour, typically derived from the terminal background.
void setHandleColor(const QColor &color);
qreal opacity() const { return m_opacity; }
void setOpacity(qreal o);
signals:
// The user dragged or clicked the scrollbar to this scrollback row.
void scrollToRow(int row);
protected:
void paintEvent(QPaintEvent *) override;
void mousePressEvent(QMouseEvent *) override;
void mouseMoveEvent(QMouseEvent *) override;
void mouseReleaseEvent(QMouseEvent *) override;
void enterEvent(QEnterEvent *) override;
void leaveEvent(QEvent *) override;
private:
QRect handleRect() const; // pixel rect of the handle
void emitRowForHandleTop(int top);
void fadeTo(qreal target, int ms);
quint64 m_total = 0; // total scrollback rows
quint64 m_offset = 0; // viewport-top row
quint64 m_len = 0; // visible rows
qreal m_opacity = 0.0;
QColor m_handleColor = QColor(235, 235, 235);
bool m_hover = false;
bool m_dragging = false;
int m_dragGrab = 0; // cursor offset within the handle
QPropertyAnimation *m_fade = nullptr;
QTimer *m_hideTimer = nullptr;
};

177
qt/src/SearchBar.cpp Normal file
View File

@ -0,0 +1,177 @@
#include "SearchBar.h"
#include <QByteArray>
#include <QEvent>
#include <QHBoxLayout>
#include <QIcon>
#include <QKeyEvent>
#include <QLabel>
#include <QLineEdit>
#include <QPalette>
#include <QTimer>
#include <QToolButton>
#include "GhosttySurface.h"
namespace {
// A themed tool button, icon from the icon theme with a text fallback.
QToolButton *makeButton(QWidget *parent, const QString &iconName,
const QString &fallback, const QString &tip) {
auto *b = new QToolButton(parent);
const QIcon icon = QIcon::fromTheme(iconName);
if (icon.isNull())
b->setText(fallback);
else
b->setIcon(icon);
b->setAutoRaise(true);
b->setToolTip(tip);
b->setFocusPolicy(Qt::NoFocus);
return b;
}
} // namespace
SearchBar::SearchBar(GhosttySurface *surface)
: QFrame(surface), m_surface(surface) {
// A themed panel: the frame, field and buttons follow the active Qt
// style and palette.
setFrameShape(QFrame::StyledPanel);
setAutoFillBackground(true);
m_field = new QLineEdit(this);
m_field->setPlaceholderText(QStringLiteral("Find"));
m_field->setMinimumWidth(200);
m_field->installEventFilter(this);
// The match counter lives inside the field, at its right edge, in the
// muted placeholder-text colour.
m_count = new QLabel(m_field);
m_count->setAttribute(Qt::WA_TransparentForMouseEvents);
QPalette pal = m_count->palette();
pal.setColor(QPalette::WindowText, pal.color(QPalette::PlaceholderText));
m_count->setPalette(pal);
QToolButton *prev = makeButton(this, QStringLiteral("go-up"),
QStringLiteral(""),
QStringLiteral("Previous match"));
QToolButton *next = makeButton(this, QStringLiteral("go-down"),
QStringLiteral(""),
QStringLiteral("Next match"));
QToolButton *close = makeButton(this, QStringLiteral("window-close"),
QStringLiteral(""),
QStringLiteral("Close search"));
auto *layout = new QHBoxLayout(this);
layout->setContentsMargins(6, 4, 6, 4);
layout->setSpacing(2);
layout->addWidget(m_field);
layout->addWidget(prev);
layout->addWidget(next);
layout->addWidget(close);
// Coalesce keystrokes so a fast typist does not thrash the search.
m_debounce = new QTimer(this);
m_debounce->setSingleShot(true);
m_debounce->setInterval(200);
connect(m_debounce, &QTimer::timeout, this, &SearchBar::sendQuery);
connect(m_field, &QLineEdit::textChanged, this,
[this]() { m_debounce->start(); });
connect(prev, &QToolButton::clicked, this, [this]() { navigate(false); });
connect(next, &QToolButton::clicked, this, [this]() { navigate(true); });
connect(close, &QToolButton::clicked, this, [this]() {
runAction("end_search");
hide();
// m_surface is the parent so it normally outlives the bar, but
// during a window teardown Qt may deliver this signal mid-cascade.
if (m_surface) m_surface->setFocus();
});
hide();
}
void SearchBar::open(const QString &prefill) {
m_total = -1;
m_selected = -1;
updateCount();
show();
raise();
if (!prefill.isEmpty())
m_field->setText(prefill); // textChanged → debounced query
m_field->setFocus();
m_field->selectAll();
}
void SearchBar::setTotal(int total) {
m_total = total;
updateCount();
}
void SearchBar::setSelected(int selected) {
m_selected = selected;
updateCount();
}
void SearchBar::updateCount() {
QString text;
if (m_total == 0)
text = QStringLiteral("No results");
else if (m_total > 0)
text = QStringLiteral("%1/%2")
.arg(m_selected > 0 ? m_selected : 0)
.arg(m_total);
m_count->setText(text);
m_count->adjustSize();
positionCount();
}
void SearchBar::positionCount() {
const int pad = 6;
m_count->move(m_field->width() - m_count->width() - pad,
(m_field->height() - m_count->height()) / 2);
// Reserve room so typed text never slides under the counter.
const int reserve =
m_count->text().isEmpty() ? 0 : m_count->width() + pad + 2;
m_field->setTextMargins(0, 0, reserve, 0);
}
void SearchBar::sendQuery() {
// An empty needle cancels the search, which libghostty handles.
const QByteArray q =
QByteArrayLiteral("search:") + m_field->text().toUtf8();
if (m_surface && m_surface->surface())
ghostty_surface_binding_action(m_surface->surface(), q.constData(),
q.size());
}
void SearchBar::navigate(bool next) {
runAction(next ? "navigate_search:next" : "navigate_search:previous");
}
void SearchBar::runAction(const char *action) {
if (m_surface && m_surface->surface())
ghostty_surface_binding_action(m_surface->surface(), action,
qstrlen(action));
}
bool SearchBar::eventFilter(QObject *obj, QEvent *event) {
if (obj == m_field) {
if (event->type() == QEvent::Resize) {
positionCount();
} else if (event->type() == QEvent::KeyPress) {
auto *ke = static_cast<QKeyEvent *>(event);
switch (ke->key()) {
case Qt::Key_Escape:
runAction("end_search");
hide();
if (m_surface) m_surface->setFocus();
return true;
case Qt::Key_Return:
case Qt::Key_Enter:
// Enter advances; Shift+Enter goes back.
navigate(!(ke->modifiers() & Qt::ShiftModifier));
return true;
default:
break;
}
}
}
return QFrame::eventFilter(obj, event);
}

49
qt/src/SearchBar.h Normal file
View File

@ -0,0 +1,49 @@
#pragma once
#include <QFrame>
class GhosttySurface;
class QLabel;
class QLineEdit;
class QTimer;
// An in-terminal search bar overlaying a GhosttySurface.
//
// The query is fed to libghostty through keybind actions (`search:`,
// `navigate_search:`, `end_search`) — the same path the macOS frontend
// uses — and the match counter mirrors the SEARCH_TOTAL and
// SEARCH_SELECTED actions libghostty reports back. Match highlighting
// in the terminal is drawn by the shared core renderer.
//
// It is a themed QFrame: the panel, field and buttons all use the
// active Qt style/palette rather than hardcoded colours.
class SearchBar : public QFrame {
Q_OBJECT
public:
explicit SearchBar(GhosttySurface *surface);
// Show the bar, focused, optionally pre-filled with a needle.
void open(const QString &prefill);
// Match counts reported by libghostty; -1 means none/unknown.
void setTotal(int total);
void setSelected(int selected);
protected:
bool eventFilter(QObject *obj, QEvent *event) override;
private:
void sendQuery(); // push the field text as `search:<text>`
void navigate(bool next); // `navigate_search:next` / `:previous`
void runAction(const char *action);
void updateCount();
void positionCount(); // place the counter inside the field
GhosttySurface *m_surface; // not owned
QLineEdit *m_field = nullptr;
QLabel *m_count = nullptr; // match counter, shown inside m_field
QTimer *m_debounce = nullptr; // coalesces keystrokes into one query
int m_total = -1;
int m_selected = -1;
};

199
qt/src/TabWidget.cpp Normal file
View File

@ -0,0 +1,199 @@
#include "TabWidget.h"
#include <cstring>
#include <QByteArray>
#include <QCoreApplication>
#include <QDataStream>
#include <QDrag>
#include <QDragEnterEvent>
#include <QDropEvent>
#include <QMimeData>
#include <QMouseEvent>
#include <QPixmap>
#include <QPointer>
#include <QRect>
#include <QSet>
namespace {
// MIME role carrying a tagged origin record so a receiving bar's
// dropEvent can mark the originator's m_dropHandled. Can't rely on
// QDrag::exec()'s return value on Wayland, and a process-wide "drop
// handled" flag races simultaneous tear-offs in different windows.
constexpr char kTearOffOriginRole[] = "application/x-ghastty-tab-origin";
// Process-local set of live TabBars so decodeOrigin can validate the
// pointer before dereferencing it (cross-process Wayland delivery, or
// the originating bar dying during drag->exec, would otherwise UAF).
QSet<TabBar *> &liveTabBars() {
static QSet<TabBar *> s;
return s;
}
// Origin payload: the source PID followed by the originating TabBar*.
// The PID rejects cross-process delivery; the live-set check rejects
// same-process pointers whose target was destroyed mid-drag.
struct OriginPayload {
qint64 pid;
TabBar *bar;
};
QByteArray encodeOrigin(TabBar *bar) {
OriginPayload p{QCoreApplication::applicationPid(), bar};
QByteArray bytes(reinterpret_cast<const char *>(&p), sizeof(p));
return bytes;
}
TabBar *decodeOrigin(const QByteArray &bytes) {
if (bytes.size() != sizeof(OriginPayload)) return nullptr;
OriginPayload p;
std::memcpy(&p, bytes.constData(), sizeof(p));
if (p.pid != QCoreApplication::applicationPid()) return nullptr;
if (!liveTabBars().contains(p.bar)) return nullptr;
return p.bar;
}
} // namespace
TabBar::TabBar(QWidget *parent) : QTabBar(parent) {
liveTabBars().insert(this);
// Truncate long titles with an ellipsis instead of letting a single
// tab consume the whole bar. Matches the upstream GTK frontend
// (Adw.TabBar, which clamps + ellipsizes) and macOS (Cocoa tabs use
// lineBreakMode = byTruncatingTail).
setElideMode(Qt::ElideRight);
// Tabs size to content (subject to the per-tab cap from tabSizeHint
// below), leaving room on the bar rather than expanding to fill.
setExpanding(false);
// When tabs still don't fit (many tabs, all near the cap), Qt
// shows left/right scroll arrows instead of shrinking each tab to
// an unreadable sliver.
setUsesScrollButtons(true);
}
TabBar::~TabBar() { liveTabBars().remove(this); }
QSize TabBar::tabSizeHint(int index) const {
// Cap at ~28em — wide enough for a typical "shell — repo (branch)"
// title, narrow enough that 5+ tabs fit on a 1280-px window without
// triggering scroll arrows. Below the cap, fall back to Qt's
// content-fit hint so short titles still get short tabs.
const QSize base = QTabBar::tabSizeHint(index);
const int cap = fontMetrics().averageCharWidth() * 28 +
// include the close-button width when the tab is
// closable, so the title clamp matches actual
// available text space.
(tabsClosable() ? 28 : 0);
if (base.width() <= cap) return base;
return QSize(cap, base.height());
}
void TabBar::mousePressEvent(QMouseEvent *e) {
if (e->button() == Qt::LeftButton) {
m_pressIndex = tabAt(e->position().toPoint());
m_pressPos = e->position().toPoint();
}
QTabBar::mousePressEvent(e);
}
void TabBar::mouseMoveEvent(QMouseEvent *e) {
// While the pointer is on the bar, QTabBar reorders normally. Once it
// leaves the bar in any direction, hand off to a tear-off drag.
const bool leftBar =
m_pressIndex >= 0 && count() > 1 && !m_tearing &&
!rect().adjusted(-20, -20, 20, 20).contains(e->position().toPoint());
if (leftBar) {
startTearOff(e);
return;
}
QTabBar::mouseMoveEvent(e);
}
void TabBar::mouseReleaseEvent(QMouseEvent *e) {
m_pressIndex = -1;
QTabBar::mouseReleaseEvent(e);
}
void TabBar::startTearOff(QMouseEvent *e) {
m_tearing = true;
// End QTabBar's internal move-drag so the tab settles back into the
// bar rather than staying "lifted".
QMouseEvent release(QEvent::MouseButtonRelease, e->position(),
e->globalPosition(), Qt::LeftButton, Qt::NoButton,
e->modifiers());
QTabBar::mouseReleaseEvent(&release);
// The dragged tab is the current one; its index is stable now that any
// in-bar reorder has settled.
const int index = currentIndex();
if (index < 0) {
m_tearing = false;
return;
}
// A snapshot of the tab follows the cursor for the duration of the drag.
const QRect tabBox = tabRect(index);
QDrag *drag = new QDrag(this);
auto *mime = new QMimeData;
mime->setData(QString::fromLatin1(kGhosttyTabMime), QByteArray());
// Tag the drag with a pointer to this bar so the receiving bar's
// dropEvent can mark *our* m_dropHandled — a process-global flag
// would race with simultaneous tear-offs in other windows.
mime->setData(QString::fromLatin1(kTearOffOriginRole), encodeOrigin(this));
drag->setMimeData(mime);
drag->setPixmap(grab(tabBox));
drag->setHotSpot(m_pressPos - tabBox.topLeft());
// A tear-off has no real drop target. A 1x1 transparent cursor
// suppresses the "forbidden" cursor Qt would otherwise show over
// non-accepting areas — releasing there is a valid outcome.
QPixmap blank(1, 1);
blank.fill(Qt::transparent);
drag->setDragCursor(blank, Qt::IgnoreAction);
drag->setDragCursor(blank, Qt::MoveAction);
// Released on a tab bar cancels the tear-off; released anywhere else
// (the terminal, another window, the desktop) tears it into a new
// window. m_dropHandled — set by a TabBar::dropEvent on the
// originating bar — is the signal, since QDrag::exec()'s result is
// unreliable across surfaces on Wayland.
//
// drag->exec spins a nested event loop. Anything queued onto this
// bar's window — a libghostty close action, the user closing the
// window mid-drag — can `delete this` while exec runs. Watch our own
// lifetime via QPointer and bail out before any post-exec member
// access if we've been deleted.
m_dropHandled = false;
QPointer<TabBar> self(this);
drag->exec(Qt::MoveAction);
if (!self) return;
m_tearing = false;
m_pressIndex = -1;
if (!m_dropHandled) emit tabTornOff(index);
}
void TabBar::dragEnterEvent(QDragEnterEvent *e) {
if (e->mimeData()->hasFormat(QString::fromLatin1(kGhosttyTabMime)))
e->acceptProposedAction();
}
void TabBar::dropEvent(QDropEvent *e) {
// Dropping a tear-off back on a tab bar cancels it. Mark the flag on
// the *originating* bar (carried in the MIME payload), not this one
// — a tear-off can be dropped onto a different window's bar.
if (e->mimeData()->hasFormat(QString::fromLatin1(kGhosttyTabMime))) {
if (TabBar *origin = decodeOrigin(
e->mimeData()->data(QString::fromLatin1(kTearOffOriginRole))))
origin->m_dropHandled = true;
else
m_dropHandled = true; // fallback: mark ourselves
e->acceptProposedAction();
}
}
TabWidget::TabWidget(QWidget *parent) : QTabWidget(parent) {
auto *bar = new TabBar(this);
bar->setAcceptDrops(true); // so a tear-off can be dropped back on it
setTabBar(bar); // protected on QTabWidget; accessible to this subclass
connect(bar, &TabBar::tabTornOff, this, &TabWidget::tabTornOff);
}

77
qt/src/TabWidget.h Normal file
View File

@ -0,0 +1,77 @@
#pragma once
#include <QMetaType>
#include <QPoint>
#include <QString>
#include <QTabBar>
#include <QTabWidget>
class QDragEnterEvent;
class QDropEvent;
class QMouseEvent;
// MIME type marking a Ghastty tab tear-off drag. Recognised by tab bars
// (to cancel the tear-off) and by terminal surfaces (to accept the drag
// so no "forbidden" cursor is shown over a Ghastty window).
inline constexpr char kGhosttyTabMime[] = "application/x-ghastty-tab";
// Per-tab data stored in QTabBar::tabData. `base` is the terminal-set
// title (libghostty SET_TITLE); `override` is a manual user-set title
// (libghostty SET_TAB_TITLE). updateTabText shows override when set,
// otherwise base.
struct TabData {
QString base;
QString override_; // `override` is a reserved C++ identifier
};
Q_DECLARE_METATYPE(TabData)
// A QTabBar that tears a tab off into its own window when it is dragged
// clear of the bar. QTabBar's built-in movable behaviour still handles
// reordering within the bar; once the pointer leaves the bar a QDrag
// takes over so a snapshot of the tab follows the cursor.
class TabBar : public QTabBar {
Q_OBJECT
public:
explicit TabBar(QWidget *parent = nullptr);
~TabBar() override;
signals:
// The tab was dragged off and released clear of its window.
void tabTornOff(int index);
protected:
void mousePressEvent(QMouseEvent *) override;
void mouseMoveEvent(QMouseEvent *) override;
void mouseReleaseEvent(QMouseEvent *) override;
// Accept a tear-off drag dropped back on a tab bar (cancels it).
void dragEnterEvent(QDragEnterEvent *) override;
void dropEvent(QDropEvent *) override;
// Cap a tab's width so a single long terminal title can't take the
// entire bar. Matches the GTK frontend's Adw.TabBar (which clamps
// width and ellipsizes) and the macOS Cocoa tabs (which use
// lineBreakMode = byTruncatingTail). Without this, Qt's default is
// "size to fit full text," and a long working-directory title
// pushes every other tab off-screen.
QSize tabSizeHint(int index) const override;
private:
void startTearOff(QMouseEvent *e);
int m_pressIndex = -1; // tab under the press, or -1
QPoint m_pressPos; // press point, for the drag hot spot
bool m_tearing = false; // a tear-off QDrag is in progress
bool m_dropHandled = false; // a TabBar dropEvent caught our tear-off
};
// A QTabWidget wired to the tear-off-aware TabBar.
class TabWidget : public QTabWidget {
Q_OBJECT
public:
explicit TabWidget(QWidget *parent = nullptr);
signals:
void tabTornOff(int index);
};

52
qt/src/Util.cpp Normal file
View File

@ -0,0 +1,52 @@
#include "Util.h"
#include <QChar>
#include <QStringLiteral>
// We index libghostty's GHOSTTY_KEY_DIGIT_0..9 and GHOSTTY_KEY_A..Z
// enum ranges by arithmetic offset. If libghostty ever inserts an
// entry into either range the math goes wrong silently — pin the
// contiguity at compile time.
static_assert(GHOSTTY_KEY_DIGIT_9 - GHOSTTY_KEY_DIGIT_0 == 9,
"ghostty_input_key_e DIGIT_0..9 must be contiguous");
static_assert(GHOSTTY_KEY_Z - GHOSTTY_KEY_A == 25,
"ghostty_input_key_e A..Z must be contiguous");
QString triggerKeyName(const ghostty_input_trigger_s &t) {
switch (t.tag) {
case GHOSTTY_TRIGGER_UNICODE:
if (t.key.unicode) return QString(QChar(t.key.unicode)).toUpper();
return {};
case GHOSTTY_TRIGGER_PHYSICAL: {
const ghostty_input_key_e k = t.key.physical;
if (k >= GHOSTTY_KEY_DIGIT_0 && k <= GHOSTTY_KEY_DIGIT_9)
return QChar('0' + (k - GHOSTTY_KEY_DIGIT_0));
if (k >= GHOSTTY_KEY_A && k <= GHOSTTY_KEY_Z)
return QChar('A' + (k - GHOSTTY_KEY_A));
if (k == GHOSTTY_KEY_ENTER) return QStringLiteral("Return");
if (k == GHOSTTY_KEY_SPACE) return QStringLiteral("Space");
if (k == GHOSTTY_KEY_TAB) return QStringLiteral("Tab");
return {};
}
default:
return {};
}
}
QString formatTrigger(const ghostty_input_trigger_s &t) {
QString s;
if (t.mods & GHOSTTY_MODS_CTRL) s += QStringLiteral("Ctrl+");
if (t.mods & GHOSTTY_MODS_ALT) s += QStringLiteral("Alt+");
if (t.mods & GHOSTTY_MODS_SHIFT) s += QStringLiteral("Shift+");
if (t.mods & GHOSTTY_MODS_SUPER) s += QStringLiteral("Super+");
const QString name = triggerKeyName(t);
if (!name.isEmpty()) {
s += name;
} else if (t.tag == GHOSTTY_TRIGGER_PHYSICAL) {
s += QStringLiteral(""); // an unmapped physical key
} else {
s += QStringLiteral(""); // CATCH_ALL etc.
}
return s;
}

51
qt/src/Util.h Normal file
View File

@ -0,0 +1,51 @@
#pragma once
#include <QString>
#include <Qt>
#include "ghostty.h"
// Shared helpers used across the Qt frontend. Kept header-only where
// possible so trivial wrappers stay inlined.
// bell-features is a packed struct returned by ghostty_config_get as a
// bitfield. Layout is fixed by the libghostty C ABI; do not reorder.
enum BellFeature : unsigned int {
BellSystem = 1u << 0,
BellAudio = 1u << 1,
BellAttention = 1u << 2,
BellTitle = 1u << 3,
BellBorder = 1u << 4,
};
// Translate Qt keyboard modifiers into libghostty's modifier bitfield.
inline ghostty_input_mods_e translateMods(Qt::KeyboardModifiers m) {
int r = GHOSTTY_MODS_NONE;
if (m & Qt::ShiftModifier) r |= GHOSTTY_MODS_SHIFT;
if (m & Qt::ControlModifier) r |= GHOSTTY_MODS_CTRL;
if (m & Qt::AltModifier) r |= GHOSTTY_MODS_ALT;
if (m & Qt::MetaModifier) r |= GHOSTTY_MODS_SUPER;
return static_cast<ghostty_input_mods_e>(r);
}
// Render the printable letter/digit/named key portion of a libghostty
// trigger. Returns an empty string if the key is not displayable
// (CATCH_ALL, an unmapped physical key, etc.).
QString triggerKeyName(const ghostty_input_trigger_s &t);
// Format a libghostty trigger as a human-readable chord (e.g. "Ctrl+B").
// Used for context-menu shortcut hints and the key-sequence overlay.
// Unmapped physical keys render as "•"; trigger.tag CATCH_ALL renders
// as "…".
QString formatTrigger(const ghostty_input_trigger_s &t);
// Wrapper around ghostty_config_get that infers the value's length
// from a string literal, so call sites stop repeating qstrlen().
//
// The template only binds to char-array references (string literals);
// passing a `const char*` is intentionally a compile error — runtime-
// length keys must call ghostty_config_get directly with qstrlen.
template <typename T, size_t N>
inline bool configGet(ghostty_config_t cfg, T *out, const char (&key)[N]) {
return cfg && ghostty_config_get(cfg, out, key, N - 1);
}

153
qt/src/WindowBlur.cpp Normal file
View File

@ -0,0 +1,153 @@
#include "WindowBlur.h"
#include <cstdlib>
#include <cstring>
#include <QGuiApplication>
#include <QHash>
#include <QString>
#include <QWidget>
#include <QWindow>
#include <qpa/qplatformnativeinterface.h>
#include <wayland-client.h>
#include <xcb/xcb.h>
#include "blur-client-protocol.h"
namespace {
// --- Wayland (org_kde_kwin_blur) -------------------------------------
// Found while enumerating the registry; cached for the process.
struct BlurGlobals {
org_kde_kwin_blur_manager *manager = nullptr;
bool searched = false;
};
void registryGlobal(void *data, wl_registry *registry, uint32_t name,
const char *interface, uint32_t) {
auto *g = static_cast<BlurGlobals *>(data);
if (std::strcmp(interface, org_kde_kwin_blur_manager_interface.name) == 0)
g->manager = static_cast<org_kde_kwin_blur_manager *>(wl_registry_bind(
registry, name, &org_kde_kwin_blur_manager_interface, 1));
}
void registryGlobalRemove(void *, wl_registry *, uint32_t) {}
const wl_registry_listener kRegistryListener = {registryGlobal,
registryGlobalRemove};
// Bind the KWin blur manager, enumerating the registry on a private
// event queue so this never dispatches Qt's own Wayland events.
org_kde_kwin_blur_manager *blurManager(wl_display *display) {
static BlurGlobals globals;
if (globals.searched) return globals.manager;
globals.searched = true;
wl_event_queue *queue = wl_display_create_queue(display);
wl_registry *registry = wl_display_get_registry(display);
wl_proxy_set_queue(reinterpret_cast<wl_proxy *>(registry), queue);
wl_registry_add_listener(registry, &kRegistryListener, &globals);
wl_display_roundtrip_queue(display, queue);
wl_registry_destroy(registry);
// The manager outlives this private queue, so move it (and the blur
// objects later created from it) back to the display's default queue
// before the private queue is destroyed.
if (globals.manager)
wl_proxy_set_queue(reinterpret_cast<wl_proxy *>(globals.manager), nullptr);
wl_event_queue_destroy(queue);
return globals.manager;
}
// The live blur object per window — kept so it can be released when
// blur is turned off, re-applied on a config change, or the window
// itself is destroyed.
static QHash<QWindow *, org_kde_kwin_blur *> &waylandBlurs() {
static QHash<QWindow *, org_kde_kwin_blur *> blurs;
return blurs;
}
void applyWayland(QWindow *window, bool enabled) {
QPlatformNativeInterface *native = QGuiApplication::platformNativeInterface();
if (!native) return;
auto *display = static_cast<wl_display *>(
native->nativeResourceForIntegration("wl_display"));
auto *surface = static_cast<wl_surface *>(
native->nativeResourceForWindow("surface", window));
if (!display || !surface) return;
org_kde_kwin_blur_manager *manager = blurManager(display);
if (!manager) return; // compositor advertises no blur support
auto &blurs = waylandBlurs();
// `take` returns and removes the prior blur if any. Knowing whether
// we're seeing this window for the first time decides whether we
// need a fresh `destroyed` connection — re-applying blur on an
// already-tracked window must NOT add a second connection.
const bool firstTime = !blurs.contains(window);
if (org_kde_kwin_blur *old = blurs.take(window))
org_kde_kwin_blur_release(old);
if (enabled) {
org_kde_kwin_blur *blur = org_kde_kwin_blur_manager_create(manager,
surface);
org_kde_kwin_blur_set_region(blur, nullptr); // null = whole surface
org_kde_kwin_blur_commit(blur);
blurs.insert(window, blur);
// Release the blur object when the window goes away. Connect once
// per window — repeated applyBlur(window, true) calls would
// otherwise stack N stale lambdas on the destroyed signal.
if (firstTime) {
QObject::connect(window, &QWindow::destroyed, qApp, [window]() {
auto &b = waylandBlurs();
if (org_kde_kwin_blur *old = b.take(window))
org_kde_kwin_blur_release(old);
});
}
} else {
org_kde_kwin_blur_manager_unset(manager, surface);
}
wl_display_flush(display);
}
// --- X11 (_KDE_NET_WM_BLUR_BEHIND_REGION) ----------------------------
void applyX11(QWindow *window, bool enabled) {
QPlatformNativeInterface *native = QGuiApplication::platformNativeInterface();
if (!native) return;
auto *conn = static_cast<xcb_connection_t *>(
native->nativeResourceForIntegration("connection"));
if (!conn) return;
const auto xid = static_cast<xcb_window_t>(window->winId());
static const char kName[] = "_KDE_NET_WM_BLUR_BEHIND_REGION";
xcb_intern_atom_reply_t *reply = xcb_intern_atom_reply(
conn, xcb_intern_atom(conn, 0, std::strlen(kName), kName), nullptr);
if (!reply) return;
const xcb_atom_t atom = reply->atom;
std::free(reply);
if (enabled)
// An empty region property blurs the whole window.
xcb_change_property(conn, XCB_PROP_MODE_REPLACE, xid, atom,
XCB_ATOM_CARDINAL, 32, 0, nullptr);
else
xcb_delete_property(conn, xid, atom);
xcb_flush(conn);
}
} // namespace
void applyWindowBlur(QWidget *window, bool enabled) {
if (!window) return;
QWindow *handle = window->windowHandle();
if (!handle) return; // not a native window yet
const QString platform = QGuiApplication::platformName();
if (platform.startsWith(QLatin1String("wayland")))
applyWayland(handle, enabled);
else if (platform == QLatin1String("xcb"))
applyX11(handle, enabled);
}

13
qt/src/WindowBlur.h Normal file
View File

@ -0,0 +1,13 @@
#pragma once
class QWidget;
// Enable or disable KWin's "blur behind" effect for `window`, honoring
// the `background-blur` config. Works on KDE/KWin via the native
// compositor protocols — `org_kde_kwin_blur` on Wayland and the
// `_KDE_NET_WM_BLUR_BEHIND_REGION` property on X11 — and is a harmless
// no-op on compositors that do not advertise blur support.
//
// The whole window is blurred; only the terminal's translucent pixels
// actually show the effect, so no per-region calculation is needed.
void applyWindowBlur(QWidget *window, bool enabled);

76
qt/src/main.cpp Normal file
View File

@ -0,0 +1,76 @@
#include <cstdio>
#include <QApplication>
#include <QIcon>
#include <QSurfaceFormat>
#include "GlobalShortcuts.h"
#include "MainWindow.h"
#include "ghostty.h"
int main(int argc, char **argv) {
// Use the display's true fractional scale rather than rounding it up
// (Wayland otherwise reports e.g. 2.0 for a 1.2x display, which scales
// the terminal up).
QGuiApplication::setHighDpiScaleFactorRoundingPolicy(
Qt::HighDpiScaleFactorRoundingPolicy::PassThrough);
// Multiple GL surfaces compose reliably with a shared GL context.
QApplication::setAttribute(Qt::AA_ShareOpenGLContexts);
// Ghostty's OpenGL renderer requires at least OpenGL 4.3 core.
QSurfaceFormat fmt;
fmt.setRenderableType(QSurfaceFormat::OpenGL);
fmt.setProfile(QSurfaceFormat::CoreProfile);
fmt.setVersion(4, 3);
fmt.setAlphaBufferSize(8); // allow a translucent terminal background
QSurfaceFormat::setDefaultFormat(fmt);
QApplication app(argc, argv);
// Match the installed ghastty.desktop: this becomes the Wayland app-id
// (and X11 WM_CLASS), so the compositor associates the window with the
// desktop entry — taskbar icon, launcher identity.
QGuiApplication::setDesktopFileName(QStringLiteral("ghastty"));
// The window icon, embedded so it works even running from the build
// tree (when ghastty.desktop / the icon theme are not yet installed).
QGuiApplication::setWindowIcon(QIcon(QStringLiteral(":/ghastty.svg")));
// We keep the user's system widget style rather than forcing Fusion.
// Some styles dim and blur translucent windows, which masks the
// terminal's own background-opacity: Kvantum themes do this when
// `blurring`/`reduce_window_opacity` are set. The fix belongs in the
// style's config, not here — for Kvantum, add "ghostty" to the
// theme's `opaque` app list (the same opt-out video players use).
// ghostty_init must run *after* QApplication: QApplication strips its
// own options (e.g. -style) out of argv in place, and libghostty later
// re-scans that array for CLI config — scanning the pre-strip array
// would walk past its end into freed/null entries.
if (ghostty_init(static_cast<uintptr_t>(argc), argv) != GHOSTTY_SUCCESS) {
std::fprintf(stderr, "[ghastty] ghostty_init failed\n");
return 1;
}
// The first window; further windows are opened on demand by the
// new_window action. Each window owns itself (WA_DeleteOnClose).
if (!MainWindow::newWindow(nullptr)) {
std::fprintf(stderr, "[ghastty] window initialization failed\n");
return 1;
}
// Register global shortcuts via the XDG portal so the quick terminal
// can be toggled while Ghostty is unfocused. Keys are assigned by the
// desktop (KDE System Settings -> Shortcuts).
GlobalShortcuts globalShortcuts;
QObject::connect(&globalShortcuts, &GlobalShortcuts::activated,
[](const QString &id) {
if (id == QLatin1String("toggle-quick-terminal"))
MainWindow::toggleQuickTerminal();
else if (id == QLatin1String("toggle-visibility"))
MainWindow::toggleVisibility();
});
return app.exec();
}

View File

@ -19,6 +19,7 @@ const CoreApp = @import("../App.zig");
const CoreInspector = @import("../inspector/main.zig").Inspector;
const CoreSurface = @import("../Surface.zig");
const configpkg = @import("../config.zig");
const build_config = @import("../build_config.zig");
const Config = configpkg.Config;
const String = @import("../main_c.zig").String;
@ -27,6 +28,12 @@ const log = std.log.scoped(.embedded_window);
pub const resourcesDir = internal_os.resourcesDir;
pub const App = struct {
/// Whether drawing must happen on the app (GUI) thread rather than a
/// dedicated renderer thread. The OpenGL renderer renders into the
/// host's GL context, which the host owns on its GUI thread (e.g. a
/// QOpenGLWidget); Metal keeps its own renderer thread.
pub const must_draw_from_app_thread = build_config.renderer == .opengl;
/// Because we only expect the embedding API to be used in embedded
/// environments, the options are extern so that we can expose it
/// directly to a C callconv and not pay for any translation costs.
@ -345,6 +352,7 @@ pub const App = struct {
pub const Platform = union(PlatformTag) {
macos: MacOS,
ios: IOS,
opengl: OpenGL,
// If our build target for libghostty is not darwin then we do
// not include macos support at all.
@ -358,6 +366,35 @@ pub const Platform = union(PlatformTag) {
uiview: objc.Object,
} else void;
/// Configuration for a host that provides its own OpenGL context
/// (e.g. a Qt, X11, or Wayland application embedding libghostty).
///
/// libghostty draws on the app (GUI) thread for the OpenGL renderer
/// (the embedded apprt sets `must_draw_from_app_thread` for OpenGL),
/// so these callbacks all run on the same thread that calls
/// `ghostty_surface_new` and `ghostty_surface_draw`. The context
/// only needs to be usable from that thread; it does not need to
/// be thread-portable.
pub const OpenGL = struct {
/// Userdata passed as the first argument to every callback.
userdata: ?*anyopaque,
/// Resolve the address of an OpenGL function by name.
get_proc_address: *const fn (
?*anyopaque,
[*:0]const u8,
) callconv(.c) ?*anyopaque,
/// Make the host's OpenGL context current on the calling thread.
make_current: *const fn (?*anyopaque) callconv(.c) void,
/// Release the host's OpenGL context from the calling thread.
release_current: *const fn (?*anyopaque) callconv(.c) void,
/// Present the most recently rendered frame (e.g. swap buffers).
present: *const fn (?*anyopaque) callconv(.c) void,
};
// The C ABI compatible version of this union. The tag is expected
// to be stored elsewhere.
pub const C = extern union {
@ -368,6 +405,17 @@ pub const Platform = union(PlatformTag) {
ios: extern struct {
uiview: ?*anyopaque,
},
opengl: extern struct {
userdata: ?*anyopaque,
get_proc_address: ?*const fn (
?*anyopaque,
[*:0]const u8,
) callconv(.c) ?*anyopaque,
make_current: ?*const fn (?*anyopaque) callconv(.c) void,
release_current: ?*const fn (?*anyopaque) callconv(.c) void,
present: ?*const fn (?*anyopaque) callconv(.c) void,
},
};
/// Initialize a Platform a tag and configuration from the C ABI.
@ -387,6 +435,21 @@ pub const Platform = union(PlatformTag) {
break :ios error.UIViewMustBeSet);
break :ios .{ .ios = .{ .uiview = uiview } };
} else error.UnsupportedPlatform,
.opengl => opengl: {
const config = c_platform.opengl;
break :opengl .{ .opengl = .{
.userdata = config.userdata,
.get_proc_address = config.get_proc_address orelse
break :opengl error.GetProcAddressMustBeSet,
.make_current = config.make_current orelse
break :opengl error.MakeCurrentMustBeSet,
.release_current = config.release_current orelse
break :opengl error.ReleaseCurrentMustBeSet,
.present = config.present orelse
break :opengl error.PresentMustBeSet,
} };
},
};
}
};
@ -397,6 +460,7 @@ pub const PlatformTag = enum(c_int) {
macos = 1,
ios = 2,
opengl = 3,
};
pub const EnvVar = extern struct {
@ -1004,10 +1068,12 @@ pub const Inspector = struct {
const Backend = enum {
metal,
opengl,
pub fn deinit(self: Backend) void {
switch (self) {
.metal => if (builtin.target.os.tag.isDarwin()) cimgui.ImGui_ImplMetal_Shutdown(),
.opengl => if (!builtin.target.os.tag.isDarwin()) cimgui.ImGui_ImplOpenGL3_ShutdownWithLoaderCleanup(),
}
}
};
@ -1034,6 +1100,11 @@ pub const Inspector = struct {
pub fn deinit(self: *Inspector) void {
self.surface.core_surface.deactivateInspector();
cimgui.c.ImGui_SetCurrentContext(self.ig_ctx);
// backend.deinit calls the ImGui backend shutdown
// (ImGui_ImplOpenGL3_ShutdownWithLoaderCleanup for `.opengl`,
// ImGui_ImplMetal_Shutdown for `.metal`). The OpenGL backend
// requires the host's GL context to be current on this thread;
// hosts must arrange that before calling ghostty_inspector_free.
if (self.backend) |v| v.deinit();
cimgui.c.ImGui_DestroyContext(self.ig_ctx);
}
@ -1108,6 +1179,52 @@ pub const Inspector = struct {
);
}
/// Initialize the inspector for an OpenGL backend. The ImGui OpenGL3
/// backend self-loads GL symbols and renders into the bound
/// framebuffer, so no device handle is needed.
pub fn initOpenGL(self: *Inspector) bool {
if (comptime builtin.target.os.tag.isDarwin()) return false;
cimgui.c.ImGui_SetCurrentContext(self.ig_ctx);
if (self.backend) |v| {
v.deinit();
self.backend = null;
}
if (!cimgui.ImGui_ImplOpenGL3_Init(null)) {
log.warn("failed to initialize OpenGL backend", .{});
return false;
}
self.backend = .opengl;
log.debug("initialized OpenGL backend", .{});
return true;
}
/// Render the inspector into the currently bound GL framebuffer.
pub fn renderOpenGL(self: *Inspector) !void {
if (comptime builtin.target.os.tag.isDarwin()) return;
assert(self.backend == .opengl);
// Render twice so ImGui completes its state processing (this
// mirrors renderMetal).
for (0..2) |_| {
cimgui.ImGui_ImplOpenGL3_NewFrame();
try self.newFrame();
cimgui.c.ImGui_NewFrame();
render: {
const surface = &self.surface.core_surface;
const inspector = surface.inspector orelse break :render;
inspector.render(surface);
}
cimgui.c.ImGui_Render();
}
cimgui.ImGui_ImplOpenGL3_RenderDrawData(cimgui.c.ImGui_GetDrawData());
}
pub fn updateContentScale(self: *Inspector, x: f64, y: f64) void {
_ = y;
cimgui.c.ImGui_SetCurrentContext(self.ig_ctx);
@ -1121,6 +1238,13 @@ pub const Inspector = struct {
var style: cimgui.c.ImGuiStyle = undefined;
cimgui.ext.ImGuiStyle_ImGuiStyle(&style);
cimgui.c.ImGuiStyle_ScaleAllSizes(&style, @floatCast(x));
// The embedded inspector font is baked at a 2x content scale
// (see Inspector.setup); FontScaleMain scales the glyphs to the
// real DPI so the text matches the scaled widget style instead
// of assuming 2x. (io.FontGlobalScale was removed in ImGui 1.92.)
style.FontScaleMain = @floatCast(x / 2.0);
const active_style = cimgui.c.ImGui_GetStyle();
active_style.* = style;
}
@ -1283,6 +1407,14 @@ pub const CAPI = struct {
cell_height_px: u32,
};
// Sync with ghostty_surface_cursor_position_s
const SurfaceCursorPosition = extern struct {
x: u32,
y: u32,
width: u32,
height: u32,
};
// ghostty_clipboard_content_s
const ClipboardContent = extern struct {
mime: [*:0]const u8,
@ -1711,6 +1843,26 @@ pub const CAPI = struct {
};
}
/// Return the terminal cursor's pixel rectangle in the surface's
/// device-pixel coordinate space. Used to place an IME candidate
/// window at the cursor.
export fn ghostty_surface_cursor_position(
surface: *Surface,
) SurfaceCursorPosition {
const core = &surface.core_surface;
core.renderer_state.mutex.lock();
defer core.renderer_state.mutex.unlock();
const cursor = core.renderer_state.terminal.screens.active.cursor;
const cell = core.size.cell;
const pad = core.size.padding;
return .{
.x = @as(u32, cursor.x) * cell.width + pad.left,
.y = @as(u32, cursor.y) * cell.height + pad.top,
.width = cell.width,
.height = cell.height,
};
}
/// Returns the PID of the foreground process for the surface PTY.
export fn ghostty_surface_foreground_pid(surface: *Surface) u64 {
return surface.core_surface.getProcessInfo(.foreground_pid) orelse 0;
@ -2099,6 +2251,23 @@ pub const CAPI = struct {
ptr.focusCallback(focused);
}
export fn ghostty_inspector_opengl_init(ptr: *Inspector) bool {
return ptr.initOpenGL();
}
export fn ghostty_inspector_opengl_render(ptr: *Inspector) void {
ptr.renderOpenGL() catch |err| {
log.err("error rendering inspector err={}", .{err});
};
}
export fn ghostty_inspector_opengl_shutdown(ptr: *Inspector) void {
if (ptr.backend) |v| {
v.deinit();
ptr.backend = null;
}
}
/// Sets the window background blur on macOS to the desired value.
/// I do this in Zig as an extern function because I don't know how to
/// call these functions in Swift.

View File

@ -589,15 +589,19 @@ pub fn add(
}
}
// If we're building an exe then we have additional dependencies.
if (step.kind != .lib) {
// We always statically compile glad
// glad (the OpenGL loader) is statically compiled whenever the
// OpenGL renderer is in use. This includes the libghostty artifact:
// the embedded apprt's OpenGL platform links against glad too.
if (self.config.renderer == .opengl) {
step.addIncludePath(b.path("vendor/glad/include/"));
step.addCSourceFile(.{
.file = b.path("vendor/glad/src/gl.c"),
.flags = &.{},
});
}
// If we're building an exe then we have additional dependencies.
if (step.kind != .lib) {
// When we're targeting flatpak we ALWAYS link GTK so we
// get access to glib for dbus.
if (self.config.flatpak) step.linkSystemLibrary2("gtk4", dynamic_link_opts);

View File

@ -224,8 +224,13 @@ fn kitty(
const entry = entry_ orelse {
// No entry found. If we have UTF-8 text this is a pure text event
// (e.g. composed/IME text), so send it as-is so programs can
// still receive it.
if (event.utf8.len > 0) return try writer.writeAll(event.utf8);
// still receive it. Releases never carry text when `report_events`
// is on this fallback would otherwise re-emit the character on
// key-up, doubling every keystroke that lacks an entry (e.g.
// punctuation when the apprt did not provide an unshifted
// codepoint).
if (event.utf8.len > 0 and event.action != .release)
return try writer.writeAll(event.utf8);
return;
};
@ -1862,6 +1867,47 @@ test "kitty: enter with utf8 (dead key state)" {
try testing.expectEqualStrings("A", writer.buffered());
}
// Regression: a release event with non-empty utf8 for a key that has no
// kitty entry (e.g. when the apprt does not populate unshifted_codepoint
// for punctuation) used to fall through and re-emit the character on
// key-up, doubling every keystroke under report_events.
test "kitty: no-entry release with utf8 emits nothing" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try kitty(&writer, .{
.action = .release,
.key = .unidentified,
.mods = .{},
.utf8 = ":",
// unshifted_codepoint = 0 forces the no-entry fallback.
}, .{
.kitty_flags = .{
.disambiguate = true,
.report_events = true,
},
});
try testing.expectEqualStrings("", writer.buffered());
}
// And a press in the same shape still emits the text (the fix is
// specifically scoped to releases).
test "kitty: no-entry press with utf8 still emits text" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try kitty(&writer, .{
.action = .press,
.key = .unidentified,
.mods = .{},
.utf8 = ":",
}, .{
.kitty_flags = .{
.disambiguate = true,
.report_events = true,
},
});
try testing.expectEqualStrings(":", writer.buffered());
}
test "kitty: keypad number" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);

View File

@ -99,6 +99,10 @@ pub fn init(alloc: Allocator, opts: rendererpkg.Options) !Metal {
.view = switch (opts.rt_surface.platform) {
.macos => |v| v.nsview,
.ios => |v| v.uiview,
// The OpenGL platform is only valid with the OpenGL
// renderer; it cannot provide a view for Metal.
.opengl => return error.UnsupportedPlatform,
},
},

View File

@ -45,6 +45,11 @@ blending: configpkg.Config.AlphaBlending,
/// The most recently presented target, in case we need to present it again.
last_target: ?Target = null,
/// The apprt surface. Used by the embedded runtime to query the drawable
/// size (GTK instead reads it from the GL viewport, which its GLArea
/// keeps in sync).
rt_surface: *apprt.Surface,
/// NOTE: This is an error{}!OpenGL instead of just OpenGL for parity with
/// Metal, since it needs to be fallible so does this, even though it
/// can't actually fail.
@ -52,6 +57,7 @@ pub fn init(alloc: Allocator, opts: rendererpkg.Options) error{}!OpenGL {
return .{
.alloc = alloc,
.blending = opts.config.blending,
.rt_surface = opts.rt_surface,
};
}
@ -158,10 +164,35 @@ fn prepareContext(getProcAddress: anytype) !void {
try gl.enable(gl.c.GL_FRAMEBUFFER_SRGB);
}
/// Host-provided OpenGL callbacks for the embedded apprt.
///
/// libghostty draws on the app thread for the OpenGL renderer
/// (`must_draw_from_app_thread`), so these callbacks are set in
/// `surfaceInit` and read by `present` on that same thread. The
/// renderer thread is a no-op for OpenGL (see threadEnter/threadExit).
/// `threadlocal` keeps the bookkeeping per-thread without an explicit
/// hand-off, and avoids threading the callbacks through `*const OpenGL`.
///
/// Never set for non-embedded runtimes.
threadlocal var gl_host: ?apprt.embedded.Platform.OpenGL = null;
/// Adapts the host's `get_proc_address` (which takes a userdata argument)
/// to glad's GLFW-style loader signature (which does not). Reads the host
/// callbacks from the thread-local `gl_host`.
fn gladHostLoader(
name: [*:0]const u8,
) callconv(.c) ?*const fn () callconv(.c) void {
const host = gl_host orelse return null;
// The host returns ?*anyopaque (alignment 1); a function pointer
// has alignment 4 on aarch64. eglGetProcAddress / glXGetProcAddress
// promise the returned pointer is suitable for the OpenGL ABI, so
// @alignCast is the assertion we want here.
const raw = host.get_proc_address(host.userdata, name) orelse return null;
return @ptrCast(@alignCast(raw));
}
/// This is called early right after surface creation.
pub fn surfaceInit(surface: *apprt.Surface) !void {
_ = surface;
switch (apprt.runtime) {
else => @compileError("unsupported app runtime for OpenGL"),
@ -169,10 +200,19 @@ pub fn surfaceInit(surface: *apprt.Surface) !void {
apprt.gtk,
=> try prepareContext(null),
apprt.embedded => {
// TODO(mitchellh): this does nothing today to allow libghostty
// to compile for OpenGL targets but libghostty is strictly
// broken for rendering on this platforms.
// The OpenGL embedded path draws on the app thread
// (must_draw_from_app_thread). Make the host context current on
// this the app thread and load GL; it stays current here for
// the surface's lifetime.
apprt.embedded => switch (surface.platform) {
.opengl => |host| {
host.make_current(host.userdata);
gl_host = host;
try prepareContext(&gladHostLoader);
},
// macOS and iOS use the Metal renderer.
.macos, .ios => return error.UnsupportedPlatform,
},
}
@ -191,6 +231,8 @@ pub fn surfaceInit(surface: *apprt.Surface) !void {
pub fn finalizeSurfaceInit(self: *const OpenGL, surface: *apprt.Surface) !void {
_ = self;
_ = surface;
// Nothing to do: GTK and the OpenGL embedded path both keep the GL
// context current on the app thread, where all drawing happens.
}
/// Callback called by renderer.Thread when it begins.
@ -201,18 +243,10 @@ pub fn threadEnter(self: *const OpenGL, surface: *apprt.Surface) !void {
switch (apprt.runtime) {
else => @compileError("unsupported app runtime for OpenGL"),
apprt.gtk => {
// GTK doesn't support threaded OpenGL operations as far as I can
// tell, so we use the renderer thread to setup all the state
// but then do the actual draws and texture syncs and all that
// on the main thread. As such, we don't do anything here.
},
apprt.embedded => {
// TODO(mitchellh): this does nothing today to allow libghostty
// to compile for OpenGL targets but libghostty is strictly
// broken for rendering on this platforms.
},
// GTK and the OpenGL embedded path both draw on the app thread
// (must_draw_from_app_thread), so the renderer thread must not
// touch the GL context.
apprt.gtk, apprt.embedded => {},
}
}
@ -223,14 +257,9 @@ pub fn threadExit(self: *const OpenGL) void {
switch (apprt.runtime) {
else => @compileError("unsupported app runtime for OpenGL"),
apprt.gtk => {
// We don't need to do any unloading for GTK because we may
// be sharing the global bindings with other windows.
},
apprt.embedded => {
// TODO: see threadEnter
},
// See threadEnter: the renderer thread does not own the GL
// context for either runtime.
apprt.gtk, apprt.embedded => {},
}
}
@ -277,13 +306,28 @@ pub fn initShaders(
/// Get the current size of the runtime surface.
pub fn surfaceSize(self: *const OpenGL) !struct { width: u32, height: u32 } {
_ = self;
var viewport: [4]gl.c.GLint = undefined;
gl.glad.context.GetIntegerv.?(gl.c.GL_VIEWPORT, &viewport);
return .{
.width = @intCast(viewport[2]),
.height = @intCast(viewport[3]),
};
switch (apprt.runtime) {
else => @compileError("unsupported app runtime for OpenGL"),
// GTK keeps the GL viewport in sync with the GLArea size, so it
// is a reliable source of the drawable size.
apprt.gtk => {
var viewport: [4]gl.c.GLint = undefined;
gl.glad.context.GetIntegerv.?(gl.c.GL_VIEWPORT, &viewport);
return .{
.width = @intCast(viewport[2]),
.height = @intCast(viewport[3]),
};
},
// The embedded host owns the drawable. Nothing keeps the GL
// viewport in sync with it, so use the pixel size the host
// reports through ghostty_surface_set_size.
apprt.embedded => {
const size = self.rt_surface.size;
return .{ .width = size.width, .height = size.height };
},
}
}
/// Initialize a new render target which can be presented by this API.
@ -328,6 +372,24 @@ pub fn present(self: *OpenGL, target: Target) !void {
// Keep track of this target in case we need to repeat it.
self.last_target = target;
// Embedded OpenGL hosts own buffer presentation: the blit above only
// writes the default framebuffer, so ask the host to swap buffers.
// (GTK presents implicitly via its GLArea, so this is embedded-only.)
if (apprt.runtime == apprt.embedded) {
if (gl_host) |host| {
host.present(host.userdata);
} else {
// We're being driven from a thread that never ran
// surfaceInit, so the threadlocal is empty. The host's
// present can't be called and the frame won't surface;
// log instead of silently dropping it.
log.warn(
"present called on a thread without an embedded GL host bound",
.{},
);
}
}
}
/// Present the last presented target again.

View File

@ -11,6 +11,8 @@ const Texture = @import("Texture.zig");
const Pipeline = @import("Pipeline.zig");
const Buffer = @import("buffer.zig").Buffer;
const log = std.log.scoped(.opengl);
/// Options for beginning a render pass.
pub const Options = struct {
/// Color attachments for this render pass.
@ -58,21 +60,35 @@ pub fn begin(
/// Add a step to this render pass.
///
/// TODO: Errors are silently ignored in this function, maybe they shouldn't be?
/// Errors during GL bind / state setup short-circuit the step (we have
/// no recovery story mid-pass) but are logged so they're not silent.
pub fn step(self: *Self, s: Step) void {
if (s.draw.instance_count == 0) return;
const pbind = s.pipeline.program.use() catch return;
const pbind = s.pipeline.program.use() catch |err| {
log.warn("render pass: program bind failed err={}", .{err});
return;
};
defer pbind.unbind();
const vaobind = s.pipeline.vao.bind() catch return;
const vaobind = s.pipeline.vao.bind() catch |err| {
log.warn("render pass: VAO bind failed err={}", .{err});
return;
};
defer vaobind.unbind();
const fbobind = switch (self.attachments[0].target) {
.target => |t| t.framebuffer.bind(.framebuffer) catch return,
.target => |t| t.framebuffer.bind(.framebuffer) catch |err| {
log.warn("render pass: framebuffer bind failed err={}", .{err});
return;
},
.texture => |t| bind: {
const fbobind = s.pipeline.fbo.bind(.framebuffer) catch return;
fbobind.texture2D(.color0, t.target, t.texture, 0) catch {
const fbobind = s.pipeline.fbo.bind(.framebuffer) catch |err| {
log.warn("render pass: pipeline fbo bind failed err={}", .{err});
return;
};
fbobind.texture2D(.color0, t.target, t.texture, 0) catch |err| {
log.warn("render pass: texture2D attach failed err={}", .{err});
fbobind.unbind();
return;
};
@ -83,6 +99,21 @@ pub fn step(self: *Self, s: Step) void {
defer self.step_number += 1;
// Set the viewport to match the attachment we're rendering into.
// Ghostty's GTK apprt keeps the GL viewport in sync via GLArea, but
// other apprts (e.g. embedded) do not, so we set it explicitly for
// every step rather than relying on external viewport state.
{
const vp_w, const vp_h = switch (self.attachments[0].target) {
.target => |t| .{ t.width, t.height },
.texture => |t| .{ t.width, t.height },
};
gl.viewport(0, 0, @intCast(vp_w), @intCast(vp_h)) catch |err| {
log.warn("render pass: viewport failed err={}", .{err});
return;
};
}
// If we have a clear color and this is the
// first step in the pass, go ahead and clear.
if (self.step_number == 0) if (self.attachments[0].clear_color) |c| {
@ -92,18 +123,30 @@ pub fn step(self: *Self, s: Step) void {
// Bind the uniform buffer we bind at index 1 to align with Metal.
if (s.uniforms) |ubo| {
_ = ubo.bindBase(.uniform, 1) catch return;
_ = ubo.bindBase(.uniform, 1) catch |err| {
log.warn("render pass: UBO bindBase failed err={}", .{err});
return;
};
}
// Bind relevant texture units.
for (s.textures, 0..) |t, i| if (t) |tex| {
gl.Texture.active(@intCast(i)) catch return;
_ = tex.texture.bind(tex.target) catch return;
gl.Texture.active(@intCast(i)) catch |err| {
log.warn("render pass: texture active({d}) failed err={}", .{ i, err });
return;
};
_ = tex.texture.bind(tex.target) catch |err| {
log.warn("render pass: texture bind({d}) failed err={}", .{ i, err });
return;
};
};
// Bind relevant samplers.
for (s.samplers, 0..) |s_, i| if (s_) |sampler| {
_ = sampler.sampler.bind(@intCast(i)) catch return;
_ = sampler.sampler.bind(@intCast(i)) catch |err| {
log.warn("render pass: sampler bind({d}) failed err={}", .{ i, err });
return;
};
};
// Bind 0th buffer as the vertex buffer,
@ -114,18 +157,33 @@ pub fn step(self: *Self, s: Step) void {
vbo.id,
0,
@intCast(s.pipeline.stride),
) catch return;
) catch |err| {
log.warn("render pass: VBO bindVertexBuffer failed err={}", .{err});
return;
};
for (s.buffers[1..], 1..) |b, i| if (b) |buf| {
_ = buf.bindBase(.storage, @intCast(i)) catch return;
_ = buf.bindBase(.storage, @intCast(i)) catch |err| {
log.warn("render pass: SSBO bindBase({d}) failed err={}", .{ i, err });
return;
};
};
}
if (s.pipeline.blending_enabled) {
gl.enable(gl.c.GL_BLEND) catch return;
gl.blendFunc(gl.c.GL_ONE, gl.c.GL_ONE_MINUS_SRC_ALPHA) catch return;
gl.enable(gl.c.GL_BLEND) catch |err| {
log.warn("render pass: enable BLEND failed err={}", .{err});
return;
};
gl.blendFunc(gl.c.GL_ONE, gl.c.GL_ONE_MINUS_SRC_ALPHA) catch |err| {
log.warn("render pass: blendFunc failed err={}", .{err});
return;
};
} else {
gl.disable(gl.c.GL_BLEND) catch return;
gl.disable(gl.c.GL_BLEND) catch |err| {
log.warn("render pass: disable BLEND failed err={}", .{err});
return;
};
}
gl.drawArraysInstanced(
@ -133,7 +191,10 @@ pub fn step(self: *Self, s: Step) void {
0,
@intCast(s.draw.vertex_count),
@intCast(s.draw.instance_count),
) catch return;
) catch |err| {
log.warn("render pass: drawArraysInstanced failed err={}", .{err});
return;
};
}
/// Complete this render pass.