From 12a67f1bd3160839829cb5de0c3f4458107f451a Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 10:20:23 -0500 Subject: [PATCH 01/75] apprt/embedded: add OpenGL platform for non-Apple hosts Adds a GHOSTTY_PLATFORM_OPENGL variant to the embedded apprt C API so a host that owns its own OpenGL context (e.g. an X11/Wayland/Qt app) can embed libghostty, and implements the OpenGL renderer's embedded path which was previously a no-op stub. The host provides four callbacks (get_proc_address, make_current, release_current, present). Ghostty's renderer thread makes the context current for the surface's lifetime, loads GL via glad through a loader trampoline, and asks the host to swap buffers after each frame. The host must never make the context current on any other thread. Co-Authored-By: claude-flow --- include/ghostty.h | 27 ++++++++++++++++++ src/apprt/embedded.zig | 54 +++++++++++++++++++++++++++++++++++ src/renderer/Metal.zig | 4 +++ src/renderer/OpenGL.zig | 63 ++++++++++++++++++++++++++++++++++------- 4 files changed, 137 insertions(+), 11 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index b099741fc..40f7b04eb 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -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,35 @@ 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. Ghostty's renderer +// runs on a dedicated thread and invokes these callbacks from that +// thread, so the context must be usable from a thread other than the +// one that created it. +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 { diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 730913eba..c948ce610 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -344,6 +344,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. @@ -357,6 +358,32 @@ 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). + /// + /// Ghostty's renderer thread drives the context via these callbacks, + /// so they must be safe to call from a thread other than the one + /// that created the context. + 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 { @@ -367,6 +394,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. @@ -386,6 +424,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, + } }; + }, }; } }; @@ -396,6 +449,7 @@ pub const PlatformTag = enum(c_int) { macos = 1, ios = 2, + opengl = 3, }; pub const EnvVar = extern struct { diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 6c7432d21..37524ebc6 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -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, }, }, diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 4b01da0c5..fc66d2ffd 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -158,6 +158,26 @@ fn prepareContext(getProcAddress: anytype) !void { try gl.enable(gl.c.GL_FRAMEBUFFER_SRGB); } +/// Host-provided OpenGL callbacks for the embedded apprt. +/// +/// The renderer thread owns the host's GL context exclusively: it is made +/// current in `threadEnter`, used by `present`, and released in +/// `threadExit` — all on the same thread. We therefore stash the callbacks +/// thread-locally rather than threading them 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; + return @ptrCast(host.get_proc_address(host.userdata, name)); +} + /// This is called early right after surface creation. pub fn surfaceInit(surface: *apprt.Surface) !void { _ = surface; @@ -169,11 +189,10 @@ 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 embedded apprt with an OpenGL host defers all GL setup to + // the renderer thread (see `threadEnter`), which owns the host's + // GL context. + apprt.embedded => {}, } // These are very noisy so this is commented, but easy to uncomment @@ -196,7 +215,6 @@ pub fn finalizeSurfaceInit(self: *const OpenGL, surface: *apprt.Surface) !void { /// Callback called by renderer.Thread when it begins. pub fn threadEnter(self: *const OpenGL, surface: *apprt.Surface) !void { _ = self; - _ = surface; switch (apprt.runtime) { else => @compileError("unsupported app runtime for OpenGL"), @@ -208,10 +226,20 @@ pub fn threadEnter(self: *const OpenGL, surface: *apprt.Surface) !void { // 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. + apprt.embedded => switch (surface.platform) { + .opengl => |host| { + // The host owns the GL context. Make it current on this + // (the renderer) thread — the host must never make it + // current on its own thread — then load the GL entry + // points via the host's loader. + host.make_current(host.userdata); + gl_host = host; + try prepareContext(&gladHostLoader); + }, + + // macOS and iOS use the Metal renderer; the OpenGL renderer + // must not be paired with those platforms. + .macos, .ios => return error.UnsupportedPlatform, }, } } @@ -229,7 +257,13 @@ pub fn threadExit(self: *const OpenGL) void { }, apprt.embedded => { - // TODO: see threadEnter + // The renderer thread is exiting, so unload glad's + // thread-local context and release the host's GL context. + if (gl_host) |host| { + gl.glad.unload(); + host.release_current(host.userdata); + gl_host = null; + } }, } } @@ -328,6 +362,13 @@ 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); + } } /// Present the last presented target again. From 67e4708dd3b27aa74c131d2d8bc406d213f90eb7 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 10:32:33 -0500 Subject: [PATCH 02/75] apprt/embedded: load GL on the calling thread, link glad into libghostty The OpenGL renderer creates GL objects during Renderer.init, which runs synchronously on the thread that calls ghostty_surface_new, before the renderer thread starts. Load GL in surfaceInit (calling thread) and release the context in finalizeSurfaceInit so the renderer thread can claim it in threadEnter. glad's context is thread-local, so each thread loads it independently; the single GL context moves between them. Also compile glad's gl.c into the libghostty artifact: it was only built for executables (step.kind != .lib), so the .so had undefined glad symbols. macOS libghostty uses Metal, so this was never exercised. Co-Authored-By: claude-flow --- src/build/SharedDeps.zig | 10 +++++++--- src/renderer/OpenGL.zig | 38 +++++++++++++++++++++++++++++++------- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index b68be92d0..4d050ba59 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -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); diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index fc66d2ffd..9801ffc1b 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -180,8 +180,6 @@ fn gladHostLoader( /// 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"), @@ -189,10 +187,21 @@ pub fn surfaceInit(surface: *apprt.Surface) !void { apprt.gtk, => try prepareContext(null), - // The embedded apprt with an OpenGL host defers all GL setup to - // the renderer thread (see `threadEnter`), which owns the host's - // GL context. - apprt.embedded => {}, + // The renderer's `init` runs next, on this (the calling) thread, + // and creates GL objects — so the host context must be current + // and GL loaded here. `finalizeSurfaceInit` releases the context + // again before the renderer thread starts. glad's context is + // thread-local, so `threadEnter` re-loads it on that thread. + 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, + }, } // These are very noisy so this is commented, but easy to uncomment @@ -209,7 +218,22 @@ pub fn surfaceInit(surface: *apprt.Surface) !void { /// thread for final main thread setup requirements. pub fn finalizeSurfaceInit(self: *const OpenGL, surface: *apprt.Surface) !void { _ = self; - _ = surface; + + switch (apprt.runtime) { + else => @compileError("unsupported app runtime for OpenGL"), + + // GTK keeps its GL context current on the app thread; there is + // nothing to finalize here. + apprt.gtk => {}, + + // The renderer's `init` has finished creating GL objects on this + // (the calling) thread. Release the host context so the renderer + // thread can make it current in `threadEnter`. + apprt.embedded => switch (surface.platform) { + .opengl => |host| host.release_current(host.userdata), + .macos, .ios => {}, + }, + } } /// Callback called by renderer.Thread when it begins. From 4ffa1c6a1e27946502dba6d45a2a737499162acd Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 10:32:41 -0500 Subject: [PATCH 03/75] embed-test: add a GLFW harness for the OpenGL embedding path A minimal GLFW host that drives libghostty through the GHOSTTY_PLATFORM_OPENGL embedded API. Verifies that the OpenGL embedded render path produces a rendered terminal; it is verification scaffolding, not a real terminal frontend. Co-Authored-By: claude-flow --- embed-test/.gitignore | 1 + embed-test/build.sh | 29 +++++ embed-test/main.c | 262 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 292 insertions(+) create mode 100644 embed-test/.gitignore create mode 100755 embed-test/build.sh create mode 100644 embed-test/main.c diff --git a/embed-test/.gitignore b/embed-test/.gitignore new file mode 100644 index 000000000..845270b45 --- /dev/null +++ b/embed-test/.gitignore @@ -0,0 +1 @@ +harness diff --git a/embed-test/build.sh b/embed-test/build.sh new file mode 100755 index 000000000..34059d63a --- /dev/null +++ b/embed-test/build.sh @@ -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 -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" diff --git a/embed-test/main.c b/embed-test/main.c new file mode 100644 index 000000000..bec4561f9 --- /dev/null +++ b/embed-test/main.c @@ -0,0 +1,262 @@ +// 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 +#include +#include +#include +#include + +#include + +#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; + +// Count of presented frames, bumped from the renderer thread. A nonzero +// value confirms the OpenGL embedded render path is producing frames. +static atomic_int g_frames = 0; + +// --- ghostty_platform_opengl_s callbacks ----------------------------- +// +// These run on libghostty's renderer thread, NOT the main thread. The +// renderer thread owns the GL context for the surface's lifetime. + +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; + (void)action; + // The harness ignores all actions (new window, title changes, ...). + 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); +} + +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; + } + + // The renderer thread owns the GL context. Release it from the main + // thread so libghostty's renderer thread can make it current. + glfwMakeContextCurrent(NULL); + + 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 and tick libghostty. The renderer runs + // on its own thread and presents through our callbacks. + 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); + + // 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; +} From edfca6130165ca645edd491e4a3ed7a698dfe8ae Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 10:39:31 -0500 Subject: [PATCH 04/75] qt: scaffold a Qt6 frontend embedding libghostty via OpenGL A QWindow-based Qt6 app that embeds libghostty through the GHOSTTY_PLATFORM_OPENGL C API. Uses raw EGL rather than QOpenGLContext: libghostty's renderer thread (not a QThread) must make the GL context current, and a QOpenGLContext is bound to its owning QThread, whereas eglMakeCurrent is thread-agnostic. Milestone M2 scaffold: renders a terminal, handles resize, focus and DPI, and accepts text input. Full key translation, mouse, clipboard and action handling are marked TODO for later milestones. Targets X11/xcb. Co-Authored-By: claude-flow --- qt/.gitignore | 1 + qt/CMakeLists.txt | 59 +++++++++ qt/src/GhosttyWindow.cpp | 265 +++++++++++++++++++++++++++++++++++++++ qt/src/GhosttyWindow.h | 76 +++++++++++ qt/src/main.cpp | 30 +++++ 5 files changed, 431 insertions(+) create mode 100644 qt/.gitignore create mode 100644 qt/CMakeLists.txt create mode 100644 qt/src/GhosttyWindow.cpp create mode 100644 qt/src/GhosttyWindow.h create mode 100644 qt/src/main.cpp diff --git a/qt/.gitignore b/qt/.gitignore new file mode 100644 index 000000000..567609b12 --- /dev/null +++ b/qt/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/qt/CMakeLists.txt b/qt/CMakeLists.txt new file mode 100644 index 000000000..eebf9d5ff --- /dev/null +++ b/qt/CMakeLists.txt @@ -0,0 +1,59 @@ +cmake_minimum_required(VERSION 3.16) +project(ghostty-qt LANGUAGES CXX) + +# A Qt6 frontend for Ghostty that embeds libghostty through the +# GHOSTTY_PLATFORM_OPENGL C API -- the same embedding model the macOS app +# uses with Metal. +# +# Build libghostty first, from the repo root: +# +# zig build -Dapp-runtime=none -Doptimize=Debug +# +# Then build and run this app: +# +# cmake -S qt -B qt/build && cmake --build qt/build +# QT_QPA_PLATFORM=xcb ./qt/build/ghostty-qt +# +# This scaffold targets X11/xcb: it uses the X11 window id for the EGL +# window surface. Wayland-native support is a follow-up. + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) + +find_package(Qt6 REQUIRED COMPONENTS Gui) +find_package(PkgConfig REQUIRED) +pkg_check_modules(EGL REQUIRED IMPORTED_TARGET egl) + +# 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 the expected name so the loader finds it. +file(CREATE_LINK "ghostty-internal.so" "${GHOSTTY_LIB_DIR}/libghostty.so" + SYMBOLIC) + +add_executable(ghostty-qt + src/main.cpp + src/GhosttyWindow.cpp +) + +target_include_directories(ghostty-qt PRIVATE "${GHOSTTY_ROOT}/include") + +target_link_libraries(ghostty-qt PRIVATE + Qt6::Gui + PkgConfig::EGL + "${GHOSTTY_LIB_DIR}/libghostty.so" +) + +target_link_options(ghostty-qt PRIVATE + "-Wl,-rpath,${GHOSTTY_LIB_DIR}" +) diff --git a/qt/src/GhosttyWindow.cpp b/qt/src/GhosttyWindow.cpp new file mode 100644 index 000000000..6e29503d0 --- /dev/null +++ b/qt/src/GhosttyWindow.cpp @@ -0,0 +1,265 @@ +#include "GhosttyWindow.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +// Count of presented frames, bumped from the renderer thread. A rising +// value confirms the OpenGL embedded render path is producing frames. +static std::atomic s_frameCount{0}; + +GhosttyWindow::GhosttyWindow() { + setSurfaceType(QWindow::OpenGLSurface); + setTitle(QStringLiteral("Ghostty (Qt)")); + + // Guide the platform's visual selection toward a GL-capable config so + // the EGL window surface can be created against this window. + QSurfaceFormat fmt; + fmt.setRenderableType(QSurfaceFormat::OpenGL); + fmt.setProfile(QSurfaceFormat::CoreProfile); + fmt.setVersion(4, 3); + fmt.setRedBufferSize(8); + fmt.setGreenBufferSize(8); + fmt.setBlueBufferSize(8); + fmt.setAlphaBufferSize(8); + setFormat(fmt); +} + +GhosttyWindow::~GhosttyWindow() { + // Freeing the surface stops libghostty's renderer thread, which calls + // threadExit -> glReleaseCurrent before this returns. + if (m_surface) ghostty_surface_free(m_surface); + if (m_app) ghostty_app_free(m_app); + if (m_config) ghostty_config_free(m_config); + + if (m_eglDisplay != EGL_NO_DISPLAY) { + if (m_eglSurface != EGL_NO_SURFACE) + eglDestroySurface(m_eglDisplay, m_eglSurface); + if (m_eglContext != EGL_NO_CONTEXT) + eglDestroyContext(m_eglDisplay, m_eglContext); + eglTerminate(m_eglDisplay); + } +} + +bool GhosttyWindow::initialize() { + // Force native window creation so winId() is valid for EGL. + create(); + + if (!setupEgl()) { + std::fprintf(stderr, "[ghostty-qt] EGL setup failed\n"); + return false; + } + + m_config = ghostty_config_new(); + ghostty_config_finalize(m_config); + + // App-level runtime config. + ghostty_runtime_config_s rt = {}; + rt.userdata = this; + rt.supports_selection_clipboard = true; + rt.wakeup_cb = onWakeup; + rt.action_cb = onAction; + rt.read_clipboard_cb = onReadClipboard; + rt.confirm_read_clipboard_cb = onConfirmReadClipboard; + rt.write_clipboard_cb = onWriteClipboard; + rt.close_surface_cb = onCloseSurface; + + m_app = ghostty_app_new(&rt, m_config); + if (!m_app) { + std::fprintf(stderr, "[ghostty-qt] ghostty_app_new failed\n"); + return false; + } + + // Surface config: hand libghostty our EGL context via callbacks. + ghostty_surface_config_s sc = ghostty_surface_config_new(); + sc.platform_tag = GHOSTTY_PLATFORM_OPENGL; + sc.platform.opengl.userdata = this; + sc.platform.opengl.get_proc_address = glGetProcAddress; + sc.platform.opengl.make_current = glMakeCurrent; + sc.platform.opengl.release_current = glReleaseCurrent; + sc.platform.opengl.present = glPresent; + sc.userdata = this; + sc.scale_factor = devicePixelRatio(); + + // ghostty_surface_new runs the renderer's init synchronously on this + // (the GUI) thread: it makes our EGL context current, builds GL + // objects, then releases it again before spawning the renderer thread. + m_surface = ghostty_surface_new(m_app, &sc); + if (!m_surface) { + std::fprintf(stderr, "[ghostty-qt] ghostty_surface_new failed\n"); + return false; + } + + updateSize(); + ghostty_surface_set_focus(m_surface, true); + + // Periodic tick as a backstop; onWakeup drives responsive ticking. + auto *timer = new QTimer(this); + connect(timer, &QTimer::timeout, this, &GhosttyWindow::tick); + timer->start(16); + + return true; +} + +bool GhosttyWindow::setupEgl() { + m_eglDisplay = eglGetDisplay(EGL_DEFAULT_DISPLAY); + if (m_eglDisplay == EGL_NO_DISPLAY) return false; + if (!eglInitialize(m_eglDisplay, nullptr, nullptr)) return false; + + // Ghostty's renderer uses desktop OpenGL, not GLES. + if (!eglBindAPI(EGL_OPENGL_API)) return false; + + const EGLint configAttribs[] = { + EGL_SURFACE_TYPE, EGL_WINDOW_BIT, + EGL_RENDERABLE_TYPE, EGL_OPENGL_BIT, + EGL_RED_SIZE, 8, + EGL_GREEN_SIZE, 8, + EGL_BLUE_SIZE, 8, + EGL_ALPHA_SIZE, 8, + EGL_NONE, + }; + EGLConfig config = nullptr; + EGLint numConfigs = 0; + if (!eglChooseConfig(m_eglDisplay, configAttribs, &config, 1, &numConfigs) || + numConfigs < 1) + return false; + + // Ghostty's OpenGL renderer requires at least OpenGL 4.3 core. + const EGLint contextAttribs[] = { + EGL_CONTEXT_MAJOR_VERSION, 4, + EGL_CONTEXT_MINOR_VERSION, 3, + EGL_CONTEXT_OPENGL_PROFILE_MASK, EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT, + EGL_NONE, + }; + m_eglContext = + eglCreateContext(m_eglDisplay, config, EGL_NO_CONTEXT, contextAttribs); + if (m_eglContext == EGL_NO_CONTEXT) return false; + + m_eglSurface = eglCreateWindowSurface( + m_eglDisplay, config, + static_cast(winId()), nullptr); + if (m_eglSurface == EGL_NO_SURFACE) return false; + + return true; +} + +void GhosttyWindow::updateSize() { + if (!m_surface) return; + const double dpr = devicePixelRatio(); + const int w = static_cast(width() * dpr); + const int h = static_cast(height() * dpr); + ghostty_surface_set_content_scale(m_surface, dpr, dpr); + if (w > 0 && h > 0) + ghostty_surface_set_size(m_surface, static_cast(w), + static_cast(h)); +} + +void GhosttyWindow::pushText(const QString &text) { + if (!m_surface || text.isEmpty()) return; + const QByteArray utf8 = text.toUtf8(); + ghostty_surface_text(m_surface, utf8.constData(), + static_cast(utf8.size())); +} + +void GhosttyWindow::tick() { + if (m_app) ghostty_app_tick(m_app); + if (m_surface && ghostty_surface_process_exited(m_surface)) { + close(); + return; + } + // Scaffold heartbeat: report presented frames roughly once a second. + if (++m_tickCount % 60 == 0) + std::fprintf(stderr, "[ghostty-qt] frames presented: %u\n", + s_frameCount.load()); +} + +// --- QWindow events -------------------------------------------------- + +void GhosttyWindow::exposeEvent(QExposeEvent *) { + if (m_surface && isExposed()) ghostty_surface_refresh(m_surface); +} + +void GhosttyWindow::resizeEvent(QResizeEvent *) { updateSize(); } + +void GhosttyWindow::keyPressEvent(QKeyEvent *ev) { + // TODO(B3): full key translation via ghostty_surface_key -- control + // sequences, arrows, function keys, modifiers. For the scaffold we + // forward committed text only, which covers typing and Enter. + pushText(ev->text()); +} + +void GhosttyWindow::focusInEvent(QFocusEvent *) { + if (m_surface) ghostty_surface_set_focus(m_surface, true); +} + +void GhosttyWindow::focusOutEvent(QFocusEvent *) { + if (m_surface) ghostty_surface_set_focus(m_surface, false); +} + +// --- GL context callbacks (run on libghostty's renderer thread) ------ + +void *GhosttyWindow::glGetProcAddress(void *, const char *name) { + return reinterpret_cast(eglGetProcAddress(name)); +} + +void GhosttyWindow::glMakeCurrent(void *ud) { + auto *self = static_cast(ud); + eglMakeCurrent(self->m_eglDisplay, self->m_eglSurface, self->m_eglSurface, + self->m_eglContext); +} + +void GhosttyWindow::glReleaseCurrent(void *ud) { + auto *self = static_cast(ud); + eglMakeCurrent(self->m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, + EGL_NO_CONTEXT); +} + +void GhosttyWindow::glPresent(void *ud) { + auto *self = static_cast(ud); + eglSwapBuffers(self->m_eglDisplay, self->m_eglSurface); + s_frameCount.fetch_add(1); +} + +// --- libghostty runtime callbacks ------------------------------------ + +void GhosttyWindow::onWakeup(void *ud) { + // Called from a libghostty thread; hop to the GUI thread to tick. + auto *self = static_cast(ud); + QMetaObject::invokeMethod(self, "tick", Qt::QueuedConnection); +} + +bool GhosttyWindow::onAction(ghostty_app_t, ghostty_target_s, + ghostty_action_s) { + // TODO(C): handle actions -- title changes, new tab/split/window, + // fullscreen, clipboard confirmations, etc. + return false; +} + +bool GhosttyWindow::onReadClipboard(void *, ghostty_clipboard_e, void *) { + // TODO(B4): wire QClipboard. + return false; +} + +void GhosttyWindow::onConfirmReadClipboard(void *, const char *, void *, + ghostty_clipboard_request_e) { + // TODO(B4): paste confirmation dialog. +} + +void GhosttyWindow::onWriteClipboard(void *, ghostty_clipboard_e, + const ghostty_clipboard_content_s *, + size_t, bool) { + // TODO(B4): wire QClipboard. +} + +void GhosttyWindow::onCloseSurface(void *ud, bool) { + auto *self = static_cast(ud); + QMetaObject::invokeMethod(self, "close", Qt::QueuedConnection); +} diff --git a/qt/src/GhosttyWindow.h b/qt/src/GhosttyWindow.h new file mode 100644 index 000000000..a10422aa8 --- /dev/null +++ b/qt/src/GhosttyWindow.h @@ -0,0 +1,76 @@ +#pragma once + +#include + +#include + +#include "ghostty.h" + +// A single Ghostty terminal surface hosted in a Qt QWindow. +// +// Rendering: libghostty owns a dedicated renderer thread and drives the +// GL context through the gl* callbacks below. We use raw EGL rather than +// QOpenGLContext because eglMakeCurrent is callable from any thread, +// whereas a QOpenGLContext is bound to the QThread it belongs to and +// libghostty's renderer thread is not a QThread. +// +// Scaffold scope (milestone M2): renders, resizes, tracks focus/DPI, and +// accepts text input. Full key translation, mouse, clipboard and action +// handling are marked TODO and belong to later milestones. +class GhosttyWindow : public QWindow { + Q_OBJECT + +public: + GhosttyWindow(); + ~GhosttyWindow() override; + + // Create the EGL context and the libghostty app + surface. Call once + // before show(). Returns false on failure. + bool initialize(); + +public slots: + // Pump libghostty's app-level work. Invoked from the wakeup callback + // (queued onto the GUI thread) and by a periodic timer. + void tick(); + +protected: + void exposeEvent(QExposeEvent *) override; + void resizeEvent(QResizeEvent *) override; + void keyPressEvent(QKeyEvent *) override; + void focusInEvent(QFocusEvent *) override; + void focusOutEvent(QFocusEvent *) override; + +private: + bool setupEgl(); + void updateSize(); + void pushText(const QString &text); + + // --- GL context callbacks (run on libghostty's renderer thread) --- + static void *glGetProcAddress(void *ud, const char *name); + static void glMakeCurrent(void *ud); + static void glReleaseCurrent(void *ud); + static void glPresent(void *ud); + + // --- libghostty runtime callbacks --- + 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); + + // EGL state. + EGLDisplay m_eglDisplay = EGL_NO_DISPLAY; + EGLContext m_eglContext = EGL_NO_CONTEXT; + EGLSurface m_eglSurface = EGL_NO_SURFACE; + + // libghostty handles. + ghostty_config_t m_config = nullptr; + ghostty_app_t m_app = nullptr; + ghostty_surface_t m_surface = nullptr; + + unsigned m_tickCount = 0; +}; diff --git a/qt/src/main.cpp b/qt/src/main.cpp new file mode 100644 index 000000000..e2fd69206 --- /dev/null +++ b/qt/src/main.cpp @@ -0,0 +1,30 @@ +#include + +#include + +#include "GhosttyWindow.h" +#include "ghostty.h" + +int main(int argc, char **argv) { + // This scaffold uses the X11 window id for the EGL window surface, so + // it requires the xcb platform plugin (XWayland is fine). + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) + qputenv("QT_QPA_PLATFORM", "xcb"); + + if (ghostty_init(static_cast(argc), argv) != GHOSTTY_SUCCESS) { + std::fprintf(stderr, "[ghostty-qt] ghostty_init failed\n"); + return 1; + } + + QGuiApplication app(argc, argv); + + GhosttyWindow window; + if (!window.initialize()) { + std::fprintf(stderr, "[ghostty-qt] window initialization failed\n"); + return 1; + } + window.resize(800, 600); + window.show(); + + return app.exec(); +} From 4506bfbb7b49e4b1dcac9d2d2ce92d6ed7a488e2 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 10:48:22 -0500 Subject: [PATCH 05/75] qt: translate keyboard and mouse input keyPressEvent/keyReleaseEvent build a ghostty_input_key_s and call ghostty_surface_key. The native keycode comes from nativeScanCode(), which on the xcb platform is the X11/XKB keycode that libghostty expects on Linux. Committed text is forwarded only for printable characters, so libghostty encodes control and special keys (Enter, Tab, arrows, Ctrl+letter, ...) itself from the keycode and modifiers. Mouse button, motion and wheel events are forwarded via the ghostty_surface_mouse_* API. Co-Authored-By: claude-flow --- qt/src/GhosttyWindow.cpp | 97 ++++++++++++++++++++++++++++++++++++---- qt/src/GhosttyWindow.h | 8 +++- 2 files changed, 95 insertions(+), 10 deletions(-) diff --git a/qt/src/GhosttyWindow.cpp b/qt/src/GhosttyWindow.cpp index 6e29503d0..0377c4ee7 100644 --- a/qt/src/GhosttyWindow.cpp +++ b/qt/src/GhosttyWindow.cpp @@ -7,10 +7,12 @@ #include #include #include +#include #include #include #include #include +#include // Count of presented frames, bumped from the renderer thread. A rising // value confirms the OpenGL embedded render path is producing frames. @@ -162,11 +164,62 @@ void GhosttyWindow::updateSize() { static_cast(h)); } -void GhosttyWindow::pushText(const QString &text) { - if (!m_surface || text.isEmpty()) return; - const QByteArray utf8 = text.toUtf8(); - ghostty_surface_text(m_surface, utf8.constData(), - static_cast(utf8.size())); +// Translate Qt keyboard modifiers into libghostty's modifier bitfield. +static 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(r); +} + +void GhosttyWindow::sendKey(QKeyEvent *ev, ghostty_input_action_e action) { + if (!m_surface) return; + + // Forward committed text only for printable input; control characters + // and special keys (Enter, Tab, arrows, Ctrl+letter, ...) are encoded + // by libghostty from the physical keycode + modifiers. + const QByteArray text = ev->text().toUtf8(); + const bool printable = + !text.isEmpty() && + static_cast(text.front()) >= 0x20 && + static_cast(text.front()) != 0x7f; + + // Unshifted codepoint, used for keybind matching (letters and digits). + uint32_t unshifted = 0; + const int key = ev->key(); + if (key >= Qt::Key_A && key <= Qt::Key_Z) + unshifted = static_cast('a' + (key - Qt::Key_A)); + else if (key >= Qt::Key_0 && key <= Qt::Key_9) + unshifted = static_cast('0' + (key - Qt::Key_0)); + + ghostty_input_key_s k = {}; + k.action = action; + k.mods = translateMods(ev->modifiers()); + k.consumed_mods = GHOSTTY_MODS_NONE; + // On the xcb platform nativeScanCode() is the X11/XKB keycode, which + // is exactly what libghostty expects as the native keycode on Linux. + k.keycode = ev->nativeScanCode(); + k.text = printable ? text.constData() : nullptr; + k.unshifted_codepoint = unshifted; + k.composing = false; + + ghostty_surface_key(m_surface, k); +} + +void GhosttyWindow::sendMouseButton(QMouseEvent *ev, + ghostty_input_mouse_state_e state) { + if (!m_surface) 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_surface_mouse_button(m_surface, state, button, + translateMods(ev->modifiers())); } void GhosttyWindow::tick() { @@ -190,10 +243,36 @@ void GhosttyWindow::exposeEvent(QExposeEvent *) { void GhosttyWindow::resizeEvent(QResizeEvent *) { updateSize(); } void GhosttyWindow::keyPressEvent(QKeyEvent *ev) { - // TODO(B3): full key translation via ghostty_surface_key -- control - // sequences, arrows, function keys, modifiers. For the scaffold we - // forward committed text only, which covers typing and Enter. - pushText(ev->text()); + sendKey(ev, ev->isAutoRepeat() ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS); +} + +void GhosttyWindow::keyReleaseEvent(QKeyEvent *ev) { + // Qt synthesizes a release before each auto-repeat press; drop those. + if (ev->isAutoRepeat()) return; + sendKey(ev, GHOSTTY_ACTION_RELEASE); +} + +void GhosttyWindow::mousePressEvent(QMouseEvent *ev) { + sendMouseButton(ev, GHOSTTY_MOUSE_PRESS); +} + +void GhosttyWindow::mouseReleaseEvent(QMouseEvent *ev) { + sendMouseButton(ev, GHOSTTY_MOUSE_RELEASE); +} + +void GhosttyWindow::mouseMoveEvent(QMouseEvent *ev) { + if (!m_surface) return; + const double dpr = devicePixelRatio(); + ghostty_surface_mouse_pos(m_surface, ev->position().x() * dpr, + ev->position().y() * dpr, + translateMods(ev->modifiers())); +} + +void GhosttyWindow::wheelEvent(QWheelEvent *ev) { + if (!m_surface) return; + // angleDelta is in eighths of a degree; 120 units == one wheel notch. + const QPoint d = ev->angleDelta(); + ghostty_surface_mouse_scroll(m_surface, d.x() / 120.0, d.y() / 120.0, 0); } void GhosttyWindow::focusInEvent(QFocusEvent *) { diff --git a/qt/src/GhosttyWindow.h b/qt/src/GhosttyWindow.h index a10422aa8..4a185aab9 100644 --- a/qt/src/GhosttyWindow.h +++ b/qt/src/GhosttyWindow.h @@ -37,13 +37,19 @@ protected: void exposeEvent(QExposeEvent *) override; void resizeEvent(QResizeEvent *) override; void keyPressEvent(QKeyEvent *) override; + void keyReleaseEvent(QKeyEvent *) override; + void mousePressEvent(QMouseEvent *) override; + void mouseReleaseEvent(QMouseEvent *) override; + void mouseMoveEvent(QMouseEvent *) override; + void wheelEvent(QWheelEvent *) override; void focusInEvent(QFocusEvent *) override; void focusOutEvent(QFocusEvent *) override; private: bool setupEgl(); void updateSize(); - void pushText(const QString &text); + void sendKey(QKeyEvent *, ghostty_input_action_e action); + void sendMouseButton(QMouseEvent *, ghostty_input_mouse_state_e state); // --- GL context callbacks (run on libghostty's renderer thread) --- static void *glGetProcAddress(void *ud, const char *name); From 1bff13bc4163c0d0f9359412b881636c789a849e Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 11:18:27 -0500 Subject: [PATCH 06/75] renderer/opengl: drive drawable size and viewport without GLArea The OpenGL renderer relied on GTK's GLArea for two things that the embedded apprt does not provide: - surfaceSize() read the drawable size from GL_VIEWPORT, which GLArea keeps in sync. The embedded path has nothing updating the viewport, so it was frozen at the initial surface size. For the embedded runtime, read the size the host reports via ghostty_surface_set_size (stored on the apprt surface) instead. - RenderPass never set the GL viewport at all; it inherited whatever GLArea last set. Set it explicitly in each render pass step to match the attachment being drawn into. This is a no-op for GTK (the target size already equals the GLArea viewport) and makes rendering correct for any apprt. Together these fix embedded OpenGL rendering, which previously drew the terminal into a small corner of the window. Co-Authored-By: claude-flow --- src/renderer/OpenGL.zig | 35 ++++++++++++++++++++++++------ src/renderer/opengl/RenderPass.zig | 12 ++++++++++ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 9801ffc1b..9c1450e87 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -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, }; } @@ -335,13 +341,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. diff --git a/src/renderer/opengl/RenderPass.zig b/src/renderer/opengl/RenderPass.zig index 180664942..c465638ef 100644 --- a/src/renderer/opengl/RenderPass.zig +++ b/src/renderer/opengl/RenderPass.zig @@ -83,6 +83,18 @@ 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 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| { From 2b06719e808cf4de8ea982c539eca45dbe374f5f Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 11:18:27 -0500 Subject: [PATCH 07/75] qt: opaque window and reliable surface sizing - Request a config with no alpha channel (both in the QSurfaceFormat and when choosing the EGL config) so the window is opaque instead of composited against the desktop. - Re-sync the surface size in exposeEvent, since devicePixelRatio is only reliable once the window is on a screen. - Drop the scaffold's frame-counter and size diagnostics. Co-Authored-By: claude-flow --- qt/src/GhosttyWindow.cpp | 43 ++++++++++++++++++++++------------------ qt/src/GhosttyWindow.h | 2 -- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/qt/src/GhosttyWindow.cpp b/qt/src/GhosttyWindow.cpp index 0377c4ee7..4e845f1e0 100644 --- a/qt/src/GhosttyWindow.cpp +++ b/qt/src/GhosttyWindow.cpp @@ -1,6 +1,5 @@ #include "GhosttyWindow.h" -#include #include #include @@ -14,10 +13,6 @@ #include #include -// Count of presented frames, bumped from the renderer thread. A rising -// value confirms the OpenGL embedded render path is producing frames. -static std::atomic s_frameCount{0}; - GhosttyWindow::GhosttyWindow() { setSurfaceType(QWindow::OpenGLSurface); setTitle(QStringLiteral("Ghostty (Qt)")); @@ -31,7 +26,9 @@ GhosttyWindow::GhosttyWindow() { fmt.setRedBufferSize(8); fmt.setGreenBufferSize(8); fmt.setBlueBufferSize(8); - fmt.setAlphaBufferSize(8); + // No alpha: the window should be opaque, not composited against the + // desktop. (Background transparency would be a deliberate later opt-in.) + fmt.setAlphaBufferSize(0); setFormat(fmt); } @@ -125,15 +122,27 @@ bool GhosttyWindow::setupEgl() { EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, - EGL_ALPHA_SIZE, 8, EGL_NONE, }; - EGLConfig config = nullptr; + EGLConfig configs[64]; EGLint numConfigs = 0; - if (!eglChooseConfig(m_eglDisplay, configAttribs, &config, 1, &numConfigs) || + if (!eglChooseConfig(m_eglDisplay, configAttribs, configs, 64, &numConfigs) || numConfigs < 1) return false; + // EGL color-size attributes are minimums, so eglChooseConfig may still + // return alpha-bearing configs. Pick one with no alpha channel so the + // window surface is opaque. + EGLConfig config = configs[0]; + for (EGLint i = 0; i < numConfigs; ++i) { + EGLint alpha = 0; + eglGetConfigAttrib(m_eglDisplay, configs[i], EGL_ALPHA_SIZE, &alpha); + if (alpha == 0) { + config = configs[i]; + break; + } + } + // Ghostty's OpenGL renderer requires at least OpenGL 4.3 core. const EGLint contextAttribs[] = { EGL_CONTEXT_MAJOR_VERSION, 4, @@ -224,20 +233,17 @@ void GhosttyWindow::sendMouseButton(QMouseEvent *ev, void GhosttyWindow::tick() { if (m_app) ghostty_app_tick(m_app); - if (m_surface && ghostty_surface_process_exited(m_surface)) { - close(); - return; - } - // Scaffold heartbeat: report presented frames roughly once a second. - if (++m_tickCount % 60 == 0) - std::fprintf(stderr, "[ghostty-qt] frames presented: %u\n", - s_frameCount.load()); + if (m_surface && ghostty_surface_process_exited(m_surface)) close(); } // --- QWindow events -------------------------------------------------- void GhosttyWindow::exposeEvent(QExposeEvent *) { - if (m_surface && isExposed()) ghostty_surface_refresh(m_surface); + if (!m_surface || !isExposed()) return; + // devicePixelRatio() is only reliable once the window is on a screen, + // so (re)sync the surface size here as well as in resizeEvent. + updateSize(); + ghostty_surface_refresh(m_surface); } void GhosttyWindow::resizeEvent(QResizeEvent *) { updateSize(); } @@ -304,7 +310,6 @@ void GhosttyWindow::glReleaseCurrent(void *ud) { void GhosttyWindow::glPresent(void *ud) { auto *self = static_cast(ud); eglSwapBuffers(self->m_eglDisplay, self->m_eglSurface); - s_frameCount.fetch_add(1); } // --- libghostty runtime callbacks ------------------------------------ diff --git a/qt/src/GhosttyWindow.h b/qt/src/GhosttyWindow.h index 4a185aab9..d47892c0f 100644 --- a/qt/src/GhosttyWindow.h +++ b/qt/src/GhosttyWindow.h @@ -77,6 +77,4 @@ private: ghostty_config_t m_config = nullptr; ghostty_app_t m_app = nullptr; ghostty_surface_t m_surface = nullptr; - - unsigned m_tickCount = 0; }; From 4a6b6bc0e058f411ccd91283bab3706384185a11 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 11:22:38 -0500 Subject: [PATCH 08/75] qt: wire clipboard and window-title actions Implement the previously stubbed runtime callbacks: - read/write clipboard via QClipboard, mapping the selection clipboard to QClipboard::Selection. - confirm-read auto-confirms pastes (no unsafe-paste dialog yet). - the action callback handles SET_TITLE and quit/close-all; other actions remain unhandled in this single-window app. Window-touching work is marshalled onto the GUI thread, since actions may be dispatched from libghostty's worker threads. Co-Authored-By: claude-flow --- qt/src/GhosttyWindow.cpp | 86 ++++++++++++++++++++++++++++++++-------- 1 file changed, 70 insertions(+), 16 deletions(-) diff --git a/qt/src/GhosttyWindow.cpp b/qt/src/GhosttyWindow.cpp index 4e845f1e0..9aad1890e 100644 --- a/qt/src/GhosttyWindow.cpp +++ b/qt/src/GhosttyWindow.cpp @@ -3,8 +3,10 @@ #include #include +#include #include #include +#include #include #include #include @@ -320,30 +322,82 @@ void GhosttyWindow::onWakeup(void *ud) { QMetaObject::invokeMethod(self, "tick", Qt::QueuedConnection); } -bool GhosttyWindow::onAction(ghostty_app_t, ghostty_target_s, - ghostty_action_s) { - // TODO(C): handle actions -- title changes, new tab/split/window, - // fullscreen, clipboard confirmations, etc. - return false; +bool GhosttyWindow::onAction(ghostty_app_t app, ghostty_target_s target, + ghostty_action_s action) { + (void)target; + // Actions can be dispatched from non-GUI threads, so anything touching + // the window is marshalled onto the GUI thread. + auto *self = static_cast(ghostty_app_userdata(app)); + if (!self) return false; + + switch (action.tag) { + case GHOSTTY_ACTION_SET_TITLE: { + const char *title = action.action.set_title.title; + if (!title) return true; + const QString t = QString::fromUtf8(title); + QMetaObject::invokeMethod( + self, [self, t]() { self->setTitle(t); }, Qt::QueuedConnection); + return true; + } + + case GHOSTTY_ACTION_QUIT: + case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: + QMetaObject::invokeMethod( + self, [self]() { self->close(); }, Qt::QueuedConnection); + return true; + + default: + // Tabs, splits, fullscreen, etc. are not handled by this + // single-window scaffold yet. + return false; + } } -bool GhosttyWindow::onReadClipboard(void *, ghostty_clipboard_e, void *) { - // TODO(B4): wire QClipboard. - return false; +bool GhosttyWindow::onReadClipboard(void *ud, ghostty_clipboard_e loc, + void *state) { + // Called synchronously when libghostty needs clipboard contents (paste). + auto *self = static_cast(ud); + if (!self->m_surface) return false; + + const QClipboard::Mode mode = loc == GHOSTTY_CLIPBOARD_SELECTION + ? QClipboard::Selection + : QClipboard::Clipboard; + const QByteArray text = QGuiApplication::clipboard()->text(mode).toUtf8(); + ghostty_surface_complete_clipboard_request(self->m_surface, + text.constData(), state, true); + return true; } -void GhosttyWindow::onConfirmReadClipboard(void *, const char *, void *, - ghostty_clipboard_request_e) { - // TODO(B4): paste confirmation dialog. +void GhosttyWindow::onConfirmReadClipboard(void *ud, const char *str, + void *state, + ghostty_clipboard_request_e req) { + (void)req; + // The scaffold trusts pastes rather than showing an unsafe-paste + // confirmation dialog. TODO: a real confirmation prompt. + auto *self = static_cast(ud); + if (self->m_surface) + ghostty_surface_complete_clipboard_request(self->m_surface, str, state, + true); } -void GhosttyWindow::onWriteClipboard(void *, ghostty_clipboard_e, - const ghostty_clipboard_content_s *, - size_t, bool) { - // TODO(B4): wire QClipboard. +void GhosttyWindow::onWriteClipboard(void *ud, ghostty_clipboard_e loc, + const ghostty_clipboard_content_s *content, + size_t n, bool confirm) { + (void)confirm; + if (n == 0 || !content[0].data) return; + + auto *self = static_cast(ud); + const QClipboard::Mode mode = loc == GHOSTTY_CLIPBOARD_SELECTION + ? QClipboard::Selection + : QClipboard::Clipboard; + const QString text = QString::fromUtf8(content[0].data); + QMetaObject::invokeMethod( + self, [text, mode]() { QGuiApplication::clipboard()->setText(text, mode); }, + Qt::QueuedConnection); } void GhosttyWindow::onCloseSurface(void *ud, bool) { auto *self = static_cast(ud); - QMetaObject::invokeMethod(self, "close", Qt::QueuedConnection); + QMetaObject::invokeMethod( + self, [self]() { self->close(); }, Qt::QueuedConnection); } From 78a88034dbc7c1ad3ae13b7922610d1a1e0b51a6 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 11:30:12 -0500 Subject: [PATCH 09/75] qt: load the user's Ghostty configuration Load default config files, CLI args, and recursively included files before finalizing, instead of using only built-in defaults. This is the same sequence the reference apprt uses, so the Qt frontend now respects ~/.config/ghostty/config. Co-Authored-By: claude-flow --- qt/src/GhosttyWindow.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/qt/src/GhosttyWindow.cpp b/qt/src/GhosttyWindow.cpp index 9aad1890e..b990841aa 100644 --- a/qt/src/GhosttyWindow.cpp +++ b/qt/src/GhosttyWindow.cpp @@ -59,7 +59,12 @@ bool GhosttyWindow::initialize() { return false; } + // Load configuration in the same order as the reference apprt: + // default files, CLI args, then any recursively included files. m_config = ghostty_config_new(); + ghostty_config_load_default_files(m_config); + ghostty_config_load_cli_args(m_config); + ghostty_config_load_recursive_files(m_config); ghostty_config_finalize(m_config); // App-level runtime config. From f5264ab6e3c54f5cc74672af3d9f6f50c679e4c8 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 11:37:46 -0500 Subject: [PATCH 10/75] qt: tabbed multi-surface architecture Split the single GhosttyWindow into two classes: - GhosttySurface: a QWindow rendering one terminal surface (its EGL context, ghostty_surface_t, and input handling). - MainWindow: a QWidget owning the shared ghostty_app_t and a QTabWidget; each terminal is a GhosttySurface embedded via QWidget::createWindowContainer. The action callback now handles new-tab/new-window (mapped to tabs), close-tab, and per-tab titles. Runtime callbacks route via the app userdata (wakeup/action) or surface userdata (clipboard/close) as libghostty provides them. New tabs inherit the working directory of the surface that requested them. Co-Authored-By: claude-flow --- qt/CMakeLists.txt | 6 +- .../{GhosttyWindow.cpp => GhosttySurface.cpp} | 210 +++----------- qt/src/GhosttySurface.h | 62 ++++ qt/src/GhosttyWindow.h | 80 ------ qt/src/MainWindow.cpp | 264 ++++++++++++++++++ qt/src/MainWindow.h | 62 ++++ qt/src/main.cpp | 8 +- 7 files changed, 438 insertions(+), 254 deletions(-) rename qt/src/{GhosttyWindow.cpp => GhosttySurface.cpp} (52%) create mode 100644 qt/src/GhosttySurface.h delete mode 100644 qt/src/GhosttyWindow.h create mode 100644 qt/src/MainWindow.cpp create mode 100644 qt/src/MainWindow.h diff --git a/qt/CMakeLists.txt b/qt/CMakeLists.txt index eebf9d5ff..61aa81d37 100644 --- a/qt/CMakeLists.txt +++ b/qt/CMakeLists.txt @@ -21,7 +21,7 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) -find_package(Qt6 REQUIRED COMPONENTS Gui) +find_package(Qt6 REQUIRED COMPONENTS Gui Widgets) find_package(PkgConfig REQUIRED) pkg_check_modules(EGL REQUIRED IMPORTED_TARGET egl) @@ -43,13 +43,15 @@ file(CREATE_LINK "ghostty-internal.so" "${GHOSTTY_LIB_DIR}/libghostty.so" add_executable(ghostty-qt src/main.cpp - src/GhosttyWindow.cpp + src/GhosttySurface.cpp + src/MainWindow.cpp ) target_include_directories(ghostty-qt PRIVATE "${GHOSTTY_ROOT}/include") target_link_libraries(ghostty-qt PRIVATE Qt6::Gui + Qt6::Widgets PkgConfig::EGL "${GHOSTTY_LIB_DIR}/libghostty.so" ) diff --git a/qt/src/GhosttyWindow.cpp b/qt/src/GhosttySurface.cpp similarity index 52% rename from qt/src/GhosttyWindow.cpp rename to qt/src/GhosttySurface.cpp index b990841aa..aec40405a 100644 --- a/qt/src/GhosttyWindow.cpp +++ b/qt/src/GhosttySurface.cpp @@ -1,26 +1,23 @@ -#include "GhosttyWindow.h" +#include "GhosttySurface.h" #include #include -#include #include #include -#include #include #include #include #include #include -#include #include -GhosttyWindow::GhosttyWindow() { +GhosttySurface::GhosttySurface(ghostty_app_t app, MainWindow *owner) + : m_app(app), m_owner(owner) { setSurfaceType(QWindow::OpenGLSurface); - setTitle(QStringLiteral("Ghostty (Qt)")); - // Guide the platform's visual selection toward a GL-capable config so - // the EGL window surface can be created against this window. + // Guide the platform's visual selection toward a GL-capable, opaque + // config so the EGL window surface can be created against this window. QSurfaceFormat fmt; fmt.setRenderableType(QSurfaceFormat::OpenGL); fmt.setProfile(QSurfaceFormat::CoreProfile); @@ -28,18 +25,14 @@ GhosttyWindow::GhosttyWindow() { fmt.setRedBufferSize(8); fmt.setGreenBufferSize(8); fmt.setBlueBufferSize(8); - // No alpha: the window should be opaque, not composited against the - // desktop. (Background transparency would be a deliberate later opt-in.) fmt.setAlphaBufferSize(0); setFormat(fmt); } -GhosttyWindow::~GhosttyWindow() { +GhosttySurface::~GhosttySurface() { // Freeing the surface stops libghostty's renderer thread, which calls // threadExit -> glReleaseCurrent before this returns. if (m_surface) ghostty_surface_free(m_surface); - if (m_app) ghostty_app_free(m_app); - if (m_config) ghostty_config_free(m_config); if (m_eglDisplay != EGL_NO_DISPLAY) { if (m_eglSurface != EGL_NO_SURFACE) @@ -50,7 +43,7 @@ GhosttyWindow::~GhosttyWindow() { } } -bool GhosttyWindow::initialize() { +bool GhosttySurface::initialize(ghostty_surface_t parent) { // Force native window creation so winId() is valid for EGL. create(); @@ -59,33 +52,12 @@ bool GhosttyWindow::initialize() { return false; } - // Load configuration in the same order as the reference apprt: - // default files, CLI args, then any recursively included files. - m_config = ghostty_config_new(); - ghostty_config_load_default_files(m_config); - ghostty_config_load_cli_args(m_config); - ghostty_config_load_recursive_files(m_config); - ghostty_config_finalize(m_config); - - // App-level runtime config. - ghostty_runtime_config_s rt = {}; - rt.userdata = this; - rt.supports_selection_clipboard = true; - rt.wakeup_cb = onWakeup; - rt.action_cb = onAction; - rt.read_clipboard_cb = onReadClipboard; - rt.confirm_read_clipboard_cb = onConfirmReadClipboard; - rt.write_clipboard_cb = onWriteClipboard; - rt.close_surface_cb = onCloseSurface; - - m_app = ghostty_app_new(&rt, m_config); - if (!m_app) { - std::fprintf(stderr, "[ghostty-qt] ghostty_app_new failed\n"); - return false; - } - - // Surface config: hand libghostty our EGL context via callbacks. - ghostty_surface_config_s sc = ghostty_surface_config_new(); + // A new surface in a tab inherits the parent surface's working + // directory etc.; the first surface uses a default config. + ghostty_surface_config_s sc = + parent ? ghostty_surface_inherited_config(parent, + GHOSTTY_SURFACE_CONTEXT_TAB) + : ghostty_surface_config_new(); sc.platform_tag = GHOSTTY_PLATFORM_OPENGL; sc.platform.opengl.userdata = this; sc.platform.opengl.get_proc_address = glGetProcAddress; @@ -106,16 +78,10 @@ bool GhosttyWindow::initialize() { updateSize(); ghostty_surface_set_focus(m_surface, true); - - // Periodic tick as a backstop; onWakeup drives responsive ticking. - auto *timer = new QTimer(this); - connect(timer, &QTimer::timeout, this, &GhosttyWindow::tick); - timer->start(16); - return true; } -bool GhosttyWindow::setupEgl() { +bool GhosttySurface::setupEgl() { m_eglDisplay = eglGetDisplay(EGL_DEFAULT_DISPLAY); if (m_eglDisplay == EGL_NO_DISPLAY) return false; if (!eglInitialize(m_eglDisplay, nullptr, nullptr)) return false; @@ -152,9 +118,9 @@ bool GhosttyWindow::setupEgl() { // Ghostty's OpenGL renderer requires at least OpenGL 4.3 core. const EGLint contextAttribs[] = { - EGL_CONTEXT_MAJOR_VERSION, 4, - EGL_CONTEXT_MINOR_VERSION, 3, - EGL_CONTEXT_OPENGL_PROFILE_MASK, EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT, + EGL_CONTEXT_MAJOR_VERSION, 4, + EGL_CONTEXT_MINOR_VERSION, 3, + EGL_CONTEXT_OPENGL_PROFILE_MASK, EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT, EGL_NONE, }; m_eglContext = @@ -162,14 +128,14 @@ bool GhosttyWindow::setupEgl() { if (m_eglContext == EGL_NO_CONTEXT) return false; m_eglSurface = eglCreateWindowSurface( - m_eglDisplay, config, - static_cast(winId()), nullptr); + m_eglDisplay, config, static_cast(winId()), + nullptr); if (m_eglSurface == EGL_NO_SURFACE) return false; return true; } -void GhosttyWindow::updateSize() { +void GhosttySurface::updateSize() { if (!m_surface) return; const double dpr = devicePixelRatio(); const int w = static_cast(width() * dpr); @@ -190,7 +156,7 @@ static ghostty_input_mods_e translateMods(Qt::KeyboardModifiers m) { return static_cast(r); } -void GhosttyWindow::sendKey(QKeyEvent *ev, ghostty_input_action_e action) { +void GhosttySurface::sendKey(QKeyEvent *ev, ghostty_input_action_e action) { if (!m_surface) return; // Forward committed text only for printable input; control characters @@ -224,8 +190,8 @@ void GhosttyWindow::sendKey(QKeyEvent *ev, ghostty_input_action_e action) { ghostty_surface_key(m_surface, k); } -void GhosttyWindow::sendMouseButton(QMouseEvent *ev, - ghostty_input_mouse_state_e state) { +void GhosttySurface::sendMouseButton(QMouseEvent *ev, + ghostty_input_mouse_state_e state) { if (!m_surface) return; ghostty_input_mouse_button_e button; switch (ev->button()) { @@ -238,14 +204,9 @@ void GhosttyWindow::sendMouseButton(QMouseEvent *ev, translateMods(ev->modifiers())); } -void GhosttyWindow::tick() { - if (m_app) ghostty_app_tick(m_app); - if (m_surface && ghostty_surface_process_exited(m_surface)) close(); -} - // --- QWindow events -------------------------------------------------- -void GhosttyWindow::exposeEvent(QExposeEvent *) { +void GhosttySurface::exposeEvent(QExposeEvent *) { if (!m_surface || !isExposed()) return; // devicePixelRatio() is only reliable once the window is on a screen, // so (re)sync the surface size here as well as in resizeEvent. @@ -253,27 +214,28 @@ void GhosttyWindow::exposeEvent(QExposeEvent *) { ghostty_surface_refresh(m_surface); } -void GhosttyWindow::resizeEvent(QResizeEvent *) { updateSize(); } +void GhosttySurface::resizeEvent(QResizeEvent *) { updateSize(); } -void GhosttyWindow::keyPressEvent(QKeyEvent *ev) { - sendKey(ev, ev->isAutoRepeat() ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS); +void GhosttySurface::keyPressEvent(QKeyEvent *ev) { + sendKey(ev, + ev->isAutoRepeat() ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS); } -void GhosttyWindow::keyReleaseEvent(QKeyEvent *ev) { +void GhosttySurface::keyReleaseEvent(QKeyEvent *ev) { // Qt synthesizes a release before each auto-repeat press; drop those. if (ev->isAutoRepeat()) return; sendKey(ev, GHOSTTY_ACTION_RELEASE); } -void GhosttyWindow::mousePressEvent(QMouseEvent *ev) { +void GhosttySurface::mousePressEvent(QMouseEvent *ev) { sendMouseButton(ev, GHOSTTY_MOUSE_PRESS); } -void GhosttyWindow::mouseReleaseEvent(QMouseEvent *ev) { +void GhosttySurface::mouseReleaseEvent(QMouseEvent *ev) { sendMouseButton(ev, GHOSTTY_MOUSE_RELEASE); } -void GhosttyWindow::mouseMoveEvent(QMouseEvent *ev) { +void GhosttySurface::mouseMoveEvent(QMouseEvent *ev) { if (!m_surface) return; const double dpr = devicePixelRatio(); ghostty_surface_mouse_pos(m_surface, ev->position().x() * dpr, @@ -281,128 +243,40 @@ void GhosttyWindow::mouseMoveEvent(QMouseEvent *ev) { translateMods(ev->modifiers())); } -void GhosttyWindow::wheelEvent(QWheelEvent *ev) { +void GhosttySurface::wheelEvent(QWheelEvent *ev) { if (!m_surface) return; // angleDelta is in eighths of a degree; 120 units == one wheel notch. const QPoint d = ev->angleDelta(); ghostty_surface_mouse_scroll(m_surface, d.x() / 120.0, d.y() / 120.0, 0); } -void GhosttyWindow::focusInEvent(QFocusEvent *) { +void GhosttySurface::focusInEvent(QFocusEvent *) { if (m_surface) ghostty_surface_set_focus(m_surface, true); } -void GhosttyWindow::focusOutEvent(QFocusEvent *) { +void GhosttySurface::focusOutEvent(QFocusEvent *) { if (m_surface) ghostty_surface_set_focus(m_surface, false); } // --- GL context callbacks (run on libghostty's renderer thread) ------ -void *GhosttyWindow::glGetProcAddress(void *, const char *name) { +void *GhosttySurface::glGetProcAddress(void *, const char *name) { return reinterpret_cast(eglGetProcAddress(name)); } -void GhosttyWindow::glMakeCurrent(void *ud) { - auto *self = static_cast(ud); +void GhosttySurface::glMakeCurrent(void *ud) { + auto *self = static_cast(ud); eglMakeCurrent(self->m_eglDisplay, self->m_eglSurface, self->m_eglSurface, self->m_eglContext); } -void GhosttyWindow::glReleaseCurrent(void *ud) { - auto *self = static_cast(ud); +void GhosttySurface::glReleaseCurrent(void *ud) { + auto *self = static_cast(ud); eglMakeCurrent(self->m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); } -void GhosttyWindow::glPresent(void *ud) { - auto *self = static_cast(ud); +void GhosttySurface::glPresent(void *ud) { + auto *self = static_cast(ud); eglSwapBuffers(self->m_eglDisplay, self->m_eglSurface); } - -// --- libghostty runtime callbacks ------------------------------------ - -void GhosttyWindow::onWakeup(void *ud) { - // Called from a libghostty thread; hop to the GUI thread to tick. - auto *self = static_cast(ud); - QMetaObject::invokeMethod(self, "tick", Qt::QueuedConnection); -} - -bool GhosttyWindow::onAction(ghostty_app_t app, ghostty_target_s target, - ghostty_action_s action) { - (void)target; - // Actions can be dispatched from non-GUI threads, so anything touching - // the window is marshalled onto the GUI thread. - auto *self = static_cast(ghostty_app_userdata(app)); - if (!self) return false; - - switch (action.tag) { - case GHOSTTY_ACTION_SET_TITLE: { - const char *title = action.action.set_title.title; - if (!title) return true; - const QString t = QString::fromUtf8(title); - QMetaObject::invokeMethod( - self, [self, t]() { self->setTitle(t); }, Qt::QueuedConnection); - return true; - } - - case GHOSTTY_ACTION_QUIT: - case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: - QMetaObject::invokeMethod( - self, [self]() { self->close(); }, Qt::QueuedConnection); - return true; - - default: - // Tabs, splits, fullscreen, etc. are not handled by this - // single-window scaffold yet. - return false; - } -} - -bool GhosttyWindow::onReadClipboard(void *ud, ghostty_clipboard_e loc, - void *state) { - // Called synchronously when libghostty needs clipboard contents (paste). - auto *self = static_cast(ud); - if (!self->m_surface) return false; - - const QClipboard::Mode mode = loc == GHOSTTY_CLIPBOARD_SELECTION - ? QClipboard::Selection - : QClipboard::Clipboard; - const QByteArray text = QGuiApplication::clipboard()->text(mode).toUtf8(); - ghostty_surface_complete_clipboard_request(self->m_surface, - text.constData(), state, true); - return true; -} - -void GhosttyWindow::onConfirmReadClipboard(void *ud, const char *str, - void *state, - ghostty_clipboard_request_e req) { - (void)req; - // The scaffold trusts pastes rather than showing an unsafe-paste - // confirmation dialog. TODO: a real confirmation prompt. - auto *self = static_cast(ud); - if (self->m_surface) - ghostty_surface_complete_clipboard_request(self->m_surface, str, state, - true); -} - -void GhosttyWindow::onWriteClipboard(void *ud, ghostty_clipboard_e loc, - const ghostty_clipboard_content_s *content, - size_t n, bool confirm) { - (void)confirm; - if (n == 0 || !content[0].data) return; - - auto *self = static_cast(ud); - const QClipboard::Mode mode = loc == GHOSTTY_CLIPBOARD_SELECTION - ? QClipboard::Selection - : QClipboard::Clipboard; - const QString text = QString::fromUtf8(content[0].data); - QMetaObject::invokeMethod( - self, [text, mode]() { QGuiApplication::clipboard()->setText(text, mode); }, - Qt::QueuedConnection); -} - -void GhosttyWindow::onCloseSurface(void *ud, bool) { - auto *self = static_cast(ud); - QMetaObject::invokeMethod( - self, [self]() { self->close(); }, Qt::QueuedConnection); -} diff --git a/qt/src/GhosttySurface.h b/qt/src/GhosttySurface.h new file mode 100644 index 000000000..3e72fa6a8 --- /dev/null +++ b/qt/src/GhosttySurface.h @@ -0,0 +1,62 @@ +#pragma once + +#include + +#include + +#include "ghostty.h" + +class MainWindow; + +// One Ghostty terminal surface, rendered into a QWindow. The QWindow is +// embedded into a MainWindow tab via QWidget::createWindowContainer. +// +// Rendering uses raw EGL rather than QOpenGLContext: libghostty's +// renderer thread, which makes the GL context current, is not a QThread. +class GhosttySurface : public QWindow { + Q_OBJECT + +public: + GhosttySurface(ghostty_app_t app, MainWindow *owner); + ~GhosttySurface() override; + + // Create the EGL context and the libghostty surface. When `parent` is + // non-null the new surface inherits its working directory etc. + bool initialize(ghostty_surface_t parent); + + ghostty_surface_t surface() const { return m_surface; } + MainWindow *owner() const { return m_owner; } + +protected: + void exposeEvent(QExposeEvent *) override; + void resizeEvent(QResizeEvent *) override; + void keyPressEvent(QKeyEvent *) override; + void keyReleaseEvent(QKeyEvent *) override; + void mousePressEvent(QMouseEvent *) override; + void mouseReleaseEvent(QMouseEvent *) override; + void mouseMoveEvent(QMouseEvent *) override; + void wheelEvent(QWheelEvent *) override; + void focusInEvent(QFocusEvent *) override; + void focusOutEvent(QFocusEvent *) override; + +private: + bool setupEgl(); + void updateSize(); + void sendKey(QKeyEvent *, ghostty_input_action_e action); + void sendMouseButton(QMouseEvent *, ghostty_input_mouse_state_e state); + + // GL context callbacks (run on libghostty's renderer 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 + + EGLDisplay m_eglDisplay = EGL_NO_DISPLAY; + EGLContext m_eglContext = EGL_NO_CONTEXT; + EGLSurface m_eglSurface = EGL_NO_SURFACE; + + ghostty_surface_t m_surface = nullptr; +}; diff --git a/qt/src/GhosttyWindow.h b/qt/src/GhosttyWindow.h deleted file mode 100644 index d47892c0f..000000000 --- a/qt/src/GhosttyWindow.h +++ /dev/null @@ -1,80 +0,0 @@ -#pragma once - -#include - -#include - -#include "ghostty.h" - -// A single Ghostty terminal surface hosted in a Qt QWindow. -// -// Rendering: libghostty owns a dedicated renderer thread and drives the -// GL context through the gl* callbacks below. We use raw EGL rather than -// QOpenGLContext because eglMakeCurrent is callable from any thread, -// whereas a QOpenGLContext is bound to the QThread it belongs to and -// libghostty's renderer thread is not a QThread. -// -// Scaffold scope (milestone M2): renders, resizes, tracks focus/DPI, and -// accepts text input. Full key translation, mouse, clipboard and action -// handling are marked TODO and belong to later milestones. -class GhosttyWindow : public QWindow { - Q_OBJECT - -public: - GhosttyWindow(); - ~GhosttyWindow() override; - - // Create the EGL context and the libghostty app + surface. Call once - // before show(). Returns false on failure. - bool initialize(); - -public slots: - // Pump libghostty's app-level work. Invoked from the wakeup callback - // (queued onto the GUI thread) and by a periodic timer. - void tick(); - -protected: - void exposeEvent(QExposeEvent *) override; - void resizeEvent(QResizeEvent *) override; - void keyPressEvent(QKeyEvent *) override; - void keyReleaseEvent(QKeyEvent *) override; - void mousePressEvent(QMouseEvent *) override; - void mouseReleaseEvent(QMouseEvent *) override; - void mouseMoveEvent(QMouseEvent *) override; - void wheelEvent(QWheelEvent *) override; - void focusInEvent(QFocusEvent *) override; - void focusOutEvent(QFocusEvent *) override; - -private: - bool setupEgl(); - void updateSize(); - void sendKey(QKeyEvent *, ghostty_input_action_e action); - void sendMouseButton(QMouseEvent *, ghostty_input_mouse_state_e state); - - // --- GL context callbacks (run on libghostty's renderer thread) --- - static void *glGetProcAddress(void *ud, const char *name); - static void glMakeCurrent(void *ud); - static void glReleaseCurrent(void *ud); - static void glPresent(void *ud); - - // --- libghostty runtime callbacks --- - 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); - - // EGL state. - EGLDisplay m_eglDisplay = EGL_NO_DISPLAY; - EGLContext m_eglContext = EGL_NO_CONTEXT; - EGLSurface m_eglSurface = EGL_NO_SURFACE; - - // libghostty handles. - ghostty_config_t m_config = nullptr; - ghostty_app_t m_app = nullptr; - ghostty_surface_t m_surface = nullptr; -}; diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp new file mode 100644 index 000000000..59bec80bf --- /dev/null +++ b/qt/src/MainWindow.cpp @@ -0,0 +1,264 @@ +#include "MainWindow.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "GhosttySurface.h" + +MainWindow::MainWindow() { + setWindowTitle(QStringLiteral("Ghostty (Qt)")); + + m_tabs = new QTabWidget(this); + m_tabs->setTabsClosable(true); + m_tabs->setMovable(true); + m_tabs->setDocumentMode(true); + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(m_tabs); + + connect(m_tabs, &QTabWidget::tabCloseRequested, this, + &MainWindow::onTabCloseRequested); + connect(m_tabs, &QTabWidget::currentChanged, this, + &MainWindow::onCurrentChanged); +} + +MainWindow::~MainWindow() { + // Surfaces must be destroyed (freeing their ghostty_surface_t and + // stopping their renderer threads) before the shared app is freed. + qDeleteAll(m_containers); + m_containers.clear(); + if (m_app) ghostty_app_free(m_app); + if (m_config) ghostty_config_free(m_config); +} + +bool MainWindow::initialize() { + // Load configuration in the same order as the reference apprt. + m_config = ghostty_config_new(); + ghostty_config_load_default_files(m_config); + ghostty_config_load_cli_args(m_config); + ghostty_config_load_recursive_files(m_config); + ghostty_config_finalize(m_config); + + ghostty_runtime_config_s rt = {}; + rt.userdata = this; + rt.supports_selection_clipboard = true; + rt.wakeup_cb = onWakeup; + rt.action_cb = onAction; + rt.read_clipboard_cb = onReadClipboard; + rt.confirm_read_clipboard_cb = onConfirmReadClipboard; + rt.write_clipboard_cb = onWriteClipboard; + rt.close_surface_cb = onCloseSurface; + + m_app = ghostty_app_new(&rt, m_config); + if (!m_app) { + std::fprintf(stderr, "[ghostty-qt] ghostty_app_new failed\n"); + return false; + } + + // Periodic tick as a backstop; onWakeup drives responsive ticking. + auto *timer = new QTimer(this); + connect(timer, &QTimer::timeout, this, &MainWindow::tick); + timer->start(16); + + return newTab(nullptr) != nullptr; +} + +GhosttySurface *MainWindow::newTab(ghostty_surface_t parent) { + auto *surface = new GhosttySurface(m_app, this); + QWidget *container = QWidget::createWindowContainer(surface, m_tabs); + container->setFocusPolicy(Qt::StrongFocus); + + const int index = m_tabs->addTab(container, QStringLiteral("Ghostty")); + m_containers.insert(surface, container); + m_tabs->setCurrentIndex(index); + + if (!surface->initialize(parent)) { + m_containers.remove(surface); + m_tabs->removeTab(index); + delete container; // also destroys the GhosttySurface window + return nullptr; + } + + surface->requestActivate(); + return surface; +} + +void MainWindow::removeSurface(GhosttySurface *surface) { + const auto it = m_containers.find(surface); + if (it == m_containers.end()) return; + + QWidget *container = it.value(); + m_containers.erase(it); + const int index = m_tabs->indexOf(container); + if (index >= 0) m_tabs->removeTab(index); + container->deleteLater(); // also destroys the GhosttySurface window + + if (m_tabs->count() == 0) close(); +} + +void MainWindow::setSurfaceTitle(GhosttySurface *surface, + const QString &title) { + const int index = indexOfSurface(surface); + if (index < 0) return; + m_tabs->setTabText(index, title); + if (index == m_tabs->currentIndex()) + setWindowTitle(title + QStringLiteral(" — Ghostty")); +} + +void MainWindow::tick() { + if (!m_app) return; + ghostty_app_tick(m_app); + + // Close any tab whose child process has exited. + const auto surfaces = m_containers.keys(); + for (GhosttySurface *s : surfaces) { + if (s->surface() && ghostty_surface_process_exited(s->surface())) + removeSurface(s); + } +} + +void MainWindow::onTabCloseRequested(int index) { + if (GhosttySurface *s = surfaceAt(index)) removeSurface(s); +} + +void MainWindow::onCurrentChanged(int index) { + GhosttySurface *s = surfaceAt(index); + if (!s) return; + s->requestActivate(); + setWindowTitle(m_tabs->tabText(index) + QStringLiteral(" — Ghostty")); +} + +GhosttySurface *MainWindow::surfaceAt(int index) const { + QWidget *w = m_tabs->widget(index); + if (!w) return nullptr; + for (auto it = m_containers.cbegin(); it != m_containers.cend(); ++it) + if (it.value() == w) return it.key(); + return nullptr; +} + +int MainWindow::indexOfSurface(GhosttySurface *surface) const { + QWidget *container = m_containers.value(surface); + return container ? m_tabs->indexOf(container) : -1; +} + +// --- libghostty runtime callbacks ------------------------------------ + +void MainWindow::onWakeup(void *ud) { + // app userdata; hop to the GUI thread to tick. + auto *self = static_cast(ud); + QMetaObject::invokeMethod(self, "tick", Qt::QueuedConnection); +} + +bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, + ghostty_action_s action) { + auto *self = static_cast(ghostty_app_userdata(app)); + if (!self) return false; + + // The surface this action targets, if any. + GhosttySurface *src = nullptr; + if (target.tag == GHOSTTY_TARGET_SURFACE && target.target.surface) + src = static_cast( + ghostty_surface_userdata(target.target.surface)); + + // Actions may be dispatched from non-GUI threads, so window-touching + // work is marshalled onto the GUI thread. + switch (action.tag) { + case GHOSTTY_ACTION_NEW_TAB: + case GHOSTTY_ACTION_NEW_WINDOW: { + // This single-window app maps new windows to new tabs. + ghostty_surface_t parent = src ? src->surface() : nullptr; + QMetaObject::invokeMethod( + self, [self, parent]() { self->newTab(parent); }, + Qt::QueuedConnection); + return true; + } + + case GHOSTTY_ACTION_CLOSE_TAB: + if (src) + QMetaObject::invokeMethod( + self, [self, src]() { self->removeSurface(src); }, + Qt::QueuedConnection); + return true; + + case GHOSTTY_ACTION_SET_TITLE: { + const char *title = action.action.set_title.title; + if (!title || !src) return true; + const QString t = QString::fromUtf8(title); + QMetaObject::invokeMethod( + self, [self, src, t]() { self->setSurfaceTitle(src, t); }, + Qt::QueuedConnection); + return true; + } + + case GHOSTTY_ACTION_QUIT: + case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: + QMetaObject::invokeMethod( + self, [self]() { self->close(); }, Qt::QueuedConnection); + return true; + + default: + // Splits, fullscreen, tab navigation, etc. are not handled yet. + return false; + } +} + +bool MainWindow::onReadClipboard(void *ud, ghostty_clipboard_e loc, + void *state) { + // surface userdata. Called synchronously when libghostty needs + // clipboard contents (paste). + auto *surface = static_cast(ud); + if (!surface || !surface->surface()) return false; + + const QClipboard::Mode mode = loc == GHOSTTY_CLIPBOARD_SELECTION + ? QClipboard::Selection + : QClipboard::Clipboard; + const QByteArray text = QGuiApplication::clipboard()->text(mode).toUtf8(); + ghostty_surface_complete_clipboard_request(surface->surface(), + text.constData(), state, true); + return true; +} + +void MainWindow::onConfirmReadClipboard(void *ud, const char *str, void *state, + ghostty_clipboard_request_e) { + // The scaffold trusts pastes rather than showing an unsafe-paste + // confirmation dialog. TODO: a real confirmation prompt. + auto *surface = static_cast(ud); + if (surface && surface->surface()) + ghostty_surface_complete_clipboard_request(surface->surface(), str, state, + true); +} + +void MainWindow::onWriteClipboard(void *ud, ghostty_clipboard_e loc, + const ghostty_clipboard_content_s *content, + size_t n, bool) { + if (n == 0 || !content[0].data) return; + auto *surface = static_cast(ud); + if (!surface) return; + + const QClipboard::Mode mode = loc == GHOSTTY_CLIPBOARD_SELECTION + ? QClipboard::Selection + : QClipboard::Clipboard; + const QString text = QString::fromUtf8(content[0].data); + QMetaObject::invokeMethod( + surface->owner(), + [text, mode]() { QGuiApplication::clipboard()->setText(text, mode); }, + Qt::QueuedConnection); +} + +void MainWindow::onCloseSurface(void *ud, bool) { + // surface userdata. + auto *surface = static_cast(ud); + if (!surface) return; + MainWindow *self = surface->owner(); + QMetaObject::invokeMethod( + self, [self, surface]() { self->removeSurface(surface); }, + Qt::QueuedConnection); +} diff --git a/qt/src/MainWindow.h b/qt/src/MainWindow.h new file mode 100644 index 000000000..3df8c4fdd --- /dev/null +++ b/qt/src/MainWindow.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include + +#include "ghostty.h" + +class QTabWidget; +class GhosttySurface; + +// The top-level window. Owns the shared ghostty_app_t and presents one +// or more terminal surfaces as tabs. +class MainWindow : public QWidget { + Q_OBJECT + +public: + MainWindow(); + ~MainWindow() override; + + // Create the libghostty app and the first tab. Call once before show(). + bool initialize(); + + // 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); + + // Remove the tab hosting `surface`; closes the window if it was last. + void removeSurface(GhosttySurface *surface); + + // Update the tab label and window title for `surface`. + void setSurfaceTitle(GhosttySurface *surface, const QString &title); + +public slots: + void tick(); + +private slots: + void onTabCloseRequested(int index); + void onCurrentChanged(int index); + +private: + GhosttySurface *surfaceAt(int index) const; + int indexOfSurface(GhosttySurface *surface) const; + + // Runtime callbacks dispatched by libghostty. wakeup/action carry the + // app userdata; 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); + + ghostty_config_t m_config = nullptr; + ghostty_app_t m_app = nullptr; + QTabWidget *m_tabs = nullptr; + + // Each surface mapped to the container widget that hosts it in a tab. + QHash m_containers; +}; diff --git a/qt/src/main.cpp b/qt/src/main.cpp index e2fd69206..fccbf7038 100644 --- a/qt/src/main.cpp +++ b/qt/src/main.cpp @@ -1,8 +1,8 @@ #include -#include +#include -#include "GhosttyWindow.h" +#include "MainWindow.h" #include "ghostty.h" int main(int argc, char **argv) { @@ -16,9 +16,9 @@ int main(int argc, char **argv) { return 1; } - QGuiApplication app(argc, argv); + QApplication app(argc, argv); - GhosttyWindow window; + MainWindow window; if (!window.initialize()) { std::fprintf(stderr, "[ghostty-qt] window initialization failed\n"); return 1; From 22eeac5a2397a981afd69ac44f6fc19c4640bee0 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 11:42:26 -0500 Subject: [PATCH 11/75] qt: terminal splits within tabs Each tab page now hosts a recursive QSplitter tree rather than a single surface. Handling GHOSTTY_ACTION_NEW_SPLIT inserts a QSplitter (oriented per the split direction) in place of the target surface's pane, holding the original surface and a new one. Closing a single pane collapses its splitter into the surviving sibling; closing a tab destroys every surface in its split tree. New panes inherit the working directory of the surface they split from. Co-Authored-By: claude-flow --- qt/src/MainWindow.cpp | 141 ++++++++++++++++++++++++++++++++++++------ qt/src/MainWindow.h | 20 ++++-- 2 files changed, 138 insertions(+), 23 deletions(-) diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index 59bec80bf..75925a969 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include #include #include @@ -73,17 +75,76 @@ bool MainWindow::initialize() { GhosttySurface *MainWindow::newTab(ghostty_surface_t parent) { auto *surface = new GhosttySurface(m_app, this); - QWidget *container = QWidget::createWindowContainer(surface, m_tabs); + QWidget *container = QWidget::createWindowContainer(surface, nullptr); container->setFocusPolicy(Qt::StrongFocus); - const int index = m_tabs->addTab(container, QStringLiteral("Ghostty")); + // The tab page hosts the tab's split tree (initially one surface). + auto *page = new QWidget(m_tabs); + auto *pageLayout = new QVBoxLayout(page); + pageLayout->setContentsMargins(0, 0, 0, 0); + pageLayout->addWidget(container); + + const int index = m_tabs->addTab(page, QStringLiteral("Ghostty")); m_containers.insert(surface, container); m_tabs->setCurrentIndex(index); if (!surface->initialize(parent)) { m_containers.remove(surface); m_tabs->removeTab(index); - delete container; // also destroys the GhosttySurface window + delete page; // also destroys the container and its surface + return nullptr; + } + + surface->requestActivate(); + return surface; +} + +GhosttySurface *MainWindow::splitSurface( + GhosttySurface *target, ghostty_action_split_direction_e dir) { + QWidget *container = m_containers.value(target); + if (!container) return nullptr; + + const bool horizontal = dir == GHOSTTY_SPLIT_DIRECTION_RIGHT || + dir == GHOSTTY_SPLIT_DIRECTION_LEFT; + const bool newAfter = dir == GHOSTTY_SPLIT_DIRECTION_RIGHT || + dir == GHOSTTY_SPLIT_DIRECTION_DOWN; + + auto *surface = new GhosttySurface(m_app, this); + QWidget *newContainer = QWidget::createWindowContainer(surface, nullptr); + newContainer->setFocusPolicy(Qt::StrongFocus); + + auto *splitter = + new QSplitter(horizontal ? Qt::Horizontal : Qt::Vertical); + splitter->setChildrenCollapsible(false); + + // Insert `splitter` where `container` currently sits in the tree. + QWidget *parent = container->parentWidget(); + if (auto *parentSplitter = qobject_cast(parent)) { + parentSplitter->replaceWidget(parentSplitter->indexOf(container), + splitter); + } else if (parent && parent->layout()) { + delete parent->layout()->replaceWidget(container, splitter); + } else { + delete splitter; + delete newContainer; // also destroys the new surface + return nullptr; + } + + // `container` is now parentless; place it and the new pane in order. + if (newAfter) { + splitter->addWidget(container); + splitter->addWidget(newContainer); + } else { + splitter->addWidget(newContainer); + splitter->addWidget(container); + } + splitter->setSizes({1 << 20, 1 << 20}); // start the panes roughly equal + + m_containers.insert(surface, newContainer); + + if (!surface->initialize(target->surface())) { + m_containers.remove(surface); + delete newContainer; // leaves a one-pane splitter; near-impossible path return nullptr; } @@ -94,19 +155,53 @@ GhosttySurface *MainWindow::newTab(ghostty_surface_t parent) { void MainWindow::removeSurface(GhosttySurface *surface) { const auto it = m_containers.find(surface); if (it == m_containers.end()) return; - QWidget *container = it.value(); m_containers.erase(it); - const int index = m_tabs->indexOf(container); - if (index >= 0) m_tabs->removeTab(index); - container->deleteLater(); // also destroys the GhosttySurface window + QWidget *parent = container->parentWidget(); + + if (auto *splitter = qobject_cast(parent)) { + // One pane of a split: collapse the splitter into its sibling. + QWidget *sibling = nullptr; + for (int i = 0; i < splitter->count(); ++i) + if (splitter->widget(i) != container) sibling = splitter->widget(i); + + QWidget *splitterParent = splitter->parentWidget(); + if (auto *grand = qobject_cast(splitterParent)) { + grand->replaceWidget(grand->indexOf(splitter), sibling); + } else if (splitterParent && splitterParent->layout()) { + delete splitterParent->layout()->replaceWidget(splitter, sibling); + } + // Deleting the now-orphaned splitter also deletes `container`. + splitter->deleteLater(); + return; + } + + // Otherwise this surface is the whole tab. + const int index = m_tabs->indexOf(parent); + if (index >= 0) m_tabs->removeTab(index); + if (parent) parent->deleteLater(); // page; deletes the container too + if (m_tabs->count() == 0) close(); +} + +void MainWindow::closeTab(int index) { + QWidget *page = m_tabs->widget(index); + if (!page) return; + + // Drop every surface hosted anywhere inside this tab's split tree. + const auto surfaces = m_containers.keys(); + for (GhosttySurface *s : surfaces) { + QWidget *c = m_containers.value(s); + if (c && page->isAncestorOf(c)) m_containers.remove(s); + } + m_tabs->removeTab(index); + page->deleteLater(); // destroys all contained surfaces if (m_tabs->count() == 0) close(); } void MainWindow::setSurfaceTitle(GhosttySurface *surface, const QString &title) { - const int index = indexOfSurface(surface); + const int index = tabIndexForSurface(surface); if (index < 0) return; m_tabs->setTabText(index, title); if (index == m_tabs->currentIndex()) @@ -117,7 +212,7 @@ void MainWindow::tick() { if (!m_app) return; ghostty_app_tick(m_app); - // Close any tab whose child process has exited. + // Close any pane whose child process has exited. const auto surfaces = m_containers.keys(); for (GhosttySurface *s : surfaces) { if (s->surface() && ghostty_surface_process_exited(s->surface())) @@ -125,9 +220,7 @@ void MainWindow::tick() { } } -void MainWindow::onTabCloseRequested(int index) { - if (GhosttySurface *s = surfaceAt(index)) removeSurface(s); -} +void MainWindow::onTabCloseRequested(int index) { closeTab(index); } void MainWindow::onCurrentChanged(int index) { GhosttySurface *s = surfaceAt(index); @@ -137,16 +230,19 @@ void MainWindow::onCurrentChanged(int index) { } GhosttySurface *MainWindow::surfaceAt(int index) const { - QWidget *w = m_tabs->widget(index); - if (!w) return nullptr; + QWidget *page = m_tabs->widget(index); + if (!page) return nullptr; for (auto it = m_containers.cbegin(); it != m_containers.cend(); ++it) - if (it.value() == w) return it.key(); + if (page->isAncestorOf(it.value())) return it.key(); return nullptr; } -int MainWindow::indexOfSurface(GhosttySurface *surface) const { +int MainWindow::tabIndexForSurface(GhosttySurface *surface) const { QWidget *container = m_containers.value(surface); - return container ? m_tabs->indexOf(container) : -1; + if (!container) return -1; + for (int i = 0; i < m_tabs->count(); ++i) + if (m_tabs->widget(i)->isAncestorOf(container)) return i; + return -1; } // --- libghostty runtime callbacks ------------------------------------ @@ -181,6 +277,15 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, return true; } + case GHOSTTY_ACTION_NEW_SPLIT: { + if (!src) return false; + const ghostty_action_split_direction_e dir = action.action.new_split; + QMetaObject::invokeMethod( + self, [self, src, dir]() { self->splitSurface(src, dir); }, + Qt::QueuedConnection); + return true; + } + case GHOSTTY_ACTION_CLOSE_TAB: if (src) QMetaObject::invokeMethod( @@ -205,7 +310,7 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, return true; default: - // Splits, fullscreen, tab navigation, etc. are not handled yet. + // Fullscreen, tab navigation, etc. are not handled yet. return false; } } diff --git a/qt/src/MainWindow.h b/qt/src/MainWindow.h index 3df8c4fdd..49b75a4fb 100644 --- a/qt/src/MainWindow.h +++ b/qt/src/MainWindow.h @@ -8,8 +8,12 @@ class QTabWidget; class GhosttySurface; -// The top-level window. Owns the shared ghostty_app_t and presents one -// or more terminal surfaces as tabs. +// The top-level window. Owns the shared ghostty_app_t and presents +// terminal surfaces as tabs; each tab may be subdivided into splits. +// +// Widget tree: QTabWidget -> tab page (QWidget) -> split tree, where a +// node is either a surface container (QWidget::createWindowContainer) +// or a QSplitter of two such nodes. class MainWindow : public QWidget { Q_OBJECT @@ -24,7 +28,12 @@ public: // directory etc. the new surface should inherit. GhosttySurface *newTab(ghostty_surface_t parent); - // Remove the tab hosting `surface`; closes the window if it was last. + // 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`. @@ -38,8 +47,9 @@ private slots: void onCurrentChanged(int index); private: + void closeTab(int index); GhosttySurface *surfaceAt(int index) const; - int indexOfSurface(GhosttySurface *surface) const; + int tabIndexForSurface(GhosttySurface *surface) const; // Runtime callbacks dispatched by libghostty. wakeup/action carry the // app userdata; clipboard/close carry the surface userdata. @@ -57,6 +67,6 @@ private: ghostty_app_t m_app = nullptr; QTabWidget *m_tabs = nullptr; - // Each surface mapped to the container widget that hosts it in a tab. + // Each surface mapped to the container widget that hosts it. QHash m_containers; }; From ce212c62a6093058ba79bd240b302a39db0e44f0 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 11:45:36 -0500 Subject: [PATCH 12/75] qt: tab and split navigation actions Handle the remaining navigation-oriented actions: - goto-tab: previous/next/last and 1-based index. - goto-split: previous/next cycle through panes in reading order; up/down/left/right pick the nearest pane in that direction. - resize-split: nudge the focused pane's splitter divider. - equalize-splits: reset every splitter in the tab to equal sizes. - toggle-fullscreen / toggle-maximize on the window. Co-Authored-By: claude-flow --- qt/src/MainWindow.cpp | 186 +++++++++++++++++++++++++++++++++++++++++- qt/src/MainWindow.h | 8 ++ 2 files changed, 193 insertions(+), 1 deletion(-) diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index 75925a969..936be399b 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -1,11 +1,15 @@ #include "MainWindow.h" +#include +#include #include #include #include #include #include +#include +#include #include #include #include @@ -245,6 +249,130 @@ int MainWindow::tabIndexForSurface(GhosttySurface *surface) const { return -1; } +QList MainWindow::surfacesInTab(int index) const { + QList result; + QWidget *page = m_tabs->widget(index); + if (!page) return result; + for (auto it = m_containers.cbegin(); it != m_containers.cend(); ++it) + if (page->isAncestorOf(it.value())) result.append(it.key()); + return result; +} + +void MainWindow::gotoTab(ghostty_action_goto_tab_e tab) { + const int n = m_tabs->count(); + if (n == 0) return; + int index; + switch (tab) { + case GHOSTTY_GOTO_TAB_PREVIOUS: + index = (m_tabs->currentIndex() - 1 + n) % n; + break; + case GHOSTTY_GOTO_TAB_NEXT: + index = (m_tabs->currentIndex() + 1) % n; + break; + case GHOSTTY_GOTO_TAB_LAST: + index = n - 1; + break; + default: + // A positive value is a 1-based tab number. + index = static_cast(tab) - 1; + break; + } + if (index >= 0 && index < n) m_tabs->setCurrentIndex(index); +} + +void MainWindow::gotoSplit(GhosttySurface *from, + ghostty_action_goto_split_e dir) { + const int tab = tabIndexForSurface(from); + if (tab < 0) return; + QList panes = surfacesInTab(tab); + if (panes.size() < 2) return; + + // Global-coordinate center of a pane's container. + const auto centerOf = [this](GhosttySurface *s) { + QWidget *c = m_containers.value(s); + return QRect(c->mapToGlobal(QPoint(0, 0)), c->size()).center(); + }; + + GhosttySurface *target = nullptr; + if (dir == GHOSTTY_GOTO_SPLIT_PREVIOUS || + dir == GHOSTTY_GOTO_SPLIT_NEXT) { + // Cycle through panes in reading order. + std::sort(panes.begin(), panes.end(), + [&](GhosttySurface *a, GhosttySurface *b) { + const QPoint pa = centerOf(a), pb = centerOf(b); + return pa.y() != pb.y() ? pa.y() < pb.y() : pa.x() < pb.x(); + }); + const int i = panes.indexOf(from); + const int step = dir == GHOSTTY_GOTO_SPLIT_NEXT ? 1 : -1; + target = panes[(i + step + panes.size()) % panes.size()]; + } else { + // Directional: the nearest pane whose center lies that way. + const QPoint fc = centerOf(from); + int best = INT_MAX; + for (GhosttySurface *p : panes) { + if (p == from) continue; + const QPoint c = centerOf(p); + const int dx = c.x() - fc.x(), dy = c.y() - fc.y(); + bool ok = false; + switch (dir) { + case GHOSTTY_GOTO_SPLIT_LEFT: ok = dx < 0; break; + case GHOSTTY_GOTO_SPLIT_RIGHT: ok = dx > 0; break; + case GHOSTTY_GOTO_SPLIT_UP: ok = dy < 0; break; + case GHOSTTY_GOTO_SPLIT_DOWN: ok = dy > 0; break; + default: break; + } + if (!ok) continue; + const int dist = dx * dx + dy * dy; + if (dist < best) { + best = dist; + target = p; + } + } + } + + if (target) target->requestActivate(); +} + +void MainWindow::resizeSplit(GhosttySurface *from, + ghostty_action_resize_split_s rs) { + QWidget *container = m_containers.value(from); + if (!container) return; + auto *splitter = qobject_cast(container->parentWidget()); + if (!splitter) return; + + const bool horizontal = splitter->orientation() == Qt::Horizontal; + const bool axisMatches = + horizontal ? (rs.direction == GHOSTTY_RESIZE_SPLIT_LEFT || + rs.direction == GHOSTTY_RESIZE_SPLIT_RIGHT) + : (rs.direction == GHOSTTY_RESIZE_SPLIT_UP || + rs.direction == GHOSTTY_RESIZE_SPLIT_DOWN); + if (!axisMatches) return; + + QList sizes = splitter->sizes(); + const int idx = splitter->indexOf(container); + if (idx < 0 || sizes.size() < 2) return; + + const bool grow = rs.direction == GHOSTTY_RESIZE_SPLIT_RIGHT || + rs.direction == GHOSTTY_RESIZE_SPLIT_DOWN; + const int delta = grow ? rs.amount : -static_cast(rs.amount); + const int other = idx == 0 ? 1 : idx - 1; + sizes[idx] = std::max(0, sizes[idx] + delta); + sizes[other] = std::max(0, sizes[other] - delta); + splitter->setSizes(sizes); +} + +void MainWindow::equalizeSplits(GhosttySurface *from) { + const int tab = tabIndexForSurface(from); + if (tab < 0) return; + QWidget *page = m_tabs->widget(tab); + const auto splitters = page->findChildren(); + for (QSplitter *splitter : splitters) { + QList sizes; + for (int i = 0; i < splitter->count(); ++i) sizes.append(1 << 20); + splitter->setSizes(sizes); + } +} + // --- libghostty runtime callbacks ------------------------------------ void MainWindow::onWakeup(void *ud) { @@ -303,6 +431,62 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, return true; } + case GHOSTTY_ACTION_GOTO_TAB: { + const ghostty_action_goto_tab_e tab = action.action.goto_tab; + QMetaObject::invokeMethod( + self, [self, tab]() { self->gotoTab(tab); }, Qt::QueuedConnection); + return true; + } + + case GHOSTTY_ACTION_GOTO_SPLIT: { + if (!src) return false; + const ghostty_action_goto_split_e dir = action.action.goto_split; + QMetaObject::invokeMethod( + self, [self, src, dir]() { self->gotoSplit(src, dir); }, + Qt::QueuedConnection); + return true; + } + + case GHOSTTY_ACTION_RESIZE_SPLIT: { + if (!src) return false; + const ghostty_action_resize_split_s rs = action.action.resize_split; + QMetaObject::invokeMethod( + self, [self, src, rs]() { self->resizeSplit(src, rs); }, + Qt::QueuedConnection); + return true; + } + + case GHOSTTY_ACTION_EQUALIZE_SPLITS: + if (src) + QMetaObject::invokeMethod( + self, [self, src]() { self->equalizeSplits(src); }, + Qt::QueuedConnection); + return true; + + case GHOSTTY_ACTION_TOGGLE_FULLSCREEN: + QMetaObject::invokeMethod( + self, + [self]() { + if (self->isFullScreen()) + self->showNormal(); + else + self->showFullScreen(); + }, + Qt::QueuedConnection); + return true; + + case GHOSTTY_ACTION_TOGGLE_MAXIMIZE: + QMetaObject::invokeMethod( + self, + [self]() { + if (self->isMaximized()) + self->showNormal(); + else + self->showMaximized(); + }, + Qt::QueuedConnection); + return true; + case GHOSTTY_ACTION_QUIT: case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: QMetaObject::invokeMethod( @@ -310,7 +494,7 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, return true; default: - // Fullscreen, tab navigation, etc. are not handled yet. + // Split zoom, tab moving, inspector, etc. are not handled yet. return false; } } diff --git a/qt/src/MainWindow.h b/qt/src/MainWindow.h index 49b75a4fb..d8db1c7e9 100644 --- a/qt/src/MainWindow.h +++ b/qt/src/MainWindow.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include "ghostty.h" @@ -50,6 +51,13 @@ private: void closeTab(int index); GhosttySurface *surfaceAt(int index) const; int tabIndexForSurface(GhosttySurface *surface) const; + QList 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); // Runtime callbacks dispatched by libghostty. wakeup/action carry the // app userdata; clipboard/close carry the surface userdata. From cf1e62e4ce9cac4bdf45d48fc913af69b763292b Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 11:48:39 -0500 Subject: [PATCH 13/75] qt: don't terminate the shared EGL display per surface Every GhosttySurface obtains the same process-wide default EGLDisplay via eglGetDisplay(EGL_DEFAULT_DISPLAY). Calling eglTerminate on it when one surface was destroyed invalidated every other surface's context, so closing one tab or split crashed the next surface's teardown: its threadEnter -> eglMakeCurrent failed, and libghostty's deinit path treats a threadEnter failure as unreachable. Destroy each surface's own EGLContext and EGLSurface, but leave the shared display alone; it is reclaimed when the process exits. Co-Authored-By: claude-flow --- qt/src/GhosttySurface.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index aec40405a..fc5b4f9a6 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -34,12 +34,15 @@ GhosttySurface::~GhosttySurface() { // threadExit -> glReleaseCurrent before this returns. if (m_surface) ghostty_surface_free(m_surface); + // Destroy this surface's own EGL objects, but NOT the EGLDisplay: it + // is the process-wide default display shared by every surface, so + // calling eglTerminate here would invalidate the other surfaces' + // contexts. The display is released when the process exits. if (m_eglDisplay != EGL_NO_DISPLAY) { if (m_eglSurface != EGL_NO_SURFACE) eglDestroySurface(m_eglDisplay, m_eglSurface); if (m_eglContext != EGL_NO_CONTEXT) eglDestroyContext(m_eglDisplay, m_eglContext); - eglTerminate(m_eglDisplay); } } From 182e7b8e02c9cdb63155a4419655da3a0e22b1f3 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 12:02:04 -0500 Subject: [PATCH 14/75] qt: experimental Wayland-native EGL path (xcb stays default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Wayland branch to GhosttySurface::setupEgl: take the EGL display from Qt's wl_display, build a wl_egl_window from the window's wl_surface (via QPlatformNativeInterface), and resize it on changes. This is not yet usable. Rendering our own EGL onto a wl_surface that Qt also manages trips Wayland protocol errors (wl_shm pool shrink, explicit-sync surface lifetime) — unlike an X11 window, a Wayland surface tolerates only one buffer source. A real fix needs a different embedding model. The X11/xcb path is unaffected and remains the default; the Wayland path is opt-in via QT_QPA_PLATFORM=wayland. Co-Authored-By: claude-flow --- qt/CMakeLists.txt | 14 ++++++++--- qt/src/GhosttySurface.cpp | 53 ++++++++++++++++++++++++++++++++++++--- qt/src/GhosttySurface.h | 4 +++ qt/src/main.cpp | 6 +++-- 4 files changed, 67 insertions(+), 10 deletions(-) diff --git a/qt/CMakeLists.txt b/qt/CMakeLists.txt index 61aa81d37..b9e682aa1 100644 --- a/qt/CMakeLists.txt +++ b/qt/CMakeLists.txt @@ -12,18 +12,22 @@ project(ghostty-qt LANGUAGES CXX) # Then build and run this app: # # cmake -S qt -B qt/build && cmake --build qt/build -# QT_QPA_PLATFORM=xcb ./qt/build/ghostty-qt +# ./qt/build/ghostty-qt # -# This scaffold targets X11/xcb: it uses the X11 window id for the EGL -# window surface. Wayland-native support is a follow-up. +# Runs on X11/xcb (default; XWayland on a Wayland session). A native +# Wayland path exists (QT_QPA_PLATFORM=wayland) but is experimental: +# rendering our own EGL onto a Qt-owned wl_surface currently trips +# Wayland protocol errors. set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) -find_package(Qt6 REQUIRED COMPONENTS Gui Widgets) +find_package(Qt6 REQUIRED COMPONENTS Gui Widgets GuiPrivate) find_package(PkgConfig REQUIRED) pkg_check_modules(EGL REQUIRED IMPORTED_TARGET egl) +# Wayland-native rendering needs wl_egl_window. +pkg_check_modules(WAYLAND_EGL REQUIRED IMPORTED_TARGET wayland-egl) # libghostty is built out-of-tree by Zig. get_filename_component(GHOSTTY_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/.." ABSOLUTE) @@ -51,8 +55,10 @@ target_include_directories(ghostty-qt PRIVATE "${GHOSTTY_ROOT}/include") target_link_libraries(ghostty-qt PRIVATE Qt6::Gui + Qt6::GuiPrivate # QPlatformNativeInterface, for the Wayland wl_surface Qt6::Widgets PkgConfig::EGL + PkgConfig::WAYLAND_EGL "${GHOSTTY_LIB_DIR}/libghostty.so" ) diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index fc5b4f9a6..2f28afae6 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -1,16 +1,23 @@ #include "GhosttySurface.h" +#include #include #include #include #include +#include #include #include #include #include #include #include +#include +#include + +#include +#include GhosttySurface::GhosttySurface(ghostty_app_t app, MainWindow *owner) : m_app(app), m_owner(owner) { @@ -44,6 +51,7 @@ GhosttySurface::~GhosttySurface() { if (m_eglContext != EGL_NO_CONTEXT) eglDestroyContext(m_eglDisplay, m_eglContext); } + if (m_wlEglWindow) wl_egl_window_destroy(m_wlEglWindow); } bool GhosttySurface::initialize(ghostty_surface_t parent) { @@ -85,7 +93,21 @@ bool GhosttySurface::initialize(ghostty_surface_t parent) { } bool GhosttySurface::setupEgl() { - m_eglDisplay = eglGetDisplay(EGL_DEFAULT_DISPLAY); + const bool wayland = + QGuiApplication::platformName().contains(QLatin1String("wayland")); + + // --- EGL display ----------------------------------------------------- + if (wayland) { + // Use Qt's own Wayland connection so the EGL surface and the window + // share a wl_display. + auto *wl = + qGuiApp->nativeInterface(); + if (!wl || !wl->display()) return false; + m_eglDisplay = eglGetPlatformDisplay(EGL_PLATFORM_WAYLAND_KHR, + wl->display(), nullptr); + } else { + m_eglDisplay = eglGetDisplay(EGL_DEFAULT_DISPLAY); + } if (m_eglDisplay == EGL_NO_DISPLAY) return false; if (!eglInitialize(m_eglDisplay, nullptr, nullptr)) return false; @@ -130,9 +152,27 @@ bool GhosttySurface::setupEgl() { eglCreateContext(m_eglDisplay, config, EGL_NO_CONTEXT, contextAttribs); if (m_eglContext == EGL_NO_CONTEXT) return false; - m_eglSurface = eglCreateWindowSurface( - m_eglDisplay, config, static_cast(winId()), - nullptr); + // --- EGL window surface --------------------------------------------- + if (wayland) { + // EGL needs a wl_egl_window built from this window's wl_surface. + auto *ni = QGuiApplication::platformNativeInterface(); + auto *wlSurface = static_cast( + ni ? ni->nativeResourceForWindow("surface", this) : nullptr); + if (!wlSurface) return false; + + const int w = std::max(1, static_cast(width() * devicePixelRatio())); + const int h = std::max(1, static_cast(height() * devicePixelRatio())); + m_wlEglWindow = wl_egl_window_create(wlSurface, w, h); + if (!m_wlEglWindow) return false; + + m_eglSurface = eglCreateWindowSurface( + m_eglDisplay, config, + reinterpret_cast(m_wlEglWindow), nullptr); + } else { + m_eglSurface = eglCreateWindowSurface( + m_eglDisplay, config, static_cast(winId()), + nullptr); + } if (m_eglSurface == EGL_NO_SURFACE) return false; return true; @@ -143,6 +183,11 @@ void GhosttySurface::updateSize() { const double dpr = devicePixelRatio(); const int w = static_cast(width() * dpr); const int h = static_cast(height() * dpr); + + // The Wayland EGL window does not track the QWindow size on its own. + if (m_wlEglWindow && w > 0 && h > 0) + wl_egl_window_resize(m_wlEglWindow, w, h, 0, 0); + ghostty_surface_set_content_scale(m_surface, dpr, dpr); if (w > 0 && h > 0) ghostty_surface_set_size(m_surface, static_cast(w), diff --git a/qt/src/GhosttySurface.h b/qt/src/GhosttySurface.h index 3e72fa6a8..6a61b37b5 100644 --- a/qt/src/GhosttySurface.h +++ b/qt/src/GhosttySurface.h @@ -7,6 +7,7 @@ #include "ghostty.h" class MainWindow; +struct wl_egl_window; // Wayland; opaque // One Ghostty terminal surface, rendered into a QWindow. The QWindow is // embedded into a MainWindow tab via QWidget::createWindowContainer. @@ -57,6 +58,9 @@ private: EGLDisplay m_eglDisplay = EGL_NO_DISPLAY; EGLContext m_eglContext = EGL_NO_CONTEXT; EGLSurface m_eglSurface = EGL_NO_SURFACE; + // Non-null only on Wayland: the EGL window surface is backed by a + // wl_egl_window rather than a native X11 window. + wl_egl_window *m_wlEglWindow = nullptr; ghostty_surface_t m_surface = nullptr; }; diff --git a/qt/src/main.cpp b/qt/src/main.cpp index fccbf7038..26d4414cb 100644 --- a/qt/src/main.cpp +++ b/qt/src/main.cpp @@ -6,8 +6,10 @@ #include "ghostty.h" int main(int argc, char **argv) { - // This scaffold uses the X11 window id for the EGL window surface, so - // it requires the xcb platform plugin (XWayland is fine). + // Default to xcb: the X11 path is stable. The Wayland-native path + // (GhosttySurface's wl_egl_window branch) is experimental — opt in + // with QT_QPA_PLATFORM=wayland. On a Wayland session xcb runs under + // XWayland. if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) qputenv("QT_QPA_PLATFORM", "xcb"); From 6e7f7f802a91bc20cf274456c6d96e64e7b0f796 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 12:38:33 -0500 Subject: [PATCH 15/75] renderer/opengl: draw on the app thread for the embedded apprt The embedded apprt now sets must_draw_from_app_thread for the OpenGL renderer, matching how the GTK apprt works: the host owns the GL context on its GUI thread and libghostty draws there, rather than on a dedicated renderer thread. This lets a host embed Ghostty with a toolkit-composited GL widget (e.g. QOpenGLWidget), which works on Wayland and X11 alike. The previous renderer-thread model required the host to hand libghostty a native drawable, which conflicts with Wayland's one-producer-per- surface model. Metal is unaffected (must_draw_from_app_thread is false for it) and keeps its renderer thread. threadEnter/threadExit/finalizeSurfaceInit for the OpenGL embedded path become no-ops: the renderer thread must not touch the GL context. Co-Authored-By: claude-flow --- src/apprt/embedded.zig | 7 ++++ src/renderer/OpenGL.zig | 72 +++++++++-------------------------------- 2 files changed, 22 insertions(+), 57 deletions(-) diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index c948ce610..97c769013 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -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. diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 9c1450e87..041baa64b 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -193,11 +193,10 @@ pub fn surfaceInit(surface: *apprt.Surface) !void { apprt.gtk, => try prepareContext(null), - // The renderer's `init` runs next, on this (the calling) thread, - // and creates GL objects — so the host context must be current - // and GL loaded here. `finalizeSurfaceInit` releases the context - // again before the renderer thread starts. glad's context is - // thread-local, so `threadEnter` re-loads it on that thread. + // 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); @@ -224,53 +223,23 @@ pub fn surfaceInit(surface: *apprt.Surface) !void { /// thread for final main thread setup requirements. pub fn finalizeSurfaceInit(self: *const OpenGL, surface: *apprt.Surface) !void { _ = self; - - switch (apprt.runtime) { - else => @compileError("unsupported app runtime for OpenGL"), - - // GTK keeps its GL context current on the app thread; there is - // nothing to finalize here. - apprt.gtk => {}, - - // The renderer's `init` has finished creating GL objects on this - // (the calling) thread. Release the host context so the renderer - // thread can make it current in `threadEnter`. - apprt.embedded => switch (surface.platform) { - .opengl => |host| host.release_current(host.userdata), - .macos, .ios => {}, - }, - } + _ = 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. pub fn threadEnter(self: *const OpenGL, surface: *apprt.Surface) !void { _ = self; + _ = surface; 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 => switch (surface.platform) { - .opengl => |host| { - // The host owns the GL context. Make it current on this - // (the renderer) thread — the host must never make it - // current on its own thread — then load the GL entry - // points via the host's loader. - host.make_current(host.userdata); - gl_host = host; - try prepareContext(&gladHostLoader); - }, - - // macOS and iOS use the Metal renderer; the OpenGL renderer - // must not be paired with those platforms. - .macos, .ios => return error.UnsupportedPlatform, - }, + // 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 => {}, } } @@ -281,20 +250,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 => { - // The renderer thread is exiting, so unload glad's - // thread-local context and release the host's GL context. - if (gl_host) |host| { - gl.glad.unload(); - host.release_current(host.userdata); - gl_host = null; - } - }, + // See threadEnter: the renderer thread does not own the GL + // context for either runtime. + apprt.gtk, apprt.embedded => {}, } } From dfdc555592b02fbfc8509f43d2664afb4f9c06c5 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 12:38:33 -0500 Subject: [PATCH 16/75] embed-test: draw on the main thread libghostty now draws on the app thread for the OpenGL renderer, so the harness services the render action by calling ghostty_surface_draw from its main loop rather than relying on a renderer thread. Co-Authored-By: claude-flow --- embed-test/main.c | 42 +++++++++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/embed-test/main.c b/embed-test/main.c index bec4561f9..84477822e 100644 --- a/embed-test/main.c +++ b/embed-test/main.c @@ -26,14 +26,17 @@ typedef struct { // The single surface, so GLFW input callbacks can reach it. static ghostty_surface_t g_surface = NULL; -// Count of presented frames, bumped from the renderer thread. A nonzero -// value confirms the OpenGL embedded render path is producing frames. +// Set when libghostty asks for a redraw; the main loop then draws. +static 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 ----------------------------- // -// These run on libghostty's renderer thread, NOT the main thread. The -// renderer thread owns the GL context for the surface's lifetime. +// 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; @@ -68,8 +71,12 @@ static bool on_action(ghostty_app_t app, ghostty_target_s target, ghostty_action_s action) { (void)app; (void)target; - (void)action; - // The harness ignores all actions (new window, title changes, ...). + // 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) { + g_needs_draw = 1; + return true; + } return false; } @@ -134,8 +141,10 @@ static void on_char(GLFWwindow *win, unsigned int cp) { static void on_framebuffer_size(GLFWwindow *win, int w, int h) { (void)win; - if (g_surface && w > 0 && h > 0) + if (g_surface && w > 0 && h > 0) { ghostty_surface_set_size(g_surface, (uint32_t)w, (uint32_t)h); + g_needs_draw = 1; + } } int main(int argc, char **argv) { @@ -163,9 +172,8 @@ int main(int argc, char **argv) { return 1; } - // The renderer thread owns the GL context. Release it from the main - // thread so libghostty's renderer thread can make it current. - glfwMakeContextCurrent(NULL); + // 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}; @@ -233,14 +241,22 @@ int main(int argc, char **argv) { "Close the window to exit.\n"); fflush(stdout); - // Main loop: pump GLFW events and tick libghostty. The renderer runs - // on its own thread and presents through our callbacks. + // 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)) + if (ghostty_surface_process_exited(surface)) { glfwSetWindowShouldClose(window, GLFW_TRUE); + break; + } + + // libghostty requested a draw (via the render action); service it + // on this thread. + if (g_needs_draw) { + g_needs_draw = 0; + ghostty_surface_draw(surface); + } // Report presented-frame count once per second. double now = glfwGetTime(); From 396977b4a4ca29dc05aa8ba1f827e37eb00cec95 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 12:38:33 -0500 Subject: [PATCH 17/75] qt: render with QOpenGLWidget for native Wayland support Replace the per-surface QWindow + raw EGL with QOpenGLWidget. Qt owns the GL context and composites the widgets; libghostty draws from paintGL on the GUI thread (must_draw_from_app_thread). This drops all the EGL and wl_egl_window code and the createWindowContainer subsurface embedding that tripped Wayland protocol errors. The app now runs natively on Wayland and X11 with no platform-specific code, and the rendering layer is considerably simpler (no renderer-thread context juggling). Co-Authored-By: claude-flow --- qt/CMakeLists.txt | 21 ++-- qt/src/GhosttySurface.cpp | 220 ++++++++------------------------------ qt/src/GhosttySurface.h | 47 ++++---- qt/src/MainWindow.cpp | 131 ++++++++--------------- qt/src/MainWindow.h | 9 +- qt/src/main.cpp | 18 ++-- 6 files changed, 129 insertions(+), 317 deletions(-) diff --git a/qt/CMakeLists.txt b/qt/CMakeLists.txt index b9e682aa1..c926479d7 100644 --- a/qt/CMakeLists.txt +++ b/qt/CMakeLists.txt @@ -2,8 +2,10 @@ cmake_minimum_required(VERSION 3.16) project(ghostty-qt LANGUAGES CXX) # A Qt6 frontend for Ghostty that embeds libghostty through the -# GHOSTTY_PLATFORM_OPENGL C API -- the same embedding model the macOS app -# uses with Metal. +# GHOSTTY_PLATFORM_OPENGL C API. Each terminal is a QOpenGLWidget; +# libghostty draws on the GUI thread (the embedded apprt sets +# must_draw_from_app_thread for the OpenGL renderer) and Qt composites +# the widgets, so this runs natively on both Wayland and X11. # # Build libghostty first, from the repo root: # @@ -13,21 +15,12 @@ project(ghostty-qt LANGUAGES CXX) # # cmake -S qt -B qt/build && cmake --build qt/build # ./qt/build/ghostty-qt -# -# Runs on X11/xcb (default; XWayland on a Wayland session). A native -# Wayland path exists (QT_QPA_PLATFORM=wayland) but is experimental: -# rendering our own EGL onto a Qt-owned wl_surface currently trips -# Wayland protocol errors. set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) -find_package(Qt6 REQUIRED COMPONENTS Gui Widgets GuiPrivate) -find_package(PkgConfig REQUIRED) -pkg_check_modules(EGL REQUIRED IMPORTED_TARGET egl) -# Wayland-native rendering needs wl_egl_window. -pkg_check_modules(WAYLAND_EGL REQUIRED IMPORTED_TARGET wayland-egl) +find_package(Qt6 REQUIRED COMPONENTS Gui Widgets OpenGLWidgets) # libghostty is built out-of-tree by Zig. get_filename_component(GHOSTTY_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/.." ABSOLUTE) @@ -55,10 +48,8 @@ target_include_directories(ghostty-qt PRIVATE "${GHOSTTY_ROOT}/include") target_link_libraries(ghostty-qt PRIVATE Qt6::Gui - Qt6::GuiPrivate # QPlatformNativeInterface, for the Wayland wl_surface Qt6::Widgets - PkgConfig::EGL - PkgConfig::WAYLAND_EGL + Qt6::OpenGLWidgets "${GHOSTTY_LIB_DIR}/libghostty.so" ) diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index 2f28afae6..dd5e7f176 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -1,74 +1,42 @@ #include "GhosttySurface.h" -#include #include #include -#include #include -#include #include #include -#include +#include #include -#include #include -#include -#include -#include -#include - -GhosttySurface::GhosttySurface(ghostty_app_t app, MainWindow *owner) - : m_app(app), m_owner(owner) { - setSurfaceType(QWindow::OpenGLSurface); - - // Guide the platform's visual selection toward a GL-capable, opaque - // config so the EGL window surface can be created against this window. - QSurfaceFormat fmt; - fmt.setRenderableType(QSurfaceFormat::OpenGL); - fmt.setProfile(QSurfaceFormat::CoreProfile); - fmt.setVersion(4, 3); - fmt.setRedBufferSize(8); - fmt.setGreenBufferSize(8); - fmt.setBlueBufferSize(8); - fmt.setAlphaBufferSize(0); - setFormat(fmt); +GhosttySurface::GhosttySurface(ghostty_app_t app, MainWindow *owner, + ghostty_surface_t parent_surface) + : m_app(app), m_owner(owner), m_parentSurface(parent_surface) { + setFocusPolicy(Qt::StrongFocus); + setMouseTracking(true); // deliver motion events for hover/link detection } GhosttySurface::~GhosttySurface() { - // Freeing the surface stops libghostty's renderer thread, which calls - // threadExit -> glReleaseCurrent before this returns. - if (m_surface) ghostty_surface_free(m_surface); - - // Destroy this surface's own EGL objects, but NOT the EGLDisplay: it - // is the process-wide default display shared by every surface, so - // calling eglTerminate here would invalidate the other surfaces' - // contexts. The display is released when the process exits. - if (m_eglDisplay != EGL_NO_DISPLAY) { - if (m_eglSurface != EGL_NO_SURFACE) - eglDestroySurface(m_eglDisplay, m_eglSurface); - if (m_eglContext != EGL_NO_CONTEXT) - eglDestroyContext(m_eglDisplay, m_eglContext); + if (m_surface) { + // The renderer releases GL objects during teardown, so do it with + // our context current. + makeCurrent(); + ghostty_surface_free(m_surface); + doneCurrent(); } - if (m_wlEglWindow) wl_egl_window_destroy(m_wlEglWindow); } -bool GhosttySurface::initialize(ghostty_surface_t parent) { - // Force native window creation so winId() is valid for EGL. - create(); +// --- QOpenGLWidget -------------------------------------------------- - if (!setupEgl()) { - std::fprintf(stderr, "[ghostty-qt] EGL setup failed\n"); - return false; - } - - // A new surface in a tab inherits the parent surface's working - // directory etc.; the first surface uses a default config. +void GhosttySurface::initializeGL() { + // The context is current. Create the libghostty surface now so the + // renderer's GL objects are created in this widget's context. ghostty_surface_config_s sc = - parent ? ghostty_surface_inherited_config(parent, - GHOSTTY_SURFACE_CONTEXT_TAB) - : ghostty_surface_config_new(); + m_parentSurface + ? ghostty_surface_inherited_config(m_parentSurface, + GHOSTTY_SURFACE_CONTEXT_TAB) + : ghostty_surface_config_new(); sc.platform_tag = GHOSTTY_PLATFORM_OPENGL; sc.platform.opengl.userdata = this; sc.platform.opengl.get_proc_address = glGetProcAddress; @@ -76,124 +44,37 @@ bool GhosttySurface::initialize(ghostty_surface_t parent) { sc.platform.opengl.release_current = glReleaseCurrent; sc.platform.opengl.present = glPresent; sc.userdata = this; - sc.scale_factor = devicePixelRatio(); + sc.scale_factor = devicePixelRatioF(); - // ghostty_surface_new runs the renderer's init synchronously on this - // (the GUI) thread: it makes our EGL context current, builds GL - // objects, then releases it again before spawning the renderer thread. m_surface = ghostty_surface_new(m_app, &sc); if (!m_surface) { std::fprintf(stderr, "[ghostty-qt] ghostty_surface_new failed\n"); - return false; + return; } - updateSize(); - ghostty_surface_set_focus(m_surface, true); - return true; + ghostty_surface_set_focus(m_surface, hasFocus()); } -bool GhosttySurface::setupEgl() { - const bool wayland = - QGuiApplication::platformName().contains(QLatin1String("wayland")); - - // --- EGL display ----------------------------------------------------- - if (wayland) { - // Use Qt's own Wayland connection so the EGL surface and the window - // share a wl_display. - auto *wl = - qGuiApp->nativeInterface(); - if (!wl || !wl->display()) return false; - m_eglDisplay = eglGetPlatformDisplay(EGL_PLATFORM_WAYLAND_KHR, - wl->display(), nullptr); - } else { - m_eglDisplay = eglGetDisplay(EGL_DEFAULT_DISPLAY); - } - if (m_eglDisplay == EGL_NO_DISPLAY) return false; - if (!eglInitialize(m_eglDisplay, nullptr, nullptr)) return false; - - // Ghostty's renderer uses desktop OpenGL, not GLES. - if (!eglBindAPI(EGL_OPENGL_API)) return false; - - const EGLint configAttribs[] = { - EGL_SURFACE_TYPE, EGL_WINDOW_BIT, - EGL_RENDERABLE_TYPE, EGL_OPENGL_BIT, - EGL_RED_SIZE, 8, - EGL_GREEN_SIZE, 8, - EGL_BLUE_SIZE, 8, - EGL_NONE, - }; - EGLConfig configs[64]; - EGLint numConfigs = 0; - if (!eglChooseConfig(m_eglDisplay, configAttribs, configs, 64, &numConfigs) || - numConfigs < 1) - return false; - - // EGL color-size attributes are minimums, so eglChooseConfig may still - // return alpha-bearing configs. Pick one with no alpha channel so the - // window surface is opaque. - EGLConfig config = configs[0]; - for (EGLint i = 0; i < numConfigs; ++i) { - EGLint alpha = 0; - eglGetConfigAttrib(m_eglDisplay, configs[i], EGL_ALPHA_SIZE, &alpha); - if (alpha == 0) { - config = configs[i]; - break; - } - } - - // Ghostty's OpenGL renderer requires at least OpenGL 4.3 core. - const EGLint contextAttribs[] = { - EGL_CONTEXT_MAJOR_VERSION, 4, - EGL_CONTEXT_MINOR_VERSION, 3, - EGL_CONTEXT_OPENGL_PROFILE_MASK, EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT, - EGL_NONE, - }; - m_eglContext = - eglCreateContext(m_eglDisplay, config, EGL_NO_CONTEXT, contextAttribs); - if (m_eglContext == EGL_NO_CONTEXT) return false; - - // --- EGL window surface --------------------------------------------- - if (wayland) { - // EGL needs a wl_egl_window built from this window's wl_surface. - auto *ni = QGuiApplication::platformNativeInterface(); - auto *wlSurface = static_cast( - ni ? ni->nativeResourceForWindow("surface", this) : nullptr); - if (!wlSurface) return false; - - const int w = std::max(1, static_cast(width() * devicePixelRatio())); - const int h = std::max(1, static_cast(height() * devicePixelRatio())); - m_wlEglWindow = wl_egl_window_create(wlSurface, w, h); - if (!m_wlEglWindow) return false; - - m_eglSurface = eglCreateWindowSurface( - m_eglDisplay, config, - reinterpret_cast(m_wlEglWindow), nullptr); - } else { - m_eglSurface = eglCreateWindowSurface( - m_eglDisplay, config, static_cast(winId()), - nullptr); - } - if (m_eglSurface == EGL_NO_SURFACE) return false; - - return true; +void GhosttySurface::paintGL() { + // libghostty renders into the framebuffer QOpenGLWidget has bound. + if (m_surface) ghostty_surface_draw(m_surface); } +void GhosttySurface::resizeGL(int, int) { updateSize(); } + void GhosttySurface::updateSize() { if (!m_surface) return; - const double dpr = devicePixelRatio(); + const double dpr = devicePixelRatioF(); const int w = static_cast(width() * dpr); const int h = static_cast(height() * dpr); - - // The Wayland EGL window does not track the QWindow size on its own. - if (m_wlEglWindow && w > 0 && h > 0) - wl_egl_window_resize(m_wlEglWindow, w, h, 0, 0); - ghostty_surface_set_content_scale(m_surface, dpr, dpr); if (w > 0 && h > 0) ghostty_surface_set_size(m_surface, static_cast(w), static_cast(h)); } +// --- input ---------------------------------------------------------- + // Translate Qt keyboard modifiers into libghostty's modifier bitfield. static ghostty_input_mods_e translateMods(Qt::KeyboardModifiers m) { int r = GHOSTTY_MODS_NONE; @@ -228,8 +109,8 @@ void GhosttySurface::sendKey(QKeyEvent *ev, ghostty_input_action_e action) { k.action = action; k.mods = translateMods(ev->modifiers()); k.consumed_mods = GHOSTTY_MODS_NONE; - // On the xcb platform nativeScanCode() is the X11/XKB keycode, which - // is exactly what libghostty expects as the native keycode on Linux. + // On xcb nativeScanCode() is the X11/XKB keycode; the Wayland plugin + // likewise reports the XKB keycode, which is libghostty's Linux native. k.keycode = ev->nativeScanCode(); k.text = printable ? text.constData() : nullptr; k.unshifted_codepoint = unshifted; @@ -252,18 +133,6 @@ void GhosttySurface::sendMouseButton(QMouseEvent *ev, translateMods(ev->modifiers())); } -// --- QWindow events -------------------------------------------------- - -void GhosttySurface::exposeEvent(QExposeEvent *) { - if (!m_surface || !isExposed()) return; - // devicePixelRatio() is only reliable once the window is on a screen, - // so (re)sync the surface size here as well as in resizeEvent. - updateSize(); - ghostty_surface_refresh(m_surface); -} - -void GhosttySurface::resizeEvent(QResizeEvent *) { updateSize(); } - void GhosttySurface::keyPressEvent(QKeyEvent *ev) { sendKey(ev, ev->isAutoRepeat() ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS); @@ -276,6 +145,7 @@ void GhosttySurface::keyReleaseEvent(QKeyEvent *ev) { } void GhosttySurface::mousePressEvent(QMouseEvent *ev) { + setFocus(); sendMouseButton(ev, GHOSTTY_MOUSE_PRESS); } @@ -285,7 +155,7 @@ void GhosttySurface::mouseReleaseEvent(QMouseEvent *ev) { void GhosttySurface::mouseMoveEvent(QMouseEvent *ev) { if (!m_surface) return; - const double dpr = devicePixelRatio(); + const double dpr = devicePixelRatioF(); ghostty_surface_mouse_pos(m_surface, ev->position().x() * dpr, ev->position().y() * dpr, translateMods(ev->modifiers())); @@ -306,25 +176,21 @@ void GhosttySurface::focusOutEvent(QFocusEvent *) { if (m_surface) ghostty_surface_set_focus(m_surface, false); } -// --- GL context callbacks (run on libghostty's renderer thread) ------ +// --- libghostty GL platform callbacks -------------------------------- void *GhosttySurface::glGetProcAddress(void *, const char *name) { - return reinterpret_cast(eglGetProcAddress(name)); + QOpenGLContext *ctx = QOpenGLContext::currentContext(); + return ctx ? reinterpret_cast(ctx->getProcAddress(name)) : nullptr; } void GhosttySurface::glMakeCurrent(void *ud) { - auto *self = static_cast(ud); - eglMakeCurrent(self->m_eglDisplay, self->m_eglSurface, self->m_eglSurface, - self->m_eglContext); + static_cast(ud)->makeCurrent(); } -void GhosttySurface::glReleaseCurrent(void *ud) { - auto *self = static_cast(ud); - eglMakeCurrent(self->m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, - EGL_NO_CONTEXT); +void GhosttySurface::glReleaseCurrent(void *) { + // No-op: QOpenGLWidget manages context currency around paintGL. } -void GhosttySurface::glPresent(void *ud) { - auto *self = static_cast(ud); - eglSwapBuffers(self->m_eglDisplay, self->m_eglSurface); +void GhosttySurface::glPresent(void *) { + // No-op: Qt composites the widget's framebuffer and swaps the window. } diff --git a/qt/src/GhosttySurface.h b/qt/src/GhosttySurface.h index 6a61b37b5..f4b6a1230 100644 --- a/qt/src/GhosttySurface.h +++ b/qt/src/GhosttySurface.h @@ -1,36 +1,35 @@ #pragma once -#include - -#include +#include #include "ghostty.h" class MainWindow; -struct wl_egl_window; // Wayland; opaque -// One Ghostty terminal surface, rendered into a QWindow. The QWindow is -// embedded into a MainWindow tab via QWidget::createWindowContainer. +// One Ghostty terminal surface, rendered with a QOpenGLWidget. // -// Rendering uses raw EGL rather than QOpenGLContext: libghostty's -// renderer thread, which makes the GL context current, is not a QThread. -class GhosttySurface : public QWindow { +// libghostty draws on the GUI thread — the embedded apprt sets +// must_draw_from_app_thread for the OpenGL renderer — so rendering is +// driven straight from paintGL. Qt owns the GL context and composites +// the widget, which works identically on X11 and Wayland. +class GhosttySurface : public QOpenGLWidget { Q_OBJECT public: - GhosttySurface(ghostty_app_t app, MainWindow *owner); + // `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; - // Create the EGL context and the libghostty surface. When `parent` is - // non-null the new surface inherits its working directory etc. - bool initialize(ghostty_surface_t parent); - ghostty_surface_t surface() const { return m_surface; } MainWindow *owner() const { return m_owner; } protected: - void exposeEvent(QExposeEvent *) override; - void resizeEvent(QResizeEvent *) override; + void initializeGL() override; + void paintGL() override; + void resizeGL(int w, int h) override; + void keyPressEvent(QKeyEvent *) override; void keyReleaseEvent(QKeyEvent *) override; void mousePressEvent(QMouseEvent *) override; @@ -41,26 +40,18 @@ protected: void focusOutEvent(QFocusEvent *) override; private: - bool setupEgl(); void updateSize(); void sendKey(QKeyEvent *, ghostty_input_action_e action); void sendMouseButton(QMouseEvent *, ghostty_input_mouse_state_e state); - // GL context callbacks (run on libghostty's renderer thread). + // 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 - - EGLDisplay m_eglDisplay = EGL_NO_DISPLAY; - EGLContext m_eglContext = EGL_NO_CONTEXT; - EGLSurface m_eglSurface = EGL_NO_SURFACE; - // Non-null only on Wayland: the EGL window surface is backed by a - // wl_egl_window rather than a native X11 window. - wl_egl_window *m_wlEglWindow = nullptr; - + 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; }; diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index 936be399b..1fccfb8bd 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -37,10 +37,10 @@ MainWindow::MainWindow() { } MainWindow::~MainWindow() { - // Surfaces must be destroyed (freeing their ghostty_surface_t and - // stopping their renderer threads) before the shared app is freed. - qDeleteAll(m_containers); - m_containers.clear(); + // Destroy the surfaces (freeing their ghostty_surface_t) before the + // shared app; Qt's own child cleanup runs after this body. + qDeleteAll(m_surfaces); + m_surfaces.clear(); if (m_app) ghostty_app_free(m_app); if (m_config) ghostty_config_free(m_config); } @@ -78,97 +78,70 @@ bool MainWindow::initialize() { } GhosttySurface *MainWindow::newTab(ghostty_surface_t parent) { - auto *surface = new GhosttySurface(m_app, this); - QWidget *container = QWidget::createWindowContainer(surface, nullptr); - container->setFocusPolicy(Qt::StrongFocus); + auto *surface = new GhosttySurface(m_app, this, parent); + m_surfaces.append(surface); // The tab page hosts the tab's split tree (initially one surface). auto *page = new QWidget(m_tabs); auto *pageLayout = new QVBoxLayout(page); pageLayout->setContentsMargins(0, 0, 0, 0); - pageLayout->addWidget(container); + pageLayout->addWidget(surface); const int index = m_tabs->addTab(page, QStringLiteral("Ghostty")); - m_containers.insert(surface, container); m_tabs->setCurrentIndex(index); - - if (!surface->initialize(parent)) { - m_containers.remove(surface); - m_tabs->removeTab(index); - delete page; // also destroys the container and its surface - return nullptr; - } - - surface->requestActivate(); + surface->setFocus(); return surface; } GhosttySurface *MainWindow::splitSurface( GhosttySurface *target, ghostty_action_split_direction_e dir) { - QWidget *container = m_containers.value(target); - if (!container) return nullptr; + if (!m_surfaces.contains(target)) return nullptr; const bool horizontal = dir == GHOSTTY_SPLIT_DIRECTION_RIGHT || dir == GHOSTTY_SPLIT_DIRECTION_LEFT; const bool newAfter = dir == GHOSTTY_SPLIT_DIRECTION_RIGHT || dir == GHOSTTY_SPLIT_DIRECTION_DOWN; - auto *surface = new GhosttySurface(m_app, this); - QWidget *newContainer = QWidget::createWindowContainer(surface, nullptr); - newContainer->setFocusPolicy(Qt::StrongFocus); - + auto *surface = new GhosttySurface(m_app, this, target->surface()); auto *splitter = new QSplitter(horizontal ? Qt::Horizontal : Qt::Vertical); splitter->setChildrenCollapsible(false); - // Insert `splitter` where `container` currently sits in the tree. - QWidget *parent = container->parentWidget(); + // Insert `splitter` where `target` currently sits in the tree. + QWidget *parent = target->parentWidget(); if (auto *parentSplitter = qobject_cast(parent)) { - parentSplitter->replaceWidget(parentSplitter->indexOf(container), - splitter); + parentSplitter->replaceWidget(parentSplitter->indexOf(target), splitter); } else if (parent && parent->layout()) { - delete parent->layout()->replaceWidget(container, splitter); + delete parent->layout()->replaceWidget(target, splitter); } else { delete splitter; - delete newContainer; // also destroys the new surface + delete surface; return nullptr; } - // `container` is now parentless; place it and the new pane in order. if (newAfter) { - splitter->addWidget(container); - splitter->addWidget(newContainer); + splitter->addWidget(target); + splitter->addWidget(surface); } else { - splitter->addWidget(newContainer); - splitter->addWidget(container); + splitter->addWidget(surface); + splitter->addWidget(target); } splitter->setSizes({1 << 20, 1 << 20}); // start the panes roughly equal - m_containers.insert(surface, newContainer); - - if (!surface->initialize(target->surface())) { - m_containers.remove(surface); - delete newContainer; // leaves a one-pane splitter; near-impossible path - return nullptr; - } - - surface->requestActivate(); + m_surfaces.append(surface); + surface->setFocus(); return surface; } void MainWindow::removeSurface(GhosttySurface *surface) { - const auto it = m_containers.find(surface); - if (it == m_containers.end()) return; - QWidget *container = it.value(); - m_containers.erase(it); - - QWidget *parent = container->parentWidget(); + if (!m_surfaces.removeOne(surface)) return; + QWidget *parent = surface->parentWidget(); if (auto *splitter = qobject_cast(parent)) { // One pane of a split: collapse the splitter into its sibling. QWidget *sibling = nullptr; for (int i = 0; i < splitter->count(); ++i) - if (splitter->widget(i) != container) sibling = splitter->widget(i); + if (splitter->widget(i) != surface) sibling = splitter->widget(i); QWidget *splitterParent = splitter->parentWidget(); if (auto *grand = qobject_cast(splitterParent)) { @@ -176,7 +149,7 @@ void MainWindow::removeSurface(GhosttySurface *surface) { } else if (splitterParent && splitterParent->layout()) { delete splitterParent->layout()->replaceWidget(splitter, sibling); } - // Deleting the now-orphaned splitter also deletes `container`. + // Deleting the orphaned splitter also deletes `surface`. splitter->deleteLater(); return; } @@ -184,22 +157,17 @@ void MainWindow::removeSurface(GhosttySurface *surface) { // Otherwise this surface is the whole tab. const int index = m_tabs->indexOf(parent); if (index >= 0) m_tabs->removeTab(index); - if (parent) parent->deleteLater(); // page; deletes the container too + if (parent) parent->deleteLater(); // page; destroys the surface too if (m_tabs->count() == 0) close(); } void MainWindow::closeTab(int index) { QWidget *page = m_tabs->widget(index); if (!page) return; - - // Drop every surface hosted anywhere inside this tab's split tree. - const auto surfaces = m_containers.keys(); - for (GhosttySurface *s : surfaces) { - QWidget *c = m_containers.value(s); - if (c && page->isAncestorOf(c)) m_containers.remove(s); - } + const auto inTab = page->findChildren(); + for (GhosttySurface *s : inTab) m_surfaces.removeOne(s); m_tabs->removeTab(index); - page->deleteLater(); // destroys all contained surfaces + page->deleteLater(); // destroys every surface in the tab if (m_tabs->count() == 0) close(); } @@ -217,7 +185,7 @@ void MainWindow::tick() { ghostty_app_tick(m_app); // Close any pane whose child process has exited. - const auto surfaces = m_containers.keys(); + const auto surfaces = m_surfaces; // copy; removeSurface mutates the list for (GhosttySurface *s : surfaces) { if (s->surface() && ghostty_surface_process_exited(s->surface())) removeSurface(s); @@ -229,33 +197,27 @@ void MainWindow::onTabCloseRequested(int index) { closeTab(index); } void MainWindow::onCurrentChanged(int index) { GhosttySurface *s = surfaceAt(index); if (!s) return; - s->requestActivate(); + s->setFocus(); setWindowTitle(m_tabs->tabText(index) + QStringLiteral(" — Ghostty")); } GhosttySurface *MainWindow::surfaceAt(int index) const { QWidget *page = m_tabs->widget(index); if (!page) return nullptr; - for (auto it = m_containers.cbegin(); it != m_containers.cend(); ++it) - if (page->isAncestorOf(it.value())) return it.key(); - return nullptr; + const auto surfaces = page->findChildren(); + return surfaces.isEmpty() ? nullptr : surfaces.first(); } int MainWindow::tabIndexForSurface(GhosttySurface *surface) const { - QWidget *container = m_containers.value(surface); - if (!container) return -1; for (int i = 0; i < m_tabs->count(); ++i) - if (m_tabs->widget(i)->isAncestorOf(container)) return i; + if (m_tabs->widget(i)->isAncestorOf(surface)) return i; return -1; } QList MainWindow::surfacesInTab(int index) const { - QList result; QWidget *page = m_tabs->widget(index); - if (!page) return result; - for (auto it = m_containers.cbegin(); it != m_containers.cend(); ++it) - if (page->isAncestorOf(it.value())) result.append(it.key()); - return result; + if (!page) return {}; + return page->findChildren(); } void MainWindow::gotoTab(ghostty_action_goto_tab_e tab) { @@ -287,10 +249,8 @@ void MainWindow::gotoSplit(GhosttySurface *from, QList panes = surfacesInTab(tab); if (panes.size() < 2) return; - // Global-coordinate center of a pane's container. - const auto centerOf = [this](GhosttySurface *s) { - QWidget *c = m_containers.value(s); - return QRect(c->mapToGlobal(QPoint(0, 0)), c->size()).center(); + const auto centerOf = [](GhosttySurface *s) { + return QRect(s->mapToGlobal(QPoint(0, 0)), s->size()).center(); }; GhosttySurface *target = nullptr; @@ -330,14 +290,12 @@ void MainWindow::gotoSplit(GhosttySurface *from, } } - if (target) target->requestActivate(); + if (target) target->setFocus(); } void MainWindow::resizeSplit(GhosttySurface *from, ghostty_action_resize_split_s rs) { - QWidget *container = m_containers.value(from); - if (!container) return; - auto *splitter = qobject_cast(container->parentWidget()); + auto *splitter = qobject_cast(from->parentWidget()); if (!splitter) return; const bool horizontal = splitter->orientation() == Qt::Horizontal; @@ -349,7 +307,7 @@ void MainWindow::resizeSplit(GhosttySurface *from, if (!axisMatches) return; QList sizes = splitter->sizes(); - const int idx = splitter->indexOf(container); + const int idx = splitter->indexOf(from); if (idx < 0 || sizes.size() < 2) return; const bool grow = rs.direction == GHOSTTY_RESIZE_SPLIT_RIGHT || @@ -395,9 +353,14 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, // Actions may be dispatched from non-GUI threads, so window-touching // work is marshalled onto the GUI thread. switch (action.tag) { + case GHOSTTY_ACTION_RENDER: + // libghostty wants a redraw; QOpenGLWidget::update schedules it. + if (src) + QMetaObject::invokeMethod(src, "update", Qt::QueuedConnection); + return true; + case GHOSTTY_ACTION_NEW_TAB: case GHOSTTY_ACTION_NEW_WINDOW: { - // This single-window app maps new windows to new tabs. ghostty_surface_t parent = src ? src->surface() : nullptr; QMetaObject::invokeMethod( self, [self, parent]() { self->newTab(parent); }, diff --git a/qt/src/MainWindow.h b/qt/src/MainWindow.h index d8db1c7e9..98457e293 100644 --- a/qt/src/MainWindow.h +++ b/qt/src/MainWindow.h @@ -1,6 +1,5 @@ #pragma once -#include #include #include @@ -13,8 +12,8 @@ class GhosttySurface; // terminal surfaces as tabs; each tab may be subdivided into splits. // // Widget tree: QTabWidget -> tab page (QWidget) -> split tree, where a -// node is either a surface container (QWidget::createWindowContainer) -// or a QSplitter of two such nodes. +// node is either a GhosttySurface (a QOpenGLWidget) or a QSplitter of +// two such nodes. class MainWindow : public QWidget { Q_OBJECT @@ -74,7 +73,5 @@ private: ghostty_config_t m_config = nullptr; ghostty_app_t m_app = nullptr; QTabWidget *m_tabs = nullptr; - - // Each surface mapped to the container widget that hosts it. - QHash m_containers; + QList m_surfaces; // every live surface }; diff --git a/qt/src/main.cpp b/qt/src/main.cpp index 26d4414cb..3c8d2e0a1 100644 --- a/qt/src/main.cpp +++ b/qt/src/main.cpp @@ -1,23 +1,27 @@ #include #include +#include #include "MainWindow.h" #include "ghostty.h" int main(int argc, char **argv) { - // Default to xcb: the X11 path is stable. The Wayland-native path - // (GhosttySurface's wl_egl_window branch) is experimental — opt in - // with QT_QPA_PLATFORM=wayland. On a Wayland session xcb runs under - // XWayland. - if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) - qputenv("QT_QPA_PLATFORM", "xcb"); - if (ghostty_init(static_cast(argc), argv) != GHOSTTY_SUCCESS) { std::fprintf(stderr, "[ghostty-qt] ghostty_init failed\n"); return 1; } + // Multiple QOpenGLWidgets 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); + QSurfaceFormat::setDefaultFormat(fmt); + QApplication app(argc, argv); MainWindow window; From 4d777da33a4c51711f8e01496c483ff9ee179366 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 12:53:42 -0500 Subject: [PATCH 18/75] qt: size the surface from the GL viewport, not devicePixelRatio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QOpenGLWidget sizes its framebuffer using the display's true scale, but devicePixelRatio() reports a rounded-up integer on a fractional- scale Wayland output (2.0 for a 1.2x display). Driving the libghostty surface size off width() * devicePixelRatio() made it render larger than the framebuffer, so only a corner of the render showed — the terminal looked zoomed in. Read the GL viewport in paintGL instead: QOpenGLWidget sets it to the framebuffer's true size. Content scale is the framebuffer-to-logical ratio. Also request fractional-scale pass-through. Co-Authored-By: claude-flow --- qt/src/GhosttySurface.cpp | 42 +++++++++++++++++++++++++++++---------- qt/src/GhosttySurface.h | 6 +++++- qt/src/main.cpp | 6 ++++++ 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index dd5e7f176..168aaf7d7 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -51,26 +52,45 @@ void GhosttySurface::initializeGL() { std::fprintf(stderr, "[ghostty-qt] ghostty_surface_new failed\n"); return; } - updateSize(); ghostty_surface_set_focus(m_surface, hasFocus()); } void GhosttySurface::paintGL() { // libghostty renders into the framebuffer QOpenGLWidget has bound. - if (m_surface) ghostty_surface_draw(m_surface); + if (!m_surface) return; + syncSize(); + ghostty_surface_draw(m_surface); } -void GhosttySurface::resizeGL(int, int) { updateSize(); } +void GhosttySurface::resizeGL(int, int) { + // The framebuffer was resized; request a repaint. paintGL reads the + // GL viewport, which is the authoritative framebuffer size. + update(); +} -void GhosttySurface::updateSize() { +void GhosttySurface::syncSize() { if (!m_surface) return; - const double dpr = devicePixelRatioF(); - const int w = static_cast(width() * dpr); - const int h = static_cast(height() * dpr); - ghostty_surface_set_content_scale(m_surface, dpr, dpr); - if (w > 0 && h > 0) - ghostty_surface_set_size(m_surface, static_cast(w), - static_cast(h)); + + // QOpenGLWidget sets the GL viewport to its framebuffer's true size + // before paintGL. That is the size libghostty must render into — it + // is NOT width() * devicePixelRatio(): on a fractional-scale Wayland + // output the framebuffer uses the fractional scale, while + // devicePixelRatio() reports a rounded-up integer. + int vp[4] = {0, 0, 0, 0}; + QOpenGLContext::currentContext()->functions()->glGetIntegerv(0x0BA2, vp); + const int fbw = vp[2], fbh = vp[3]; + if (fbw <= 0 || fbh <= 0) return; + if (fbw == m_lastW && fbh == m_lastH) return; + m_lastW = fbw; + m_lastH = fbh; + + // Content scale is the framebuffer-to-logical ratio (the real + // display scale), so libghostty sizes the font correctly. + const double sx = width() > 0 ? static_cast(fbw) / width() : 1.0; + const double sy = height() > 0 ? static_cast(fbh) / height() : 1.0; + ghostty_surface_set_content_scale(m_surface, sx, sy); + ghostty_surface_set_size(m_surface, static_cast(fbw), + static_cast(fbh)); } // --- input ---------------------------------------------------------- diff --git a/qt/src/GhosttySurface.h b/qt/src/GhosttySurface.h index f4b6a1230..9a4a4af69 100644 --- a/qt/src/GhosttySurface.h +++ b/qt/src/GhosttySurface.h @@ -40,7 +40,7 @@ protected: void focusOutEvent(QFocusEvent *) override; private: - void updateSize(); + void syncSize(); void sendKey(QKeyEvent *, ghostty_input_action_e action); void sendMouseButton(QMouseEvent *, ghostty_input_mouse_state_e state); @@ -54,4 +54,8 @@ private: MainWindow *m_owner; // not owned ghostty_surface_t m_parentSurface; // inherited-config source; may be null ghostty_surface_t m_surface = nullptr; + + // Last framebuffer size pushed to libghostty, to skip redundant work. + int m_lastW = 0; + int m_lastH = 0; }; diff --git a/qt/src/main.cpp b/qt/src/main.cpp index 3c8d2e0a1..f7483f36e 100644 --- a/qt/src/main.cpp +++ b/qt/src/main.cpp @@ -12,6 +12,12 @@ int main(int argc, char **argv) { return 1; } + // 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 QOpenGLWidgets compose reliably with a shared GL context. QApplication::setAttribute(Qt::AA_ShareOpenGLContexts); From f76f0284dbdbc028135e32d55649ecdf127ff334 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 13:06:08 -0500 Subject: [PATCH 19/75] qt: support a translucent terminal background libghostty bakes background-opacity into the rendered pixels' alpha (renderer/generic.zig). Preserve it through Qt: give the surface format an alpha channel and mark the window and tab pages WA_TranslucentBackground so the alpha is composited rather than flattened onto an opaque background. Co-Authored-By: claude-flow --- qt/src/MainWindow.cpp | 3 +++ qt/src/main.cpp | 1 + 2 files changed, 4 insertions(+) diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index 1fccfb8bd..faf0f5b75 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -20,6 +20,8 @@ MainWindow::MainWindow() { setWindowTitle(QStringLiteral("Ghostty (Qt)")); + // Let a translucent terminal background show through to the desktop. + setAttribute(Qt::WA_TranslucentBackground); m_tabs = new QTabWidget(this); m_tabs->setTabsClosable(true); @@ -83,6 +85,7 @@ GhosttySurface *MainWindow::newTab(ghostty_surface_t parent) { // The tab page hosts the tab's split tree (initially one surface). auto *page = new QWidget(m_tabs); + page->setAttribute(Qt::WA_TranslucentBackground); auto *pageLayout = new QVBoxLayout(page); pageLayout->setContentsMargins(0, 0, 0, 0); pageLayout->addWidget(surface); diff --git a/qt/src/main.cpp b/qt/src/main.cpp index f7483f36e..336872b63 100644 --- a/qt/src/main.cpp +++ b/qt/src/main.cpp @@ -26,6 +26,7 @@ int main(int argc, char **argv) { 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); From 7b2a85c16d12da0688d27d634aec52e04e51914d Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 13:10:40 -0500 Subject: [PATCH 20/75] qt: auto-hide the tab bar for single-tab windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single-tab window now hides the tab bar, giving the terminal the full window height — this matches the GTK frontend and removes the one-row grid difference observed against it. Also zero the tab widget's content margins. Co-Authored-By: claude-flow --- qt/src/MainWindow.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index faf0f5b75..6e4f67b5a 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -27,6 +27,10 @@ MainWindow::MainWindow() { m_tabs->setTabsClosable(true); m_tabs->setMovable(true); m_tabs->setDocumentMode(true); + // Hide the tab bar with a single tab so the terminal gets the full + // window height (matching the GTK frontend). + m_tabs->setTabBarAutoHide(true); + m_tabs->setContentsMargins(0, 0, 0, 0); auto *layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); From c54831018fab64e72db8b68d3db070fabfff5af0 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 13:20:00 -0500 Subject: [PATCH 21/75] qt: composite the terminal framebuffer's alpha The QOpenGLWidget itself needs WA_TranslucentBackground for Qt to blend its framebuffer's alpha into the window. Setting the attribute only on the parent widgets left the terminal painting opaque while the surrounding chrome turned translucent. Co-Authored-By: claude-flow --- qt/src/GhosttySurface.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index 168aaf7d7..00fea5be3 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -16,6 +16,10 @@ GhosttySurface::GhosttySurface(ghostty_app_t app, MainWindow *owner, : m_app(app), m_owner(owner), m_parentSurface(parent_surface) { setFocusPolicy(Qt::StrongFocus); setMouseTracking(true); // deliver motion events for hover/link detection + // Composite the framebuffer's alpha (libghostty bakes background-opacity + // into it) instead of painting the widget opaque. + setAttribute(Qt::WA_TranslucentBackground); + setAttribute(Qt::WA_AlwaysStackOnTop); } GhosttySurface::~GhosttySurface() { From 75c4bc7750070fc68e1af8332a90605b44867bdc Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 13:26:51 -0500 Subject: [PATCH 22/75] qt: extend the translucent-background chain to the tab widget The QTabWidget and its internal QStackedWidget paint an opaque background, flattening the terminal's alpha even though the window, tab pages and surface widget were already translucent. Mark them translucent too so libghostty's background-opacity reaches the desktop. Co-Authored-By: claude-flow --- qt/src/MainWindow.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index 6e4f67b5a..8e0fd1dd7 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -31,6 +32,12 @@ MainWindow::MainWindow() { // window height (matching the GTK frontend). m_tabs->setTabBarAutoHide(true); m_tabs->setContentsMargins(0, 0, 0, 0); + // Keep the whole chain translucent so the terminal's background + // opacity reaches the desktop: the QTabWidget and its internal + // stacked widget otherwise paint an opaque background. + m_tabs->setAttribute(Qt::WA_TranslucentBackground); + if (auto *stack = m_tabs->findChild()) + stack->setAttribute(Qt::WA_TranslucentBackground); auto *layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); From 2928242915e2a641cb14f63551de43093b5b99c7 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 13:36:28 -0500 Subject: [PATCH 23/75] qt: clear WA_NoSystemBackground so the terminal is translucent Setting WA_TranslucentBackground implicitly sets WA_NoSystemBackground. For a QOpenGLWidget the latter must be cleared again, otherwise its framebuffer's alpha (where libghostty bakes in background-opacity) is not composited through to the translucent window. This is the documented QOpenGLWidget translucency requirement. Co-Authored-By: claude-flow --- qt/src/GhosttySurface.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index 00fea5be3..58ee5539c 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -17,9 +17,11 @@ GhosttySurface::GhosttySurface(ghostty_app_t app, MainWindow *owner, setFocusPolicy(Qt::StrongFocus); setMouseTracking(true); // deliver motion events for hover/link detection // Composite the framebuffer's alpha (libghostty bakes background-opacity - // into it) instead of painting the widget opaque. + // into it) instead of painting the widget opaque. WA_TranslucentBackground + // also sets WA_NoSystemBackground; a QOpenGLWidget needs the latter + // cleared for its framebuffer alpha to reach the window. setAttribute(Qt::WA_TranslucentBackground); - setAttribute(Qt::WA_AlwaysStackOnTop); + setAttribute(Qt::WA_NoSystemBackground, false); } GhosttySurface::~GhosttySurface() { From f7a161ad9e241d0d42ced4b895bc98e5046ca3f6 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 17:21:30 -0500 Subject: [PATCH 24/75] qt: render the terminal translucent via offscreen FBO readback The terminal background never composited transparently: a QOpenGLWidget flattens its framebuffer into the window's opaque raster backing store, and an embedded QOpenGLWindow does not present on Wayland at all. Render libghostty into an offscreen QOpenGLFramebufferObject (a private QOpenGLContext, no on-screen GL surface) instead, read each frame back into a QImage, and paint it with QPainter. GhosttySurface is now a plain translucent QWidget, so its background composites to the desktop like any other widget and the tab/split tree is unaffected. Also: - main: run ghostty_init after QApplication, which strips its own argv options in place (libghostty later rescans argv and would walk off the end) -- fixes a crash on any Qt flag such as -style. - main: force the Fusion style; KDE Breeze unconditionally blurs any translucent window, masking the real transparency. - premultiply the framebuffer when a custom shader is configured, since Shadertoy-style shaders emit straight rather than premultiplied alpha. Co-Authored-By: claude-flow --- qt/CMakeLists.txt | 15 +-- qt/src/GhosttySurface.cpp | 211 +++++++++++++++++++++++++++++--------- qt/src/GhosttySurface.h | 56 +++++++--- qt/src/MainWindow.cpp | 28 ++++- qt/src/MainWindow.h | 6 ++ qt/src/main.cpp | 25 +++-- 6 files changed, 264 insertions(+), 77 deletions(-) diff --git a/qt/CMakeLists.txt b/qt/CMakeLists.txt index c926479d7..7b39b0e0d 100644 --- a/qt/CMakeLists.txt +++ b/qt/CMakeLists.txt @@ -2,10 +2,13 @@ cmake_minimum_required(VERSION 3.16) project(ghostty-qt LANGUAGES CXX) # A Qt6 frontend for Ghostty that embeds libghostty through the -# GHOSTTY_PLATFORM_OPENGL C API. Each terminal is a QOpenGLWidget; -# libghostty draws on the GUI thread (the embedded apprt sets -# must_draw_from_app_thread for the OpenGL renderer) and Qt composites -# the widgets, so this runs natively on both Wayland and X11. +# 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: # @@ -20,7 +23,7 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) -find_package(Qt6 REQUIRED COMPONENTS Gui Widgets OpenGLWidgets) +find_package(Qt6 REQUIRED COMPONENTS Gui Widgets OpenGL) # libghostty is built out-of-tree by Zig. get_filename_component(GHOSTTY_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/.." ABSOLUTE) @@ -49,7 +52,7 @@ target_include_directories(ghostty-qt PRIVATE "${GHOSTTY_ROOT}/include") target_link_libraries(ghostty-qt PRIVATE Qt6::Gui Qt6::Widgets - Qt6::OpenGLWidgets + Qt6::OpenGL "${GHOSTTY_LIB_DIR}/libghostty.so" ) diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index 58ee5539c..df3ca7dfb 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -1,14 +1,24 @@ #include "GhosttySurface.h" +#include "MainWindow.h" + +#include #include #include #include #include #include +#include #include +#include #include +#include +#include +#include +#include #include +#include #include GhosttySurface::GhosttySurface(ghostty_app_t app, MainWindow *owner, @@ -16,29 +26,34 @@ GhosttySurface::GhosttySurface(ghostty_app_t app, MainWindow *owner, : m_app(app), m_owner(owner), m_parentSurface(parent_surface) { setFocusPolicy(Qt::StrongFocus); setMouseTracking(true); // deliver motion events for hover/link detection - // Composite the framebuffer's alpha (libghostty bakes background-opacity - // into it) instead of painting the widget opaque. WA_TranslucentBackground - // also sets WA_NoSystemBackground; a QOpenGLWidget needs the latter - // cleared for its framebuffer alpha to reach the window. + // The widget paints a per-pixel-alpha QImage of the terminal; a + // translucent background lets that alpha reach the desktop. setAttribute(Qt::WA_TranslucentBackground); - setAttribute(Qt::WA_NoSystemBackground, false); -} -GhosttySurface::~GhosttySurface() { - if (m_surface) { - // The renderer releases GL objects during teardown, so do it with - // our context current. - makeCurrent(); - ghostty_surface_free(m_surface); - doneCurrent(); + // A private OpenGL context for libghostty's renderer. It is never made + // current on a window — rendering goes to an offscreen framebuffer — + // so an unparented QOffscreenSurface is enough to satisfy makeCurrent. + m_context = new QOpenGLContext(this); + m_context->setFormat(QSurfaceFormat::defaultFormat()); + if (!m_context->create()) { + std::fprintf(stderr, "[ghostty-qt] GL context creation failed\n"); + return; } -} + m_offscreen = new QOffscreenSurface(nullptr, this); + m_offscreen->setFormat(m_context->format()); + m_offscreen->create(); -// --- QOpenGLWidget -------------------------------------------------- + if (!makeCurrent()) { + std::fprintf(stderr, "[ghostty-qt] makeCurrent failed\n"); + return; + } + + // A placeholder framebuffer; resizeEvent installs the real size. + QOpenGLFramebufferObjectFormat fmt; + fmt.setInternalTextureFormat(GL_RGBA8); + m_fbw = m_fbh = 16; + m_fbo = new QOpenGLFramebufferObject(QSize(m_fbw, m_fbh), fmt); -void GhosttySurface::initializeGL() { - // The context is current. Create the libghostty surface now so the - // renderer's GL objects are created in this widget's context. ghostty_surface_config_s sc = m_parentSurface ? ghostty_surface_inherited_config(m_parentSurface, @@ -58,45 +73,143 @@ void GhosttySurface::initializeGL() { std::fprintf(stderr, "[ghostty-qt] ghostty_surface_new failed\n"); return; } - ghostty_surface_set_focus(m_surface, hasFocus()); + + if (m_owner->needsPremultiply()) initPremultiply(); } -void GhosttySurface::paintGL() { - // libghostty renders into the framebuffer QOpenGLWidget has bound. +GhosttySurface::~GhosttySurface() { + // Release GL-owning objects with the context current. + if (makeCurrent()) { + if (m_surface) ghostty_surface_free(m_surface); + delete m_fbo; + delete m_premultProg; + delete m_premultVao; + m_context->doneCurrent(); + } +} + +bool GhosttySurface::makeCurrent() { + return m_context && m_offscreen && m_offscreen->isValid() && + m_context->makeCurrent(m_offscreen); +} + +// --- rendering ------------------------------------------------------ + +void GhosttySurface::resizeEvent(QResizeEvent *) { if (!m_surface) return; - syncSize(); - ghostty_surface_draw(m_surface); + + // Render at the display's device-pixel resolution. devicePixelRatioF() + // is the true (possibly fractional) scale because main() selects the + // PassThrough rounding policy. + const double dpr = devicePixelRatioF(); + const int w = std::max(1, static_cast(width() * dpr)); + const int h = std::max(1, static_cast(height() * dpr)); + if (w == m_fbw && h == m_fbh) return; + m_fbw = w; + m_fbh = h; + + if (!makeCurrent()) return; + delete m_fbo; + QOpenGLFramebufferObjectFormat fmt; + fmt.setInternalTextureFormat(GL_RGBA8); + m_fbo = new QOpenGLFramebufferObject(QSize(w, h), fmt); + + ghostty_surface_set_content_scale(m_surface, dpr, dpr); + ghostty_surface_set_size(m_surface, static_cast(w), + static_cast(h)); + renderTerminal(); } -void GhosttySurface::resizeGL(int, int) { - // The framebuffer was resized; request a repaint. paintGL reads the - // GL viewport, which is the authoritative framebuffer size. +void GhosttySurface::requestRender() { renderTerminal(); } + +void GhosttySurface::renderTerminal() { + if (!m_surface || !m_fbo || !makeCurrent()) return; + + // libghostty renders into its own target and blits the result to the + // currently bound framebuffer — bind ours so we get the final image. + m_fbo->bind(); + m_context->functions()->glViewport(0, 0, m_fbw, m_fbh); + ghostty_surface_draw(m_surface); + premultiplyFramebuffer(); + + // Read the frame back as a premultiplied, top-down QImage. paintEvent + // scales it to the widget, so its device pixel ratio is irrelevant. + m_image = m_fbo->toImage(); + m_fbo->release(); + update(); } -void GhosttySurface::syncSize() { - if (!m_surface) return; +void GhosttySurface::paintEvent(QPaintEvent *) { + if (m_image.isNull()) return; + QPainter painter(this); + // Scale the framebuffer image to fill the widget. The QRect overload + // is required: drawImage(0, 0, img) would select the int-coordinate + // overload, which blits at raw pixel size and ignores both the + // widget's logical size and the device pixel ratio (a 2x zoom on a + // HiDPI display). + painter.setRenderHint(QPainter::SmoothPixmapTransform); + // Replace the (transparent) widget pixels with the terminal image, + // alpha included, so the background's translucency is preserved. + painter.setCompositionMode(QPainter::CompositionMode_Source); + painter.drawImage(rect(), m_image); +} - // QOpenGLWidget sets the GL viewport to its framebuffer's true size - // before paintGL. That is the size libghostty must render into — it - // is NOT width() * devicePixelRatio(): on a fractional-scale Wayland - // output the framebuffer uses the fractional scale, while - // devicePixelRatio() reports a rounded-up integer. - int vp[4] = {0, 0, 0, 0}; - QOpenGLContext::currentContext()->functions()->glGetIntegerv(0x0BA2, vp); - const int fbw = vp[2], fbh = vp[3]; - if (fbw <= 0 || fbh <= 0) return; - if (fbw == m_lastW && fbh == m_lastH) return; - m_lastW = fbw; - m_lastH = fbh; +// libghostty's renderer outputs premultiplied alpha — except a custom +// shader runs as a final Shadertoy-style pass and those conventionally +// emit *straight* alpha (RGB not scaled by alpha). QPainter and the +// compositor expect premultiplied, so a straight framebuffer renders the +// terminal color at full strength and reads as opaque. Fix it by +// premultiplying the framebuffer in place before reading it back. +// +// This runs only when a custom shader is configured: without one the +// renderer's output is already premultiplied and a second pass would +// wrongly darken the background. +void GhosttySurface::initPremultiply() { + m_premultVao = new QOpenGLVertexArrayObject(this); + m_premultVao->create(); - // Content scale is the framebuffer-to-logical ratio (the real - // display scale), so libghostty sizes the font correctly. - const double sx = width() > 0 ? static_cast(fbw) / width() : 1.0; - const double sy = height() > 0 ? static_cast(fbh) / height() : 1.0; - ghostty_surface_set_content_scale(m_surface, sx, sy); - ghostty_surface_set_size(m_surface, static_cast(fbw), - static_cast(fbh)); + m_premultProg = new QOpenGLShaderProgram(this); + // A single oversized triangle covering the viewport; positions are + // derived from gl_VertexID so no vertex buffer is needed. + m_premultProg->addShaderFromSourceCode(QOpenGLShader::Vertex, + R"(#version 330 core +void main() { + vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); +})"); + // The fragment color is irrelevant: the blend below uses a source + // factor of zero, so only the destination framebuffer and its alpha + // matter. + m_premultProg->addShaderFromSourceCode(QOpenGLShader::Fragment, + R"(#version 330 core +out vec4 fragColor; +void main() { fragColor = vec4(1.0); } +)"); + m_premultProg->link(); +} + +void GhosttySurface::premultiplyFramebuffer() { + if (!m_premultProg || !m_premultProg->isLinked()) return; + auto *f = m_context->functions(); + + // result.rgb = src.rgb*0 + dst.rgb*dst.a ; alpha left untouched by the + // color mask. This multiplies every pixel's RGB by its own alpha. + f->glViewport(0, 0, m_fbw, m_fbh); + f->glDisable(GL_SCISSOR_TEST); + f->glDisable(GL_DEPTH_TEST); + f->glEnable(GL_BLEND); + f->glBlendFuncSeparate(GL_ZERO, GL_DST_ALPHA, GL_ZERO, GL_ONE); + f->glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_FALSE); + + m_premultVao->bind(); + m_premultProg->bind(); + f->glDrawArrays(GL_TRIANGLES, 0, 3); + m_premultProg->release(); + m_premultVao->release(); + + f->glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + f->glDisable(GL_BLEND); } // --- input ---------------------------------------------------------- @@ -214,9 +327,9 @@ void GhosttySurface::glMakeCurrent(void *ud) { } void GhosttySurface::glReleaseCurrent(void *) { - // No-op: QOpenGLWidget manages context currency around paintGL. + // No-op: renderTerminal makes the context current around each frame. } void GhosttySurface::glPresent(void *) { - // No-op: Qt composites the widget's framebuffer and swaps the window. + // No-op: the frame is read back from the framebuffer, not swapped. } diff --git a/qt/src/GhosttySurface.h b/qt/src/GhosttySurface.h index 9a4a4af69..9adddc844 100644 --- a/qt/src/GhosttySurface.h +++ b/qt/src/GhosttySurface.h @@ -1,18 +1,28 @@ #pragma once -#include +#include +#include #include "ghostty.h" class MainWindow; +class QOffscreenSurface; +class QOpenGLContext; +class QOpenGLFramebufferObject; +class QOpenGLShaderProgram; +class QOpenGLVertexArrayObject; -// One Ghostty terminal surface, rendered with a QOpenGLWidget. +// One Ghostty terminal pane. // -// libghostty draws on the GUI thread — the embedded apprt sets -// must_draw_from_app_thread for the OpenGL renderer — so rendering is -// driven straight from paintGL. Qt owns the GL context and composites -// the widget, which works identically on X11 and Wayland. -class GhosttySurface : public QOpenGLWidget { +// 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: @@ -25,10 +35,13 @@ public: ghostty_surface_t surface() const { return m_surface; } MainWindow *owner() const { return m_owner; } +public slots: + // Render a fresh frame (the libghostty RENDER action). + void requestRender(); + protected: - void initializeGL() override; - void paintGL() override; - void resizeGL(int w, int h) override; + void paintEvent(QPaintEvent *) override; + void resizeEvent(QResizeEvent *) override; void keyPressEvent(QKeyEvent *) override; void keyReleaseEvent(QKeyEvent *) override; @@ -40,10 +53,16 @@ protected: void focusOutEvent(QFocusEvent *) override; private: - void syncSize(); + bool makeCurrent(); + void renderTerminal(); void sendKey(QKeyEvent *, ghostty_input_action_e action); void sendMouseButton(QMouseEvent *, ghostty_input_mouse_state_e state); + // 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); @@ -55,7 +74,16 @@ private: ghostty_surface_t m_parentSurface; // inherited-config source; may be null ghostty_surface_t m_surface = nullptr; - // Last framebuffer size pushed to libghostty, to skip redundant work. - int m_lastW = 0; - int m_lastH = 0; + // 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; }; diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index 8e0fd1dd7..b20108e7e 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -6,6 +6,8 @@ #include #include +#include +#include #include #include #include @@ -58,6 +60,26 @@ MainWindow::~MainWindow() { if (m_config) ghostty_config_free(m_config); } +// Whether the Ghostty config enables a custom shader. libghostty does +// not expose this through ghostty_config_get (`custom-shader` is a +// repeatable path), so scan the primary config file directly. +static bool configHasCustomShader() { + QString dir = qEnvironmentVariable("XDG_CONFIG_HOME"); + if (dir.isEmpty()) dir = QDir::homePath() + QStringLiteral("/.config"); + + QFile f(dir + QStringLiteral("/ghostty/config")); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) return false; + + while (!f.atEnd()) { + const QByteArray line = f.readLine().trimmed(); + if (!line.startsWith("custom-shader")) continue; + // Require a non-empty value: `custom-shader =` alone clears it. + const int eq = line.indexOf('='); + if (eq >= 0 && !line.mid(eq + 1).trimmed().isEmpty()) return true; + } + return false; +} + bool MainWindow::initialize() { // Load configuration in the same order as the reference apprt. m_config = ghostty_config_new(); @@ -66,6 +88,8 @@ bool MainWindow::initialize() { ghostty_config_load_recursive_files(m_config); ghostty_config_finalize(m_config); + m_needsPremultiply = configHasCustomShader(); + ghostty_runtime_config_s rt = {}; rt.userdata = this; rt.supports_selection_clipboard = true; @@ -368,9 +392,9 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, // work is marshalled onto the GUI thread. switch (action.tag) { case GHOSTTY_ACTION_RENDER: - // libghostty wants a redraw; QOpenGLWidget::update schedules it. + // libghostty wants a redraw; schedule one on the terminal window. if (src) - QMetaObject::invokeMethod(src, "update", Qt::QueuedConnection); + QMetaObject::invokeMethod(src, "requestRender", Qt::QueuedConnection); return true; case GHOSTTY_ACTION_NEW_TAB: diff --git a/qt/src/MainWindow.h b/qt/src/MainWindow.h index 98457e293..38a2ed834 100644 --- a/qt/src/MainWindow.h +++ b/qt/src/MainWindow.h @@ -39,6 +39,11 @@ public: // Update the tab label and window title for `surface`. void setSurfaceTitle(GhosttySurface *surface, const QString &title); + // 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 m_needsPremultiply; } + public slots: void tick(); @@ -74,4 +79,5 @@ private: ghostty_app_t m_app = nullptr; QTabWidget *m_tabs = nullptr; QList m_surfaces; // every live surface + bool m_needsPremultiply = false; // a custom shader is configured }; diff --git a/qt/src/main.cpp b/qt/src/main.cpp index 336872b63..1664e48a3 100644 --- a/qt/src/main.cpp +++ b/qt/src/main.cpp @@ -7,18 +7,13 @@ #include "ghostty.h" int main(int argc, char **argv) { - if (ghostty_init(static_cast(argc), argv) != GHOSTTY_SUCCESS) { - std::fprintf(stderr, "[ghostty-qt] ghostty_init failed\n"); - return 1; - } - // 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 QOpenGLWidgets compose reliably with a shared GL context. + // 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. @@ -31,6 +26,24 @@ int main(int argc, char **argv) { QApplication app(argc, argv); + // Use the Fusion style rather than KDE's Breeze. Breeze unconditionally + // applies a blur-behind (frosted-glass) effect to any translucent + // window — which our terminal is — and offers no way to opt out. That + // blur masks the real background transparency; Fusion has no such + // behaviour. The widget style is otherwise nearly invisible here (the + // terminal is GL-rendered; the only Qt chrome is an auto-hidden tab + // bar), so this costs nothing visible. + QApplication::setStyle(QStringLiteral("Fusion")); + + // 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(argc), argv) != GHOSTTY_SUCCESS) { + std::fprintf(stderr, "[ghostty-qt] ghostty_init failed\n"); + return 1; + } + MainWindow window; if (!window.initialize()) { std::fprintf(stderr, "[ghostty-qt] window initialization failed\n"); From 707e11570993a18b24cf5e16198b812753dbc121 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 18:02:27 -0500 Subject: [PATCH 25/75] qt: keep the system style; confine translucency to the terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous `setStyle("Fusion")` forced off the user's Qt style to avoid a frosted-glass blur over the translucent terminal. That blur is not a Breeze behaviour — it comes from theme engines such as Kvantum (`blurring` / `reduce_window_opacity` in the .kvconfig), which provide a per-app opt-out (their `opaque` app list). Drop the Fusion override and keep whatever style the system provides. Also confine WA_TranslucentBackground to the top-level window and the GhosttySurface widgets. Making the whole widget chain translucent left the tab bar see-through; the QTabWidget now paints an opaque background (autoFillBackground) so the tab bar renders as a solid, styled bar. The terminal still composites translucent because GhosttySurface blits with CompositionMode_Source, overwriting the opaque chrome painted behind it. Co-Authored-By: claude-flow --- qt/src/MainWindow.cpp | 18 ++++++++++-------- qt/src/main.cpp | 14 ++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index b20108e7e..96730b08c 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -13,7 +13,6 @@ #include #include #include -#include #include #include #include @@ -34,12 +33,15 @@ MainWindow::MainWindow() { // window height (matching the GTK frontend). m_tabs->setTabBarAutoHide(true); m_tabs->setContentsMargins(0, 0, 0, 0); - // Keep the whole chain translucent so the terminal's background - // opacity reaches the desktop: the QTabWidget and its internal - // stacked widget otherwise paint an opaque background. - m_tabs->setAttribute(Qt::WA_TranslucentBackground); - if (auto *stack = m_tabs->findChild()) - stack->setAttribute(Qt::WA_TranslucentBackground); + // Paint an opaque background behind the tab widget so the tab bar + // renders as a solid, styled bar. A plain QTabWidget fills nothing, so + // the translucent top-level window would otherwise show through the + // document-mode tabs; autoFillBackground fills it with the palette + // window colour. Only the top-level window and the GL surfaces are + // translucent: each GhosttySurface paints with CompositionMode_Source, + // overwriting this opaque background so the terminal's per-pixel alpha + // still reaches the window backing store. + m_tabs->setAutoFillBackground(true); auto *layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); @@ -119,8 +121,8 @@ GhosttySurface *MainWindow::newTab(ghostty_surface_t parent) { m_surfaces.append(surface); // The tab page hosts the tab's split tree (initially one surface). + // It stays opaque chrome; the GhosttySurface paints over it. auto *page = new QWidget(m_tabs); - page->setAttribute(Qt::WA_TranslucentBackground); auto *pageLayout = new QVBoxLayout(page); pageLayout->setContentsMargins(0, 0, 0, 0); pageLayout->addWidget(surface); diff --git a/qt/src/main.cpp b/qt/src/main.cpp index 1664e48a3..bac784297 100644 --- a/qt/src/main.cpp +++ b/qt/src/main.cpp @@ -26,14 +26,12 @@ int main(int argc, char **argv) { QApplication app(argc, argv); - // Use the Fusion style rather than KDE's Breeze. Breeze unconditionally - // applies a blur-behind (frosted-glass) effect to any translucent - // window — which our terminal is — and offers no way to opt out. That - // blur masks the real background transparency; Fusion has no such - // behaviour. The widget style is otherwise nearly invisible here (the - // terminal is GL-rendered; the only Qt chrome is an auto-hidden tab - // bar), so this costs nothing visible. - QApplication::setStyle(QStringLiteral("Fusion")); + // 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-qt" 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 From c0fcccfef46dfb689ec41e16e2711dbbdce7b41e Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 18:44:03 -0500 Subject: [PATCH 26/75] qt: rename to "ghostty" and fix HiDPI / startup image sizing Rename the executable from ghostty-qt to ghostty (CMake target, project, log prefixes). The process name is what fastfetch and Kvantum match on, so "ghostty" lets fastfetch's auto-detection recognise the terminal and matches the Kvantum opaque-list entry. Sizing fixes for the FBO-readback render path: - Blit the readback image 1:1 instead of scaling it to the widget rect. m_image now carries the device pixel ratio it was rendered at, so a frame that briefly lags a resize keeps its true size instead of rubber-banding (which visibly distorted images). - Tag the image with the ratio the framebuffer was sized at (m_fbDpr), not the live devicePixelRatioF(). On Wayland the fractional scale settles asynchronously after the window appears; using the live ratio mis-sized the blit at startup. - Re-sync the framebuffer on QEvent::DevicePixelRatioChange, not only on resize, so a scale change without a resize is handled. - Create the first tab from showEvent rather than initialize(). A surface created before show() spawns its shell while the DPR is still unsettled, so a shell greeting (fastfetch) sized Kitty graphics images for the wrong cell size. The first tab now follows the same path as every later tab. Co-Authored-By: claude-flow --- qt/CMakeLists.txt | 12 ++++----- qt/src/GhosttySurface.cpp | 52 +++++++++++++++++++++++++++------------ qt/src/GhosttySurface.h | 3 +++ qt/src/MainWindow.cpp | 22 +++++++++++++++-- qt/src/MainWindow.h | 5 ++++ qt/src/main.cpp | 6 ++--- 6 files changed, 73 insertions(+), 27 deletions(-) diff --git a/qt/CMakeLists.txt b/qt/CMakeLists.txt index 7b39b0e0d..76e934b98 100644 --- a/qt/CMakeLists.txt +++ b/qt/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.16) -project(ghostty-qt LANGUAGES CXX) +project(ghostty LANGUAGES CXX) # A Qt6 frontend for Ghostty that embeds libghostty through the # GHOSTTY_PLATFORM_OPENGL C API. libghostty's OpenGL renderer draws each @@ -17,7 +17,7 @@ project(ghostty-qt LANGUAGES CXX) # Then build and run this app: # # cmake -S qt -B qt/build && cmake --build qt/build -# ./qt/build/ghostty-qt +# ./qt/build/ghostty set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -41,21 +41,21 @@ endif() file(CREATE_LINK "ghostty-internal.so" "${GHOSTTY_LIB_DIR}/libghostty.so" SYMBOLIC) -add_executable(ghostty-qt +add_executable(ghostty src/main.cpp src/GhosttySurface.cpp src/MainWindow.cpp ) -target_include_directories(ghostty-qt PRIVATE "${GHOSTTY_ROOT}/include") +target_include_directories(ghostty PRIVATE "${GHOSTTY_ROOT}/include") -target_link_libraries(ghostty-qt PRIVATE +target_link_libraries(ghostty PRIVATE Qt6::Gui Qt6::Widgets Qt6::OpenGL "${GHOSTTY_LIB_DIR}/libghostty.so" ) -target_link_options(ghostty-qt PRIVATE +target_link_options(ghostty PRIVATE "-Wl,-rpath,${GHOSTTY_LIB_DIR}" ) diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index df3ca7dfb..5dc78c82c 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -36,7 +36,7 @@ GhosttySurface::GhosttySurface(ghostty_app_t app, MainWindow *owner, m_context = new QOpenGLContext(this); m_context->setFormat(QSurfaceFormat::defaultFormat()); if (!m_context->create()) { - std::fprintf(stderr, "[ghostty-qt] GL context creation failed\n"); + std::fprintf(stderr, "[ghostty] GL context creation failed\n"); return; } m_offscreen = new QOffscreenSurface(nullptr, this); @@ -44,7 +44,7 @@ GhosttySurface::GhosttySurface(ghostty_app_t app, MainWindow *owner, m_offscreen->create(); if (!makeCurrent()) { - std::fprintf(stderr, "[ghostty-qt] makeCurrent failed\n"); + std::fprintf(stderr, "[ghostty] makeCurrent failed\n"); return; } @@ -70,7 +70,7 @@ GhosttySurface::GhosttySurface(ghostty_app_t app, MainWindow *owner, m_surface = ghostty_surface_new(m_app, &sc); if (!m_surface) { - std::fprintf(stderr, "[ghostty-qt] ghostty_surface_new failed\n"); + std::fprintf(stderr, "[ghostty] ghostty_surface_new failed\n"); return; } @@ -95,7 +95,11 @@ bool GhosttySurface::makeCurrent() { // --- rendering ------------------------------------------------------ -void GhosttySurface::resizeEvent(QResizeEvent *) { +// Re-sync the framebuffer and libghostty surface to the widget's current +// size and device pixel ratio. Driven by resizeEvent and by +// DevicePixelRatioChange: on Wayland the fractional scale settles +// asynchronously, after the window has already first appeared. +void GhosttySurface::syncSurfaceSize() { if (!m_surface) return; // Render at the display's device-pixel resolution. devicePixelRatioF() @@ -104,9 +108,10 @@ void GhosttySurface::resizeEvent(QResizeEvent *) { const double dpr = devicePixelRatioF(); const int w = std::max(1, static_cast(width() * dpr)); const int h = std::max(1, static_cast(height() * dpr)); - if (w == m_fbw && h == m_fbh) return; + if (w == m_fbw && h == m_fbh && dpr == m_fbDpr) return; m_fbw = w; m_fbh = h; + m_fbDpr = dpr; if (!makeCurrent()) return; delete m_fbo; @@ -120,6 +125,18 @@ void GhosttySurface::resizeEvent(QResizeEvent *) { renderTerminal(); } +void GhosttySurface::resizeEvent(QResizeEvent *) { syncSurfaceSize(); } + +bool GhosttySurface::event(QEvent *e) { + // The device pixel ratio can change without a resize — the Wayland + // fractional scale settling after startup, or a move between monitors. + // Re-sync so the framebuffer matches and the readback is tagged with + // that same ratio; otherwise paintEvent blits the frame at the wrong + // size (the FBO was sized at one DPR, the image tagged with another). + if (e->type() == QEvent::DevicePixelRatioChange) syncSurfaceSize(); + return QWidget::event(e); +} + void GhosttySurface::requestRender() { renderTerminal(); } void GhosttySurface::renderTerminal() { @@ -132,9 +149,14 @@ void GhosttySurface::renderTerminal() { ghostty_surface_draw(m_surface); premultiplyFramebuffer(); - // Read the frame back as a premultiplied, top-down QImage. paintEvent - // scales it to the widget, so its device pixel ratio is irrelevant. + // Read the frame back as a premultiplied, top-down QImage, tagged with + // the ratio the framebuffer was sized at so paintEvent can blit it 1:1 + // at its true logical size. Using the live devicePixelRatioF() here + // would mis-size the blit if the DPR changed since syncSurfaceSize ran. + // (Scaling it to the widget instead made the whole frame — images + // included — rubber-band while a resize was in flight.) m_image = m_fbo->toImage(); + m_image.setDevicePixelRatio(m_fbDpr); m_fbo->release(); update(); @@ -143,16 +165,14 @@ void GhosttySurface::renderTerminal() { void GhosttySurface::paintEvent(QPaintEvent *) { if (m_image.isNull()) return; QPainter painter(this); - // Scale the framebuffer image to fill the widget. The QRect overload - // is required: drawImage(0, 0, img) would select the int-coordinate - // overload, which blits at raw pixel size and ignores both the - // widget's logical size and the device pixel ratio (a 2x zoom on a - // HiDPI display). - painter.setRenderHint(QPainter::SmoothPixmapTransform); - // Replace the (transparent) widget pixels with the terminal image, - // alpha included, so the background's translucency is preserved. + // Blit the framebuffer 1:1. m_image carries the device pixel ratio, so + // the QPointF overload draws it at its true logical size: when in sync + // that exactly fills the widget, and mid-resize the content keeps its + // real size instead of stretching to the (already-resized) widget. + // CompositionMode_Source replaces the transparent widget pixels with + // the terminal image, alpha included, so its translucency is kept. painter.setCompositionMode(QPainter::CompositionMode_Source); - painter.drawImage(rect(), m_image); + painter.drawImage(QPointF(0, 0), m_image); } // libghostty's renderer outputs premultiplied alpha — except a custom diff --git a/qt/src/GhosttySurface.h b/qt/src/GhosttySurface.h index 9adddc844..ac9463e23 100644 --- a/qt/src/GhosttySurface.h +++ b/qt/src/GhosttySurface.h @@ -40,6 +40,7 @@ public slots: void requestRender(); protected: + bool event(QEvent *) override; void paintEvent(QPaintEvent *) override; void resizeEvent(QResizeEvent *) override; @@ -54,6 +55,7 @@ protected: private: bool makeCurrent(); + void syncSurfaceSize(); void renderTerminal(); void sendKey(QKeyEvent *, ghostty_input_action_e action); void sendMouseButton(QMouseEvent *, ghostty_input_mouse_state_e state); @@ -86,4 +88,5 @@ private: int m_fbw = 0; // framebuffer size, device pixels int m_fbh = 0; + double m_fbDpr = 1.0; // DPR the framebuffer was sized at }; diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index 96730b08c..a9692589a 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -104,7 +105,7 @@ bool MainWindow::initialize() { m_app = ghostty_app_new(&rt, m_config); if (!m_app) { - std::fprintf(stderr, "[ghostty-qt] ghostty_app_new failed\n"); + std::fprintf(stderr, "[ghostty] ghostty_app_new failed\n"); return false; } @@ -113,7 +114,24 @@ bool MainWindow::initialize() { connect(timer, &QTimer::timeout, this, &MainWindow::tick); timer->start(16); - return newTab(nullptr) != nullptr; + // The first tab is created in showEvent, not here: see below. + return true; +} + +void MainWindow::showEvent(QShowEvent *event) { + QWidget::showEvent(event); + + // Create the first terminal only once the window is on-screen. A + // surface created earlier (from initialize(), before show()) spawns + // its shell while the device pixel ratio is still unsettled, so a + // shell greeting such as fastfetch queries a wrong cell size and sizes + // Kitty graphics images for it. Deferring to here — past show(), via a + // queued call so the window is fully mapped — makes the first tab + // behave exactly like every later one. + if (m_firstTabPending) { + m_firstTabPending = false; + QTimer::singleShot(0, this, [this] { newTab(nullptr); }); + } } GhosttySurface *MainWindow::newTab(ghostty_surface_t parent) { diff --git a/qt/src/MainWindow.h b/qt/src/MainWindow.h index 38a2ed834..6fbf29d82 100644 --- a/qt/src/MainWindow.h +++ b/qt/src/MainWindow.h @@ -5,6 +5,7 @@ #include "ghostty.h" +class QShowEvent; class QTabWidget; class GhosttySurface; @@ -47,6 +48,9 @@ public: public slots: void tick(); +protected: + void showEvent(QShowEvent *) override; + private slots: void onTabCloseRequested(int index); void onCurrentChanged(int index); @@ -80,4 +84,5 @@ private: QTabWidget *m_tabs = nullptr; QList m_surfaces; // every live surface bool m_needsPremultiply = false; // a custom shader is configured + bool m_firstTabPending = true; // first tab is created on show() }; diff --git a/qt/src/main.cpp b/qt/src/main.cpp index bac784297..435f1c1ec 100644 --- a/qt/src/main.cpp +++ b/qt/src/main.cpp @@ -30,7 +30,7 @@ int main(int argc, char **argv) { // 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-qt" to 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 @@ -38,13 +38,13 @@ int main(int argc, char **argv) { // 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(argc), argv) != GHOSTTY_SUCCESS) { - std::fprintf(stderr, "[ghostty-qt] ghostty_init failed\n"); + std::fprintf(stderr, "[ghostty] ghostty_init failed\n"); return 1; } MainWindow window; if (!window.initialize()) { - std::fprintf(stderr, "[ghostty-qt] window initialization failed\n"); + std::fprintf(stderr, "[ghostty] window initialization failed\n"); return 1; } window.resize(800, 600); From fefc252d93ef3281858b019fa43d2556d1fd2e0d Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 19:55:20 -0500 Subject: [PATCH 27/75] qt: add Tier 1 action handlers for GTK parity Handle nine more libghostty actions in MainWindow::onAction and add the input handling they imply: - RELOAD_CONFIG / CONFIG_CHANGE: reloadConfig() re-reads the config from disk; applyConfig() pushes a config to the app and every surface and adopts it as the live one. - INITIAL_SIZE: resize the window to the configured dimensions. - CLOSE_WINDOW, RING_BELL (taskbar attention hint). - MOUSE_SHAPE: map to the nearest Qt cursor. - MOUSE_OVER_LINK: show the URL as a tooltip; OPEN_URL hands off to QDesktopServices. - TOGGLE_SPLIT_ZOOM: re-parent the focused surface to fill its tab and back into the splitter tree. - SHOW_CHILD_EXITED: a deferred, dismissable "process exited" overlay on the surface. The tick() process-exit poll is removed; libghostty now drives normal closes via close_surface_cb. GhosttySurface gains IME support: WA_InputMethodEnabled plus inputMethodEvent/inputMethodQuery, forwarding preedit text through ghostty_surface_preedit and committing finalized text as input. Co-Authored-By: claude-flow --- qt/src/GhosttySurface.cpp | 95 ++++++++++++++++- qt/src/GhosttySurface.h | 15 +++ qt/src/MainWindow.cpp | 217 ++++++++++++++++++++++++++++++++++++-- qt/src/MainWindow.h | 17 +++ 4 files changed, 335 insertions(+), 9 deletions(-) diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index 5dc78c82c..6a3bb36bf 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -7,7 +7,9 @@ #include #include +#include #include +#include #include #include #include @@ -19,6 +21,7 @@ #include #include #include +#include #include GhosttySurface::GhosttySurface(ghostty_app_t app, MainWindow *owner, @@ -26,6 +29,7 @@ GhosttySurface::GhosttySurface(ghostty_app_t app, MainWindow *owner, : m_app(app), m_owner(owner), m_parentSurface(parent_surface) { setFocusPolicy(Qt::StrongFocus); setMouseTracking(true); // deliver motion events for hover/link detection + setAttribute(Qt::WA_InputMethodEnabled, true); // IME composition // The widget paints a per-pixel-alpha QImage of the terminal; a // translucent background lets that alpha reach the desktop. setAttribute(Qt::WA_TranslucentBackground); @@ -125,7 +129,10 @@ void GhosttySurface::syncSurfaceSize() { renderTerminal(); } -void GhosttySurface::resizeEvent(QResizeEvent *) { syncSurfaceSize(); } +void GhosttySurface::resizeEvent(QResizeEvent *) { + syncSurfaceSize(); + if (m_exitOverlay) m_exitOverlay->setGeometry(rect()); +} bool GhosttySurface::event(QEvent *e) { // The device pixel ratio can change without a resize — the Wayland @@ -175,6 +182,40 @@ void GhosttySurface::paintEvent(QPaintEvent *) { painter.drawImage(QPointF(0, 0), m_image); } +void GhosttySurface::showChildExited(int exitCode) { + if (m_exitOverlay) return; // already shown + + // Defer the banner briefly. A normal `exit` closes the surface within + // a frame or two (libghostty calls close() right after this action), + // and we don't want the banner to flash in that case. The QObject- + // context singleShot is cancelled if the surface is destroyed first, + // so the banner only appears for surfaces that actually persist (an + // abnormal exit, or `wait-after-command`). + QTimer::singleShot(120, this, [this, exitCode]() { buildExitOverlay(exitCode); }); +} + +void GhosttySurface::buildExitOverlay(int exitCode) { + if (m_exitOverlay) return; + + // A translucent banner over the terminal. It is transparent to mouse + // events so a click lands on this widget and dismisses it (see + // mousePressEvent / keyPressEvent). + m_exitOverlay = new QLabel(this); + m_exitOverlay->setAlignment(Qt::AlignCenter); + m_exitOverlay->setWordWrap(true); + m_exitOverlay->setAttribute(Qt::WA_TransparentForMouseEvents); + m_exitOverlay->setStyleSheet(QStringLiteral( + "background: rgba(0,0,0,0.65); color: #e0e0e0; font-size: 14px;")); + const QString code = exitCode >= 0 + ? QStringLiteral(" (code %1)").arg(exitCode) + : QString(); + m_exitOverlay->setText(QStringLiteral( + "Process exited%1\nPress any key or click to close").arg(code)); + m_exitOverlay->setGeometry(rect()); + m_exitOverlay->show(); + m_exitOverlay->raise(); +} + // libghostty's renderer outputs premultiplied alpha — except a custom // shader runs as a final Shadertoy-style pass and those conventionally // emit *straight* alpha (RGB not scaled by alpha). QPainter and the @@ -293,6 +334,12 @@ void GhosttySurface::sendMouseButton(QMouseEvent *ev, } void GhosttySurface::keyPressEvent(QKeyEvent *ev) { + // While the child-exited overlay is up, any key dismisses it (closes + // the pane) instead of reaching the dead terminal. + if (m_exitOverlay) { + m_owner->removeSurface(this); + return; + } sendKey(ev, ev->isAutoRepeat() ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS); } @@ -304,6 +351,10 @@ void GhosttySurface::keyReleaseEvent(QKeyEvent *ev) { } void GhosttySurface::mousePressEvent(QMouseEvent *ev) { + if (m_exitOverlay) { + m_owner->removeSurface(this); + return; + } setFocus(); sendMouseButton(ev, GHOSTTY_MOUSE_PRESS); } @@ -335,6 +386,48 @@ void GhosttySurface::focusOutEvent(QFocusEvent *) { if (m_surface) ghostty_surface_set_focus(m_surface, false); } +// Insert a string of committed text (an IME commit) as terminal input. +void GhosttySurface::commitText(const QString &text) { + if (!m_surface || text.isEmpty()) return; + const QByteArray utf8 = text.toUtf8(); + ghostty_input_key_s k = {}; + k.action = GHOSTTY_ACTION_PRESS; + k.mods = GHOSTTY_MODS_NONE; + k.consumed_mods = GHOSTTY_MODS_NONE; + k.keycode = 0; + k.text = utf8.constData(); + k.unshifted_codepoint = 0; + k.composing = false; + ghostty_surface_key(m_surface, k); +} + +void GhosttySurface::inputMethodEvent(QInputMethodEvent *ev) { + if (m_surface) { + // Forward the in-progress composition for inline display, then any + // finalized text. A well-behaved IME sends an empty preedit string + // alongside the commit, so this order matches GTK: clear, then commit. + const QByteArray preedit = ev->preeditString().toUtf8(); + ghostty_surface_preedit( + m_surface, preedit.isEmpty() ? nullptr : preedit.constData(), + static_cast(preedit.size())); + if (!ev->commitString().isEmpty()) commitText(ev->commitString()); + } + ev->accept(); +} + +QVariant GhosttySurface::inputMethodQuery(Qt::InputMethodQuery query) const { + switch (query) { + case Qt::ImEnabled: + return true; + case Qt::ImCursorRectangle: + // Approximate anchor for the candidate window; tracking the real + // terminal cursor cell is a follow-up. + return QRect(4, height() - 4, 1, 1); + default: + return QWidget::inputMethodQuery(query); + } +} + // --- libghostty GL platform callbacks -------------------------------- void *GhosttySurface::glGetProcAddress(void *, const char *name) { diff --git a/qt/src/GhosttySurface.h b/qt/src/GhosttySurface.h index ac9463e23..3157df8ec 100644 --- a/qt/src/GhosttySurface.h +++ b/qt/src/GhosttySurface.h @@ -6,6 +6,8 @@ #include "ghostty.h" class MainWindow; +class QInputMethodEvent; +class QLabel; class QOffscreenSurface; class QOpenGLContext; class QOpenGLFramebufferObject; @@ -35,6 +37,10 @@ public: ghostty_surface_t surface() const { return m_surface; } MainWindow *owner() const { return m_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); + public slots: // Render a fresh frame (the libghostty RENDER action). void requestRender(); @@ -53,11 +59,18 @@ protected: 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 buildExitOverlay(int exitCode); void sendKey(QKeyEvent *, ghostty_input_action_e action); + void commitText(const QString &text); void sendMouseButton(QMouseEvent *, ghostty_input_mouse_state_e state); // Premultiply the framebuffer's alpha; only used when a custom shader @@ -89,4 +102,6 @@ private: 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 }; diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index a9692589a..be1238ea7 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -4,8 +4,10 @@ #include #include +#include #include #include +#include #include #include #include @@ -14,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -194,6 +197,13 @@ GhosttySurface *MainWindow::splitSurface( void MainWindow::removeSurface(GhosttySurface *surface) { if (!m_surfaces.removeOne(surface)) return; + // Drop stale split-zoom state if the zoomed surface is going away. + if (surface == m_zoomed) { + m_zoomed = nullptr; + m_zoomRoot = nullptr; + m_zoomSplitter = nullptr; + } + QWidget *parent = surface->parentWidget(); if (auto *splitter = qobject_cast(parent)) { // One pane of a split: collapse the splitter into its sibling. @@ -241,13 +251,8 @@ void MainWindow::setSurfaceTitle(GhosttySurface *surface, void MainWindow::tick() { if (!m_app) return; ghostty_app_tick(m_app); - - // Close any pane whose child process has exited. - const auto surfaces = m_surfaces; // copy; removeSurface mutates the list - for (GhosttySurface *s : surfaces) { - if (s->surface() && ghostty_surface_process_exited(s->surface())) - removeSurface(s); - } + // Process exit is handled by libghostty: a normal exit closes the + // surface via close_surface_cb; an abnormal one fires SHOW_CHILD_EXITED. } void MainWindow::onTabCloseRequested(int index) { closeTab(index); } @@ -389,6 +394,71 @@ void MainWindow::equalizeSplits(GhosttySurface *from) { } } +// Push `config` to the app and every surface, and adopt it as the live +// config. Takes ownership of `config` (frees the previous one). +void MainWindow::applyConfig(ghostty_config_t config) { + if (!config) return; + ghostty_app_update_config(m_app, config); + for (GhosttySurface *s : m_surfaces) + if (s->surface()) ghostty_surface_update_config(s->surface(), config); + + if (m_config && m_config != config) ghostty_config_free(m_config); + m_config = config; + m_needsPremultiply = configHasCustomShader(); +} + +void MainWindow::reloadConfig() { + // Re-read the config from disk in the same order as initialize(). + ghostty_config_t config = ghostty_config_new(); + ghostty_config_load_default_files(config); + ghostty_config_load_cli_args(config); + ghostty_config_load_recursive_files(config); + ghostty_config_finalize(config); + applyConfig(config); +} + +void MainWindow::toggleSplitZoom(GhosttySurface *surface) { + // Already zoomed: restore the surface into its splitter and the + // stashed tree back into the tab page. + if (m_zoomed) { + GhosttySurface *was = m_zoomed; + QWidget *page = m_zoomRoot->parentWidget(); + page->layout()->removeWidget(was); + m_zoomSplitter->insertWidget(m_zoomIndex, was); + page->layout()->addWidget(m_zoomRoot); + m_zoomRoot->show(); + m_zoomed = nullptr; + m_zoomRoot = nullptr; + m_zoomSplitter = nullptr; + was->setFocus(); + if (was == surface) return; // plain toggle-off + // Zoom requested on a different pane: fall through and zoom it. + } + + // A surface with no splitter parent is the whole tab — nothing to zoom. + auto *splitter = qobject_cast(surface->parentWidget()); + if (!splitter) return; + const int tab = tabIndexForSurface(surface); + if (tab < 0) return; + QLayout *pageLayout = m_tabs->widget(tab)->layout(); + if (!pageLayout || pageLayout->count() == 0) return; + QWidget *root = pageLayout->itemAt(0)->widget(); + if (!root) return; + + m_zoomed = surface; + m_zoomSplitter = splitter; + m_zoomIndex = splitter->indexOf(surface); + m_zoomRoot = root; + + // Stash the tree (hidden, still a child of the page) and let the + // zoomed surface fill the page. + pageLayout->removeWidget(root); + root->hide(); + pageLayout->addWidget(surface); + surface->show(); + surface->setFocus(); +} + // --- libghostty runtime callbacks ------------------------------------ void MainWindow::onWakeup(void *ud) { @@ -397,6 +467,43 @@ void MainWindow::onWakeup(void *ud) { QMetaObject::invokeMethod(self, "tick", Qt::QueuedConnection); } +// Map a libghostty mouse shape to the nearest Qt cursor. +static Qt::CursorShape mouseShapeToCursor(ghostty_action_mouse_shape_e s) { + switch (s) { + case GHOSTTY_MOUSE_SHAPE_TEXT: + case GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT: return Qt::IBeamCursor; + case GHOSTTY_MOUSE_SHAPE_POINTER: + case GHOSTTY_MOUSE_SHAPE_ALIAS: return Qt::PointingHandCursor; + case GHOSTTY_MOUSE_SHAPE_WAIT: + case GHOSTTY_MOUSE_SHAPE_PROGRESS: return Qt::WaitCursor; + case GHOSTTY_MOUSE_SHAPE_CROSSHAIR: + case GHOSTTY_MOUSE_SHAPE_CELL: return Qt::CrossCursor; + case GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED: + case GHOSTTY_MOUSE_SHAPE_NO_DROP: return Qt::ForbiddenCursor; + case GHOSTTY_MOUSE_SHAPE_GRAB: return Qt::OpenHandCursor; + case GHOSTTY_MOUSE_SHAPE_GRABBING: return Qt::ClosedHandCursor; + case GHOSTTY_MOUSE_SHAPE_MOVE: + case GHOSTTY_MOUSE_SHAPE_ALL_SCROLL: return Qt::SizeAllCursor; + case GHOSTTY_MOUSE_SHAPE_COPY: return Qt::DragCopyCursor; + case GHOSTTY_MOUSE_SHAPE_HELP: return Qt::WhatsThisCursor; + case GHOSTTY_MOUSE_SHAPE_COL_RESIZE: + case GHOSTTY_MOUSE_SHAPE_E_RESIZE: + case GHOSTTY_MOUSE_SHAPE_W_RESIZE: + case GHOSTTY_MOUSE_SHAPE_EW_RESIZE: return Qt::SizeHorCursor; + case GHOSTTY_MOUSE_SHAPE_ROW_RESIZE: + case GHOSTTY_MOUSE_SHAPE_N_RESIZE: + case GHOSTTY_MOUSE_SHAPE_S_RESIZE: + case GHOSTTY_MOUSE_SHAPE_NS_RESIZE: return Qt::SizeVerCursor; + case GHOSTTY_MOUSE_SHAPE_NE_RESIZE: + case GHOSTTY_MOUSE_SHAPE_SW_RESIZE: + case GHOSTTY_MOUSE_SHAPE_NESW_RESIZE: return Qt::SizeBDiagCursor; + case GHOSTTY_MOUSE_SHAPE_NW_RESIZE: + case GHOSTTY_MOUSE_SHAPE_SE_RESIZE: + case GHOSTTY_MOUSE_SHAPE_NWSE_RESIZE: return Qt::SizeFDiagCursor; + default: return Qt::ArrowCursor; // DEFAULT, CONTEXT_MENU, zoom, ... + } +} + bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, ghostty_action_s action) { auto *self = static_cast(ghostty_app_userdata(app)); @@ -514,8 +621,102 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, self, [self]() { self->close(); }, Qt::QueuedConnection); return true; + case GHOSTTY_ACTION_SHOW_CHILD_EXITED: { + if (!src) return false; + const int code = + static_cast(action.action.child_exited.exit_code); + QMetaObject::invokeMethod( + src, [src, code]() { src->showChildExited(code); }, + Qt::QueuedConnection); + return true; + } + + case GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM: + if (src) + QMetaObject::invokeMethod( + self, [self, src]() { self->toggleSplitZoom(src); }, + Qt::QueuedConnection); + return true; + + case GHOSTTY_ACTION_RELOAD_CONFIG: + QMetaObject::invokeMethod( + self, [self]() { self->reloadConfig(); }, Qt::QueuedConnection); + return true; + + case GHOSTTY_ACTION_CONFIG_CHANGE: { + // Clone libghostty's config so it outlives this callback; applyConfig + // adopts the clone as the live config. + ghostty_config_t cfg = + ghostty_config_clone(action.action.config_change.config); + QMetaObject::invokeMethod( + self, [self, cfg]() { self->applyConfig(cfg); }, + Qt::QueuedConnection); + return true; + } + + case GHOSTTY_ACTION_INITIAL_SIZE: { + const ghostty_action_initial_size_s sz = action.action.initial_size; + QMetaObject::invokeMethod( + self, + [self, sz]() { + // The action carries device pixels; resize() takes logical. + const double dpr = self->devicePixelRatioF(); + self->resize(static_cast(sz.width / dpr), + static_cast(sz.height / dpr)); + }, + Qt::QueuedConnection); + return true; + } + + case GHOSTTY_ACTION_CLOSE_WINDOW: + QMetaObject::invokeMethod( + self, [self]() { self->close(); }, Qt::QueuedConnection); + return true; + + case GHOSTTY_ACTION_RING_BELL: + // Taskbar/window attention hint. Honoring `bell-features` config + // (audio file, volume) is a future refinement. + QMetaObject::invokeMethod( + self, [self]() { QApplication::alert(self); }, + Qt::QueuedConnection); + return true; + + case GHOSTTY_ACTION_MOUSE_SHAPE: { + if (!src) return false; + const Qt::CursorShape shape = + mouseShapeToCursor(action.action.mouse_shape); + QMetaObject::invokeMethod( + src, [src, shape]() { src->setCursor(shape); }, + Qt::QueuedConnection); + return true; + } + + case GHOSTTY_ACTION_MOUSE_OVER_LINK: { + if (!src) return true; + const ghostty_action_mouse_over_link_s l = action.action.mouse_over_link; + const QString url = + l.url && l.len ? QString::fromUtf8(l.url, l.len) : QString(); + QMetaObject::invokeMethod( + src, [src, url]() { src->setToolTip(url); }, Qt::QueuedConnection); + return true; + } + + case GHOSTTY_ACTION_OPEN_URL: { + const ghostty_action_open_url_s u = action.action.open_url; + if (!u.url || !u.len) return true; + const QString s = QString::fromUtf8(u.url, static_cast(u.len)); + QMetaObject::invokeMethod( + self, + [s]() { + QDesktopServices::openUrl( + QUrl::fromUserInput(s, QString(), QUrl::AssumeLocalFile)); + }, + Qt::QueuedConnection); + return true; + } + default: - // Split zoom, tab moving, inspector, etc. are not handled yet. + // Inspector, command palette, search, etc. are not handled yet. return false; } } diff --git a/qt/src/MainWindow.h b/qt/src/MainWindow.h index 6fbf29d82..0a980fa19 100644 --- a/qt/src/MainWindow.h +++ b/qt/src/MainWindow.h @@ -6,6 +6,7 @@ #include "ghostty.h" class QShowEvent; +class QSplitter; class QTabWidget; class GhosttySurface; @@ -67,6 +68,15 @@ private: void resizeSplit(GhosttySurface *from, ghostty_action_resize_split_s rs); void equalizeSplits(GhosttySurface *from); + // Config: rebuild from disk (reloadConfig) or apply one libghostty + // handed us (applyConfig), pushing it to the app and every surface. + void reloadConfig(); + void applyConfig(ghostty_config_t config); + + // 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 carry the // app userdata; clipboard/close carry the surface userdata. static void onWakeup(void *ud); @@ -85,4 +95,11 @@ private: QList m_surfaces; // every live surface bool m_needsPremultiply = false; // a custom shader is configured bool m_firstTabPending = true; // first tab is created on show() + + // 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; }; From 46d97c14050204f76beb1f36cd6af21a4343c6ef Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 20:09:28 -0500 Subject: [PATCH 28/75] qt: create the first tab only once the device pixel ratio settles The first surface was created on a singleShot(0) after showEvent, on the assumption the device pixel ratio was settled one event-loop tick later. On Wayland the fractional scale arrives asynchronously after the window appears, so this was a race: the surface (and its shell's fastfetch) could spawn at a stale scale, making fastfetch query a wrong cell size and mis-size Kitty graphics images. The Tier 1 changes shifted startup timing enough to lose the race reliably. Gate first-tab creation on the DPR actually being settled: event() creates it on DevicePixelRatioChange, with a 250ms fallback for when the ratio was already correct at show. Co-Authored-By: claude-flow --- qt/src/MainWindow.cpp | 34 +++++++++++++++++++++++----------- qt/src/MainWindow.h | 4 ++++ 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index be1238ea7..579bb22f1 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -124,17 +124,29 @@ bool MainWindow::initialize() { void MainWindow::showEvent(QShowEvent *event) { QWidget::showEvent(event); - // Create the first terminal only once the window is on-screen. A - // surface created earlier (from initialize(), before show()) spawns - // its shell while the device pixel ratio is still unsettled, so a - // shell greeting such as fastfetch queries a wrong cell size and sizes - // Kitty graphics images for it. Deferring to here — past show(), via a - // queued call so the window is fully mapped — makes the first tab - // behave exactly like every later one. - if (m_firstTabPending) { - m_firstTabPending = false; - QTimer::singleShot(0, this, [this] { newTab(nullptr); }); - } + // Defer the first terminal until the device pixel ratio has settled. + // On Wayland the fractional scale arrives asynchronously after the + // window appears; a surface created before then spawns its shell at a + // stale scale, so a shell greeting (fastfetch) queries a wrong cell + // size and mis-sizes Kitty images. event() creates the tab as soon as + // a DevicePixelRatioChange lands; this timer is the fallback for when + // the ratio was already correct at show. + if (m_firstTabPending) + QTimer::singleShot(250, this, [this] { createFirstTab(); }); +} + +bool MainWindow::event(QEvent *e) { + // The fractional scale settling after the window appears arrives as a + // DevicePixelRatioChange — the earliest point the first surface can be + // created with a correct, stable scale. + if (e->type() == QEvent::DevicePixelRatioChange) createFirstTab(); + return QWidget::event(e); +} + +void MainWindow::createFirstTab() { + if (!m_firstTabPending) return; + m_firstTabPending = false; + newTab(nullptr); } GhosttySurface *MainWindow::newTab(ghostty_surface_t parent) { diff --git a/qt/src/MainWindow.h b/qt/src/MainWindow.h index 0a980fa19..c35655e29 100644 --- a/qt/src/MainWindow.h +++ b/qt/src/MainWindow.h @@ -50,6 +50,7 @@ public slots: void tick(); protected: + bool event(QEvent *) override; void showEvent(QShowEvent *) override; private slots: @@ -57,6 +58,9 @@ private slots: void onCurrentChanged(int index); private: + // Create the first tab once the device pixel ratio has settled. + void createFirstTab(); + void closeTab(int index); GhosttySurface *surfaceAt(int index) const; int tabIndexForSurface(GhosttySurface *surface) const; From 083f3d87e67bb989a3d48fecdb2f2e4dcb77a350 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 20:14:26 -0500 Subject: [PATCH 29/75] qt: feed unscaled mouse coordinates to libghostty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mouseMoveEvent passed ev->position() * devicePixelRatio() to ghostty_surface_mouse_pos, but cursorPosCallback expects unscaled (logical) coordinates and applies the content scale itself. The position was double-scaled, so on a HiDPI display the selection drifted from the cursor — increasingly so further down the screen. Pass logical pixels. Co-Authored-By: claude-flow --- qt/src/GhosttySurface.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index 6a3bb36bf..0ba1e8123 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -365,9 +365,11 @@ void GhosttySurface::mouseReleaseEvent(QMouseEvent *ev) { void GhosttySurface::mouseMoveEvent(QMouseEvent *ev) { if (!m_surface) return; - const double dpr = devicePixelRatioF(); - ghostty_surface_mouse_pos(m_surface, ev->position().x() * dpr, - ev->position().y() * dpr, + // ghostty_surface_mouse_pos wants unscaled (logical) coordinates — it + // applies the content scale itself. Passing device pixels double-scales + // the position and drifts the selection on HiDPI displays. + ghostty_surface_mouse_pos(m_surface, ev->position().x(), + ev->position().y(), translateMods(ev->modifiers())); } From 26e4e5aff16a4c1db19ab653391d818f32f2e393 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 21:09:21 -0500 Subject: [PATCH 30/75] qt: add a right-click context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GhosttySurface now shows a QMenu on right-click (unless the running program is capturing the mouse, in which case the click is forwarded). The menu mirrors the GTK frontend's structure: Copy / Paste / Select All Clear / Reset Split ▸ (Change Title…, Split Right/Down/Left/Up) Tab ▸ (Change Tab Title…, New Tab, Close Tab) Window ▸ (New Window, Close Window) Config ▸ (Open Config, Reload Config) Each item invokes a libghostty keybind action by string via ghostty_surface_binding_action; window-affecting actions route back through MainWindow::onAction, which already handles them. Items carry a theme icon and a shortcut hint queried live from ghostty_config_trigger, so the hint matches the user's actual keybinds. Copy/Paste are enabled from ghostty_surface_has_selection / clipboard state. Change Title… / Change Tab Title… collect text via QInputDialog (no apprt-side prompt exists in libghostty) and apply set_surface_title / set_tab_title. "Notify on Next Command Finish" is omitted: it has no keybind action and needs the desktop-notification path, which is not wired yet. Co-Authored-By: claude-flow --- qt/src/GhosttySurface.cpp | 147 ++++++++++++++++++++++++++++++++++++++ qt/src/GhosttySurface.h | 8 +++ qt/src/MainWindow.h | 3 + 3 files changed, 158 insertions(+) diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index 0ba1e8123..a791d715c 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -6,10 +6,18 @@ #include #include +#include +#include #include +#include +#include +#include #include #include +#include #include +#include +#include #include #include #include @@ -350,19 +358,158 @@ void GhosttySurface::keyReleaseEvent(QKeyEvent *ev) { sendKey(ev, GHOSTTY_ACTION_RELEASE); } +// A right-click opens the context menu (contextMenuEvent) unless the +// running program is capturing the mouse, in which case it gets the +// click. Returns true if the click was for the menu and should not be +// forwarded to the terminal. +bool GhosttySurface::rightClickOpensMenu(QMouseEvent *ev) const { + return ev->button() == Qt::RightButton && m_surface && + !ghostty_surface_mouse_captured(m_surface); +} + void GhosttySurface::mousePressEvent(QMouseEvent *ev) { if (m_exitOverlay) { m_owner->removeSurface(this); return; } setFocus(); + if (rightClickOpensMenu(ev)) return; sendMouseButton(ev, GHOSTTY_MOUSE_PRESS); } void GhosttySurface::mouseReleaseEvent(QMouseEvent *ev) { + if (rightClickOpensMenu(ev)) return; sendMouseButton(ev, GHOSTTY_MOUSE_RELEASE); } +// The keybind bound to `action` in the live config, as a QKeySequence +// for a context-menu hint. Empty if unbound or not displayable. +QKeySequence GhosttySurface::shortcutFor(const char *action) const { + if (!m_owner || !m_owner->config()) return {}; + const ghostty_input_trigger_s t = + ghostty_config_trigger(m_owner->config(), action, qstrlen(action)); + + QString key; + switch (t.tag) { + case GHOSTTY_TRIGGER_UNICODE: + if (t.key.unicode) key = QString(QChar(t.key.unicode)).toUpper(); + break; + case GHOSTTY_TRIGGER_PHYSICAL: { + const ghostty_input_key_e k = t.key.physical; + if (k >= GHOSTTY_KEY_A && k <= GHOSTTY_KEY_Z) + key = QChar('A' + (k - GHOSTTY_KEY_A)); + else if (k >= GHOSTTY_KEY_DIGIT_0 && k <= GHOSTTY_KEY_DIGIT_9) + key = QChar('0' + (k - GHOSTTY_KEY_DIGIT_0)); + else if (k == GHOSTTY_KEY_ENTER) + key = QStringLiteral("Return"); + else if (k == GHOSTTY_KEY_SPACE) + key = QStringLiteral("Space"); + else if (k == GHOSTTY_KEY_TAB) + key = QStringLiteral("Tab"); + break; + } + default: + break; // CATCH_ALL etc. — nothing displayable + } + if (key.isEmpty()) return {}; + + QString seq; + if (t.mods & GHOSTTY_MODS_CTRL) seq += QStringLiteral("Ctrl+"); + if (t.mods & GHOSTTY_MODS_ALT) seq += QStringLiteral("Alt+"); + if (t.mods & GHOSTTY_MODS_SHIFT) seq += QStringLiteral("Shift+"); + if (t.mods & GHOSTTY_MODS_SUPER) seq += QStringLiteral("Meta+"); + return QKeySequence(seq + key); +} + +void GhosttySurface::contextMenuEvent(QContextMenuEvent *ev) { + // Let a mouse-capturing program have the right-click; also suppress + // the menu while the child-exited overlay is up. + if (!m_surface || m_exitOverlay || + ghostty_surface_mouse_captured(m_surface)) + return; + + QMenu menu(this); + // Each item carries its libghostty keybind-action string in data(); + // exec() returns the chosen action and we run it once, below. Icons + // come from the system theme; the shortcut hint from the live config. + const auto add = [this](QMenu *into, const char *label, const char *icon, + const char *action, bool enabled) { + QAction *a = into->addAction(QString::fromUtf8(label)); + a->setData(QString::fromUtf8(action)); + a->setEnabled(enabled); + if (QIcon themed = QIcon::fromTheme(QString::fromUtf8(icon)); + !themed.isNull()) + a->setIcon(themed); + if (QKeySequence sc = shortcutFor(action); !sc.isEmpty()) + a->setShortcut(sc); + }; + + add(&menu, "Copy", "edit-copy", "copy_to_clipboard", + ghostty_surface_has_selection(m_surface)); + add(&menu, "Paste", "edit-paste", "paste_from_clipboard", + !QGuiApplication::clipboard()->text().isEmpty()); + add(&menu, "Select All", "edit-select-all", "select_all", true); + menu.addSeparator(); + add(&menu, "Clear", "edit-clear-all", "clear_screen", true); + add(&menu, "Reset", "view-refresh", "reset", true); + menu.addSeparator(); + + QMenu *split = menu.addMenu( + QIcon::fromTheme(QStringLiteral("view-split-left-right")), + QStringLiteral("Split")); + add(split, "Change Title…", "document-edit", "prompt_surface_title", true); + add(split, "Split Right", "view-split-left-right", "new_split:right", true); + add(split, "Split Down", "view-split-top-bottom", "new_split:down", true); + add(split, "Split Left", "view-split-left-right", "new_split:left", true); + add(split, "Split Up", "view-split-top-bottom", "new_split:up", true); + + QMenu *tab = menu.addMenu(QIcon::fromTheme(QStringLiteral("tab-new")), + QStringLiteral("Tab")); + add(tab, "Change Tab Title…", "document-edit", "prompt_tab_title", true); + add(tab, "New Tab", "tab-new", "new_tab", true); + add(tab, "Close Tab", "tab-close", "close_tab", true); + + QMenu *window = menu.addMenu(QIcon::fromTheme(QStringLiteral("window-new")), + QStringLiteral("Window")); + add(window, "New Window", "window-new", "new_window", true); + add(window, "Close Window", "window-close", "close_window", true); + + menu.addSeparator(); + QMenu *config = menu.addMenu(QIcon::fromTheme(QStringLiteral("configure")), + QStringLiteral("Config")); + add(config, "Open Config", "document-open", "open_config", true); + add(config, "Reload Config", "view-refresh", "reload_config", true); + + QAction *chosen = menu.exec(ev->globalPos()); + if (!chosen || !m_surface) return; + const QString data = chosen->data().toString(); + + // The title items have no apprt-side prompt in libghostty: collect the + // text here and apply it with the set_*_title keybind action (an empty + // title resets it). + if (data == QLatin1String("prompt_surface_title") || + data == QLatin1String("prompt_tab_title")) { + const bool surfaceTitle = data == QLatin1String("prompt_surface_title"); + bool ok = false; + const QString title = QInputDialog::getText( + this, + surfaceTitle ? QStringLiteral("Change Title") + : QStringLiteral("Change Tab Title"), + QStringLiteral("Title:"), QLineEdit::Normal, QString(), &ok); + if (!ok) return; + const QByteArray act = + (surfaceTitle ? QByteArrayLiteral("set_surface_title:") + : QByteArrayLiteral("set_tab_title:")) + + title.toUtf8(); + ghostty_surface_binding_action(m_surface, act.constData(), act.size()); + return; + } + + const QByteArray action = data.toUtf8(); + ghostty_surface_binding_action(m_surface, action.constData(), + action.size()); +} + void GhosttySurface::mouseMoveEvent(QMouseEvent *ev) { if (!m_surface) return; // ghostty_surface_mouse_pos wants unscaled (logical) coordinates — it diff --git a/qt/src/GhosttySurface.h b/qt/src/GhosttySurface.h index 3157df8ec..a4194f6c9 100644 --- a/qt/src/GhosttySurface.h +++ b/qt/src/GhosttySurface.h @@ -6,7 +6,9 @@ #include "ghostty.h" class MainWindow; +class QContextMenuEvent; class QInputMethodEvent; +class QKeySequence; class QLabel; class QOffscreenSurface; class QOpenGLContext; @@ -55,6 +57,7 @@ protected: void mousePressEvent(QMouseEvent *) override; void mouseReleaseEvent(QMouseEvent *) override; void mouseMoveEvent(QMouseEvent *) override; + void contextMenuEvent(QContextMenuEvent *) override; void wheelEvent(QWheelEvent *) override; void focusInEvent(QFocusEvent *) override; void focusOutEvent(QFocusEvent *) override; @@ -72,6 +75,11 @@ private: 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). diff --git a/qt/src/MainWindow.h b/qt/src/MainWindow.h index c35655e29..ce2fdb46a 100644 --- a/qt/src/MainWindow.h +++ b/qt/src/MainWindow.h @@ -41,6 +41,9 @@ public: // 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 m_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). From c42b911c6c4723aac58b9cde31b8f5151da8568e Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 18 May 2026 22:24:00 -0500 Subject: [PATCH 31/75] qt: handle OPEN_CONFIG so the context-menu item works The context menu's "Open Config" invokes the open_config keybind action, which makes libghostty emit GHOSTTY_ACTION_OPEN_CONFIG. onAction did not handle it, so the item was inert. ghostty_config_open_path() opens the config in the user's editor and returns the path; handle the action by calling it (and freeing the returned string). Co-Authored-By: claude-flow --- qt/src/MainWindow.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index 579bb22f1..08f9cd598 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -650,6 +650,14 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, Qt::QueuedConnection); return true; + case GHOSTTY_ACTION_OPEN_CONFIG: { + // libghostty opens the config file in the user's editor itself and + // returns the path; we only need to free that string. + ghostty_string_s path = ghostty_config_open_path(); + ghostty_string_free(path); + return true; + } + case GHOSTTY_ACTION_RELOAD_CONFIG: QMetaObject::invokeMethod( self, [self]() { self->reloadConfig(); }, Qt::QueuedConnection); From cab55783deaa146ee4be8e472627a7c822f809ce Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 08:48:34 -0500 Subject: [PATCH 32/75] =?UTF-8?q?qt:=20Tier=202=20parity=20=E2=80=94=20not?= =?UTF-8?q?ifications,=20drag-and-drop,=20paste=20confirm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the libghostty-API-achievable subset of Tier 2: - Desktop notifications: DESKTOP_NOTIFICATION posts via the freedesktop D-Bus notification service (new Qt6::DBus dependency). - Notify on next command finish: a per-surface one-shot flag, armed by a restored context-menu item, fires a notification on COMMAND_FINISHED (requires Ghostty shell integration). - Drag-and-drop: GhosttySurface accepts dropped files (inserted as shell-quoted paths) and text. - Unsafe-paste confirmation: onConfirmReadClipboard shows a dialog and completes the request with the user's answer. The dialog is deferred off the libghostty callback stack — running it inline spun a nested event loop that re-entered libghostty via the render tick and crashed. - Quick handlers: MOVE_TAB, MOUSE_VISIBILITY, RENDERER_HEALTH. Deferred: in-terminal search (needs a libghostty C-API extension), command palette, undo/redo, and PROGRESS_REPORT (coupled to packaging). Co-Authored-By: claude-flow --- qt/CMakeLists.txt | 3 +- qt/src/GhosttySurface.cpp | 39 +++++++++++++ qt/src/GhosttySurface.h | 14 +++++ qt/src/MainWindow.cpp | 120 ++++++++++++++++++++++++++++++++++++-- qt/src/MainWindow.h | 1 + 5 files changed, 171 insertions(+), 6 deletions(-) diff --git a/qt/CMakeLists.txt b/qt/CMakeLists.txt index 76e934b98..61757e684 100644 --- a/qt/CMakeLists.txt +++ b/qt/CMakeLists.txt @@ -23,7 +23,7 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) -find_package(Qt6 REQUIRED COMPONENTS Gui Widgets OpenGL) +find_package(Qt6 REQUIRED COMPONENTS Gui Widgets OpenGL DBus) # libghostty is built out-of-tree by Zig. get_filename_component(GHOSTTY_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/.." ABSOLUTE) @@ -53,6 +53,7 @@ target_link_libraries(ghostty PRIVATE Qt6::Gui Qt6::Widgets Qt6::OpenGL + Qt6::DBus "${GHOSTTY_LIB_DIR}/libghostty.so" ) diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index a791d715c..3eda6bbec 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include #include #include @@ -18,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -28,8 +31,10 @@ #include #include #include +#include #include #include +#include #include GhosttySurface::GhosttySurface(ghostty_app_t app, MainWindow *owner, @@ -38,6 +43,7 @@ GhosttySurface::GhosttySurface(ghostty_app_t app, MainWindow *owner, setFocusPolicy(Qt::StrongFocus); setMouseTracking(true); // deliver motion events for hover/link detection setAttribute(Qt::WA_InputMethodEnabled, true); // IME composition + setAcceptDrops(true); // file / text drops // The widget paints a per-pixel-alpha QImage of the terminal; a // translucent background lets that alpha reach the desktop. setAttribute(Qt::WA_TranslucentBackground); @@ -449,6 +455,8 @@ void GhosttySurface::contextMenuEvent(QContextMenuEvent *ev) { add(&menu, "Paste", "edit-paste", "paste_from_clipboard", !QGuiApplication::clipboard()->text().isEmpty()); add(&menu, "Select All", "edit-select-all", "select_all", true); + add(&menu, "Notify on Next Command Finish", + "preferences-desktop-notification", "@notify-command", true); menu.addSeparator(); add(&menu, "Clear", "edit-clear-all", "clear_screen", true); add(&menu, "Reset", "view-refresh", "reset", true); @@ -484,6 +492,12 @@ void GhosttySurface::contextMenuEvent(QContextMenuEvent *ev) { if (!chosen || !m_surface) return; const QString data = chosen->data().toString(); + // Arm the one-shot "command finished" notification (no keybind action). + if (data == QLatin1String("@notify-command")) { + armCommandNotify(); + return; + } + // The title items have no apprt-side prompt in libghostty: collect the // text here and apply it with the set_*_title keybind action (an empty // title resets it). @@ -510,6 +524,31 @@ void GhosttySurface::contextMenuEvent(QContextMenuEvent *ev) { action.size()); } +void GhosttySurface::dragEnterEvent(QDragEnterEvent *ev) { + if (ev->mimeData()->hasUrls() || ev->mimeData()->hasText()) + ev->acceptProposedAction(); +} + +void GhosttySurface::dropEvent(QDropEvent *ev) { + const QMimeData *mime = ev->mimeData(); + QString text; + if (mime->hasUrls()) { + // Dropped files are inserted as shell-quoted, space-separated paths. + QStringList paths; + for (const QUrl &url : mime->urls()) { + QString p = url.isLocalFile() ? url.toLocalFile() : url.toString(); + p.replace(QLatin1String("'"), QLatin1String("'\\''")); + paths << QLatin1Char('\'') + p + QLatin1Char('\''); + } + text = paths.join(QLatin1Char(' ')); + } else if (mime->hasText()) { + text = mime->text(); + } + if (text.isEmpty()) return; + commitText(text); + ev->acceptProposedAction(); +} + void GhosttySurface::mouseMoveEvent(QMouseEvent *ev) { if (!m_surface) return; // ghostty_surface_mouse_pos wants unscaled (logical) coordinates — it diff --git a/qt/src/GhosttySurface.h b/qt/src/GhosttySurface.h index a4194f6c9..071fb4548 100644 --- a/qt/src/GhosttySurface.h +++ b/qt/src/GhosttySurface.h @@ -7,6 +7,8 @@ class MainWindow; class QContextMenuEvent; +class QDragEnterEvent; +class QDropEvent; class QInputMethodEvent; class QKeySequence; class QLabel; @@ -43,6 +45,15 @@ public: // 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 slots: // Render a fresh frame (the libghostty RENDER action). void requestRender(); @@ -58,6 +69,8 @@ protected: 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 focusInEvent(QFocusEvent *) override; void focusOutEvent(QFocusEvent *) override; @@ -112,4 +125,5 @@ private: double m_fbDpr = 1.0; // DPR the framebuffer was sized at QLabel *m_exitOverlay = nullptr; // "process exited" banner; lazily made + bool m_notifyOnCommand = false; // one-shot: notify on next cmd finish }; diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index 08f9cd598..22a52c48f 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -7,19 +7,26 @@ #include #include #include +#include +#include #include #include #include #include #include +#include +#include #include #include #include #include +#include #include #include +#include #include #include +#include #include #include "GhosttySurface.h" @@ -86,6 +93,26 @@ static bool configHasCustomShader() { return false; } +// Post a desktop notification via the freedesktop D-Bus service. +static void postNotification(const QString &title, const QString &body) { + QDBusMessage msg = QDBusMessage::createMethodCall( + QStringLiteral("org.freedesktop.Notifications"), + QStringLiteral("/org/freedesktop/Notifications"), + QStringLiteral("org.freedesktop.Notifications"), + QStringLiteral("Notify")); + msg.setArguments({ + QStringLiteral("Ghostty"), // app_name + uint(0), // replaces_id + QStringLiteral("utilities-terminal"), // app_icon + title, // summary + body, // body + QStringList(), // actions + QVariantMap(), // hints + -1, // expire_timeout (default) + }); + QDBusConnection::sessionBus().send(msg); // fire-and-forget +} + bool MainWindow::initialize() { // Load configuration in the same order as the reference apprt. m_config = ghostty_config_new(); @@ -406,6 +433,15 @@ void MainWindow::equalizeSplits(GhosttySurface *from) { } } +void MainWindow::moveTab(int amount) { + const int n = m_tabs->count(); + if (n < 2 || amount == 0) return; + const int from = m_tabs->currentIndex(); + const int to = std::clamp(from + amount, 0, n - 1); + if (to != from) + if (QTabBar *bar = m_tabs->findChild()) bar->moveTab(from, to); +} + // Push `config` to the app and every surface, and adopt it as the live // config. Takes ownership of `config` (frees the previous one). void MainWindow::applyConfig(ghostty_config_t config) { @@ -735,6 +771,59 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, return true; } + case GHOSTTY_ACTION_DESKTOP_NOTIFICATION: { + const ghostty_action_desktop_notification_s n = + action.action.desktop_notification; + const QString title = QString::fromUtf8(n.title ? n.title : ""); + const QString body = QString::fromUtf8(n.body ? n.body : ""); + QMetaObject::invokeMethod( + self, [title, body]() { postNotification(title, body); }, + Qt::QueuedConnection); + return true; + } + + case GHOSTTY_ACTION_COMMAND_FINISHED: { + if (!src) return true; + const int code = action.action.command_finished.exit_code; + QMetaObject::invokeMethod( + src, + [src, code]() { + if (!src->consumeCommandNotify()) return; + postNotification( + QStringLiteral("Command finished"), + code >= 0 ? QStringLiteral("Exited with code %1").arg(code) + : QStringLiteral("The command completed.")); + }, + Qt::QueuedConnection); + return true; + } + + case GHOSTTY_ACTION_MOVE_TAB: { + const int amount = static_cast(action.action.move_tab.amount); + QMetaObject::invokeMethod( + self, [self, amount]() { self->moveTab(amount); }, + Qt::QueuedConnection); + return true; + } + + case GHOSTTY_ACTION_MOUSE_VISIBILITY: { + if (!src) return false; + const bool hidden = + action.action.mouse_visibility == GHOSTTY_MOUSE_HIDDEN; + QMetaObject::invokeMethod( + src, + [src, hidden]() { + src->setCursor(hidden ? Qt::BlankCursor : Qt::ArrowCursor); + }, + Qt::QueuedConnection); + return true; + } + + case GHOSTTY_ACTION_RENDERER_HEALTH: + if (action.action.renderer_health == GHOSTTY_RENDERER_HEALTH_UNHEALTHY) + std::fprintf(stderr, "[ghostty] renderer reported unhealthy\n"); + return true; + default: // Inspector, command palette, search, etc. are not handled yet. return false; @@ -759,12 +848,33 @@ bool MainWindow::onReadClipboard(void *ud, ghostty_clipboard_e loc, void MainWindow::onConfirmReadClipboard(void *ud, const char *str, void *state, ghostty_clipboard_request_e) { - // The scaffold trusts pastes rather than showing an unsafe-paste - // confirmation dialog. TODO: a real confirmation prompt. + // libghostty asks for confirmation when a paste looks unsafe. The + // dialog MUST be deferred: this callback runs inside libghostty, and a + // modal dialog here spins a nested event loop that re-enters libghostty + // through the render tick — a crash/freeze. `state` is a completion + // token valid until used; `str` is not, so copy it. auto *surface = static_cast(ud); - if (surface && surface->surface()) - ghostty_surface_complete_clipboard_request(surface->surface(), str, state, - true); + if (!surface || !surface->surface()) return; + + const QByteArray content(str); + QMetaObject::invokeMethod( + surface->owner(), + [surface, content, state]() { + if (!surface->surface()) return; + QString preview = QString::fromUtf8(content); + if (preview.size() > 200) + preview = preview.left(200) + QStringLiteral("…"); + const auto reply = QMessageBox::warning( + surface->owner(), QStringLiteral("Confirm Paste"), + QStringLiteral("The text being pasted may be unsafe:\n\n%1\n\n" + "Paste anyway?") + .arg(preview), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + ghostty_surface_complete_clipboard_request( + surface->surface(), content.constData(), state, + reply == QMessageBox::Yes); + }, + Qt::QueuedConnection); } void MainWindow::onWriteClipboard(void *ud, ghostty_clipboard_e loc, diff --git a/qt/src/MainWindow.h b/qt/src/MainWindow.h index ce2fdb46a..f2d36fcec 100644 --- a/qt/src/MainWindow.h +++ b/qt/src/MainWindow.h @@ -74,6 +74,7 @@ private: 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` // Config: rebuild from disk (reloadConfig) or apply one libghostty // handed us (applyConfig), pushing it to the app and every surface. From 49021699c7468f7891fc556ecf261ec27eaca144 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 08:57:54 -0500 Subject: [PATCH 33/75] qt: coalesce the render loop so a busy surface can't stall the UI A chatty TUI (e.g. rmpc) made opening a new tab/split lag for seconds: onWakeup queued a tick for every libghostty wakeup, and every RENDER action queued a full renderTerminal (FBO render + GPU readback). Both were unbounded, so the event queue backed up and newly created surfaces sat behind the backlog. Coalesce both: - onWakeup queues a tick only when one is not already pending (std::atomic m_tickPending, cleared at the start of tick()). - RENDER actions just set an atomic per-surface dirty flag; no event is queued. A new MainWindow::frame() on the 16ms timer ticks libghostty and renders each dirty surface, capping render to ~60fps. Work per frame is now bounded regardless of how chatty a surface is, so tab/split creation is no longer starved. Co-Authored-By: claude-flow --- qt/src/GhosttySurface.cpp | 4 +++- qt/src/GhosttySurface.h | 12 +++++++++--- qt/src/MainWindow.cpp | 29 ++++++++++++++++++++--------- qt/src/MainWindow.h | 10 ++++++++++ 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index 3eda6bbec..308f2b442 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -158,7 +158,9 @@ bool GhosttySurface::event(QEvent *e) { return QWidget::event(e); } -void GhosttySurface::requestRender() { renderTerminal(); } +void GhosttySurface::renderIfDirty() { + if (m_dirty.exchange(false)) renderTerminal(); +} void GhosttySurface::renderTerminal() { if (!m_surface || !m_fbo || !makeCurrent()) return; diff --git a/qt/src/GhosttySurface.h b/qt/src/GhosttySurface.h index 071fb4548..09af1f62e 100644 --- a/qt/src/GhosttySurface.h +++ b/qt/src/GhosttySurface.h @@ -1,5 +1,7 @@ #pragma once +#include + #include #include @@ -54,9 +56,12 @@ public: return armed; } -public slots: - // Render a fresh frame (the libghostty RENDER action). - void requestRender(); +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(); protected: bool event(QEvent *) override; @@ -126,4 +131,5 @@ private: QLabel *m_exitOverlay = nullptr; // "process exited" banner; lazily made bool m_notifyOnCommand = false; // one-shot: notify on next cmd finish + std::atomic m_dirty{false}; // a frame render is pending }; diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index 22a52c48f..cc53a86af 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -139,9 +139,10 @@ bool MainWindow::initialize() { return false; } - // Periodic tick as a backstop; onWakeup drives responsive ticking. + // 60fps frame timer: a backstop tick plus rendering. onWakeup drives + // extra ticks between frames for input responsiveness. auto *timer = new QTimer(this); - connect(timer, &QTimer::timeout, this, &MainWindow::tick); + connect(timer, &QTimer::timeout, this, &MainWindow::frame); timer->start(16); // The first tab is created in showEvent, not here: see below. @@ -288,10 +289,18 @@ void MainWindow::setSurfaceTitle(GhosttySurface *surface, } void MainWindow::tick() { + // Cleared first so a wakeup during the tick re-queues another. + m_tickPending.store(false); if (!m_app) return; ghostty_app_tick(m_app); - // Process exit is handled by libghostty: a normal exit closes the - // surface via close_surface_cb; an abnormal one fires SHOW_CHILD_EXITED. +} + +void MainWindow::frame() { + if (!m_app) return; + ghostty_app_tick(m_app); + // Rendering happens only here, so a flood of RENDER actions cannot + // saturate the GUI thread — each surface renders at most once a frame. + for (GhosttySurface *s : m_surfaces) s->renderIfDirty(); } void MainWindow::onTabCloseRequested(int index) { closeTab(index); } @@ -510,9 +519,11 @@ void MainWindow::toggleSplitZoom(GhosttySurface *surface) { // --- libghostty runtime callbacks ------------------------------------ void MainWindow::onWakeup(void *ud) { - // app userdata; hop to the GUI thread to tick. + // app userdata. Coalesce: queue a tick only when one is not already + // pending, so a chatty surface cannot flood the event loop. auto *self = static_cast(ud); - QMetaObject::invokeMethod(self, "tick", Qt::QueuedConnection); + if (!self->m_tickPending.exchange(true)) + QMetaObject::invokeMethod(self, "tick", Qt::QueuedConnection); } // Map a libghostty mouse shape to the nearest Qt cursor. @@ -567,9 +578,9 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, // work is marshalled onto the GUI thread. switch (action.tag) { case GHOSTTY_ACTION_RENDER: - // libghostty wants a redraw; schedule one on the terminal window. - if (src) - QMetaObject::invokeMethod(src, "requestRender", Qt::QueuedConnection); + // Mark the surface dirty; the frame timer renders it. No event is + // queued here — a busy surface would otherwise flood the loop. + if (src) src->markDirty(); return true; case GHOSTTY_ACTION_NEW_TAB: diff --git a/qt/src/MainWindow.h b/qt/src/MainWindow.h index f2d36fcec..f2239271e 100644 --- a/qt/src/MainWindow.h +++ b/qt/src/MainWindow.h @@ -1,5 +1,7 @@ #pragma once +#include + #include #include @@ -64,6 +66,9 @@ private: // Create the first tab once the device pixel ratio has settled. void createFirstTab(); + // 60fps frame timer: ticks libghostty and renders any dirty surface. + void frame(); + void closeTab(int index); GhosttySurface *surfaceAt(int index) const; int tabIndexForSurface(GhosttySurface *surface) const; @@ -104,6 +109,11 @@ private: bool m_needsPremultiply = false; // a custom shader is configured bool m_firstTabPending = true; // first tab is created on show() + // Coalesces wakeup-driven ticks: a tick is queued at most once at a + // time, so a busy surface can't flood the event loop. Set from + // onWakeup (possibly off-thread), cleared at the start of tick(). + std::atomic m_tickPending{false}; + // 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; From 399717a422804937cbe878a27f65a78f4a2f0611 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 09:14:43 -0500 Subject: [PATCH 34/75] qt: document building libghostty with ReleaseFast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build comment recommended -Doptimize=Debug, which leaves the entire terminal core unoptimized — rendering and surface creation (the per-surface renderer/font-atlas setup in ghostty_surface_new) run several times slower. ReleaseFast makes tab/split creation snappy. Co-Authored-By: claude-flow --- qt/CMakeLists.txt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/qt/CMakeLists.txt b/qt/CMakeLists.txt index 61757e684..5143be2b0 100644 --- a/qt/CMakeLists.txt +++ b/qt/CMakeLists.txt @@ -10,9 +10,11 @@ project(ghostty LANGUAGES CXX) # 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: +# 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=Debug +# zig build -Dapp-runtime=none -Doptimize=ReleaseFast # # Then build and run this app: # From 00f64470eb96e8624830c7b545a48ff2e95a5150 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 09:22:45 -0500 Subject: [PATCH 35/75] qt: desktop integration and a CMake install target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Qt frontend had no desktop integration. Add: - qt/dist/ghostty.desktop — a desktop entry (TerminalEmulator category, StartupWMClass=ghostty). - main.cpp sets QGuiApplication::setDesktopFileName("ghostty") so the Wayland app-id / X11 WM_CLASS matches the entry and the compositor associates the window with it (taskbar icon, launcher identity). - An install target: the binary, libghostty.so (its SONAME), the desktop entry, and Ghostty's PNG icons into the hicolor theme. RPATH is now CMake-managed — BUILD_RPATH for running from the build tree, INSTALL_RPATH=$ORIGIN/../lib for the installed binary. cmake --install build --prefix ~/.local Co-Authored-By: claude-flow --- qt/CMakeLists.txt | 30 +++++++++++++++++++++++++++--- qt/dist/ghostty.desktop | 13 +++++++++++++ qt/src/main.cpp | 5 +++++ 3 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 qt/dist/ghostty.desktop diff --git a/qt/CMakeLists.txt b/qt/CMakeLists.txt index 5143be2b0..197746c3e 100644 --- a/qt/CMakeLists.txt +++ b/qt/CMakeLists.txt @@ -16,15 +16,18 @@ project(ghostty LANGUAGES CXX) # # zig build -Dapp-runtime=none -Doptimize=ReleaseFast # -# Then build and run this app: +# Then build, run, and (optionally) install this app: # # cmake -S qt -B qt/build && cmake --build qt/build # ./qt/build/ghostty +# 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) # libghostty is built out-of-tree by Zig. @@ -59,6 +62,27 @@ target_link_libraries(ghostty PRIVATE "${GHOSTTY_LIB_DIR}/libghostty.so" ) -target_link_options(ghostty PRIVATE - "-Wl,-rpath,${GHOSTTY_LIB_DIR}" +# RPATH: the build-tree binary finds libghostty.so in zig-out/lib; the +# installed binary finds it next to itself ($ORIGIN/../lib). +set_target_properties(ghostty PROPERTIES + BUILD_RPATH "${GHOSTTY_LIB_DIR}" + INSTALL_RPATH "$ORIGIN/../${CMAKE_INSTALL_LIBDIR}" ) + +# --- install --------------------------------------------------------- +install(TARGETS ghostty 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/ghostty.desktop + DESTINATION "${CMAKE_INSTALL_DATADIR}/applications") + +# Reuse Ghostty's own PNG icons, installed into the hicolor theme. +foreach(sz 16 32 128 256 512) + install(FILES "${GHOSTTY_ROOT}/images/icons/icon_${sz}.png" + DESTINATION "${CMAKE_INSTALL_DATADIR}/icons/hicolor/${sz}x${sz}/apps" + RENAME ghostty.png) +endforeach() diff --git a/qt/dist/ghostty.desktop b/qt/dist/ghostty.desktop new file mode 100644 index 000000000..df90eee55 --- /dev/null +++ b/qt/dist/ghostty.desktop @@ -0,0 +1,13 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=Ghostty +GenericName=Terminal +Comment=A terminal emulator +Exec=ghostty +Icon=ghostty +Categories=System;TerminalEmulator; +Keywords=terminal;tty;pty; +StartupNotify=true +StartupWMClass=ghostty +Terminal=false diff --git a/qt/src/main.cpp b/qt/src/main.cpp index 435f1c1ec..e042fecdd 100644 --- a/qt/src/main.cpp +++ b/qt/src/main.cpp @@ -26,6 +26,11 @@ int main(int argc, char **argv) { QApplication app(argc, argv); + // Match the installed ghostty.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("ghostty")); + // 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 From f952bd2d527e0d4f3749ed3ab401543db471740b Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 09:24:43 -0500 Subject: [PATCH 36/75] =?UTF-8?q?qt:=20handle=20PROGRESS=5FREPORT=20?= =?UTF-8?q?=E2=80=94=20taskbar=20progress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OSC 9;4 progress reports now drive the task-manager progress bar: postProgress() emits the Unity LauncherEntry Update D-Bus signal (honored by the KDE task manager), keyed to application://ghostty.desktop. The onAction case maps the report state to visibility (REMOVE hides it) and the 0-100 progress to a 0-1 fraction. Relies on the window being associated with ghostty.desktop, which the packaging change established. Co-Authored-By: claude-flow --- qt/src/MainWindow.cpp | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index cc53a86af..9d669945a 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -113,6 +113,21 @@ static void postNotification(const QString &title, const QString &body) { QDBusConnection::sessionBus().send(msg); // fire-and-forget } +// Drive the taskbar progress bar via the Unity LauncherEntry D-Bus API +// (honored by the KDE task manager), keyed to ghostty.desktop. +static void postProgress(bool visible, double fraction) { + QDBusMessage msg = QDBusMessage::createSignal( + QStringLiteral("/com/canonical/unity/launcherentry/ghostty"), + QStringLiteral("com.canonical.Unity.LauncherEntry"), + QStringLiteral("Update")); + QVariantMap props; + props[QStringLiteral("progress")] = fraction; + props[QStringLiteral("progress-visible")] = visible; + msg.setArguments( + {QStringLiteral("application://ghostty.desktop"), QVariant(props)}); + QDBusConnection::sessionBus().send(msg); +} + bool MainWindow::initialize() { // Load configuration in the same order as the reference apprt. m_config = ghostty_config_new(); @@ -835,6 +850,16 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, std::fprintf(stderr, "[ghostty] renderer reported unhealthy\n"); return true; + case GHOSTTY_ACTION_PROGRESS_REPORT: { + const ghostty_action_progress_report_s p = action.action.progress_report; + const bool visible = p.state != GHOSTTY_PROGRESS_STATE_REMOVE; + const double fraction = p.progress >= 0 ? p.progress / 100.0 : 0.0; + QMetaObject::invokeMethod( + self, [visible, fraction]() { postProgress(visible, fraction); }, + Qt::QueuedConnection); + return true; + } + default: // Inspector, command palette, search, etc. are not handled yet. return false; From e324a1632c0eae800d70737505dd72ef1d8e837d Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 09:35:49 -0500 Subject: [PATCH 37/75] qt: scrollback scrollbar Each GhosttySurface gets a themed QScrollBar on its right edge, driven by libghostty's SCROLLBAR action ({total, offset, len} rows). The terminal renders into the width left of the scrollbar strip; dragging the thumb runs the scroll_to_row keybind action (the action's row coordinate matches the SCROLLBAR offset). Range updates are wrapped in a QSignalBlocker so they don't echo back as a scroll_to_row. Visibility honors the `scrollbar` config (read via ghostty_config_get, which returns the enum as its tag name): `never` hides it unconditionally, `system` shows it whenever there is scrollback. Read fresh per update so a config reload re-applies the policy. Co-Authored-By: claude-flow --- qt/src/GhosttySurface.cpp | 55 ++++++++++++++++++++++++++++++++++++++- qt/src/GhosttySurface.h | 8 ++++++ qt/src/MainWindow.cpp | 10 +++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index 308f2b442..ca5f34dcc 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -30,6 +30,8 @@ #include #include #include +#include +#include #include #include #include @@ -44,6 +46,16 @@ GhosttySurface::GhosttySurface(ghostty_app_t app, MainWindow *owner, setMouseTracking(true); // deliver motion events for hover/link detection setAttribute(Qt::WA_InputMethodEnabled, true); // IME composition setAcceptDrops(true); // file / text drops + + // Scrollback scrollbar: driven by SCROLLBAR actions, hidden until one + // reports scrollback. Dragging it runs libghostty's scroll_to_row. + m_scrollbar = new QScrollBar(Qt::Vertical, this); + m_scrollbar->hide(); + connect(m_scrollbar, &QScrollBar::valueChanged, this, [this](int v) { + if (!m_surface) return; + const QByteArray a = "scroll_to_row:" + QByteArray::number(v); + ghostty_surface_binding_action(m_surface, a.constData(), a.size()); + }); // The widget paints a per-pixel-alpha QImage of the terminal; a // translucent background lets that alpha reach the desktop. setAttribute(Qt::WA_TranslucentBackground); @@ -124,7 +136,11 @@ void GhosttySurface::syncSurfaceSize() { // is the true (possibly fractional) scale because main() selects the // PassThrough rounding policy. const double dpr = devicePixelRatioF(); - const int w = std::max(1, static_cast(width() * dpr)); + // The terminal renders into the width left of the scrollbar strip. + const int sbw = (m_scrollbar && m_scrollbar->isVisible()) + ? m_scrollbar->sizeHint().width() + : 0; + const int w = std::max(1, static_cast((width() - sbw) * dpr)); const int h = std::max(1, static_cast(height() * dpr)); if (w == m_fbw && h == m_fbh && dpr == m_fbDpr) return; m_fbw = w; @@ -144,6 +160,7 @@ void GhosttySurface::syncSurfaceSize() { } void GhosttySurface::resizeEvent(QResizeEvent *) { + layoutScrollbar(); syncSurfaceSize(); if (m_exitOverlay) m_exitOverlay->setGeometry(rect()); } @@ -162,6 +179,42 @@ void GhosttySurface::renderIfDirty() { if (m_dirty.exchange(false)) renderTerminal(); } +void GhosttySurface::layoutScrollbar() { + if (!m_scrollbar || !m_scrollbar->isVisible()) return; + const int w = m_scrollbar->sizeHint().width(); + m_scrollbar->setGeometry(width() - w, 0, w, height()); +} + +// `scrollbar = never` in the config hides the scrollbar unconditionally; +// `system` (the default) shows it whenever there is scrollback. +bool GhosttySurface::scrollbarAllowed() const { + if (!m_owner || !m_owner->config()) return true; + const char *value = nullptr; + if (ghostty_config_get(m_owner->config(), &value, "scrollbar", + qstrlen("scrollbar")) && + value) + return qstrcmp(value, "never") != 0; + return true; // unknown — default to showing +} + +void GhosttySurface::updateScrollbar(uint64_t total, uint64_t offset, + uint64_t len) { + const bool visible = scrollbarAllowed() && total > len; + if (visible != m_scrollbar->isVisible()) { + m_scrollbar->setVisible(visible); + layoutScrollbar(); + syncSurfaceSize(); // the terminal's available width changed + } + if (!visible) return; + + // Update the range without echoing back through valueChanged as a + // scroll_to_row (which would fight libghostty's own scroll state). + const QSignalBlocker block(m_scrollbar); + m_scrollbar->setRange(0, static_cast(total - len)); + m_scrollbar->setPageStep(static_cast(len)); + m_scrollbar->setValue(static_cast(offset)); +} + void GhosttySurface::renderTerminal() { if (!m_surface || !m_fbo || !makeCurrent()) return; diff --git a/qt/src/GhosttySurface.h b/qt/src/GhosttySurface.h index 09af1f62e..cb6a584f9 100644 --- a/qt/src/GhosttySurface.h +++ b/qt/src/GhosttySurface.h @@ -19,6 +19,7 @@ class QOpenGLContext; class QOpenGLFramebufferObject; class QOpenGLShaderProgram; class QOpenGLVertexArrayObject; +class QScrollBar; // One Ghostty terminal pane. // @@ -63,6 +64,10 @@ public: 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); + protected: bool event(QEvent *) override; void paintEvent(QPaintEvent *) override; @@ -89,6 +94,8 @@ private: bool makeCurrent(); void syncSurfaceSize(); void renderTerminal(); + void layoutScrollbar(); // position the scrollbar at the edge + bool scrollbarAllowed() const; // false when `scrollbar = never` void buildExitOverlay(int exitCode); void sendKey(QKeyEvent *, ghostty_input_action_e action); void commitText(const QString &text); @@ -130,6 +137,7 @@ private: double m_fbDpr = 1.0; // DPR the framebuffer was sized at QLabel *m_exitOverlay = nullptr; // "process exited" banner; lazily made + QScrollBar *m_scrollbar = nullptr; // scrollback scrollbar; hidden by default bool m_notifyOnCommand = false; // one-shot: notify on next cmd finish std::atomic m_dirty{false}; // a frame render is pending }; diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index 9d669945a..67b2a8344 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -850,6 +850,16 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, std::fprintf(stderr, "[ghostty] renderer reported unhealthy\n"); return true; + case GHOSTTY_ACTION_SCROLLBAR: { + if (!src) return false; + const ghostty_action_scrollbar_s s = action.action.scrollbar; + QMetaObject::invokeMethod( + src, + [src, s]() { src->updateScrollbar(s.total, s.offset, s.len); }, + Qt::QueuedConnection); + return true; + } + case GHOSTTY_ACTION_PROGRESS_REPORT: { const ghostty_action_progress_report_s p = action.action.progress_report; const bool visible = p.state != GHOSTTY_PROGRESS_STATE_REMOVE; From 4946bb2f595ed73c776f41d8e6d1841fa41688ad Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 09:56:59 -0500 Subject: [PATCH 38/75] libghostty+qt: IME candidate window tracks the terminal cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ghostty_surface_cursor_position to the embedded C API — it returns the cursor's pixel rectangle in the surface's device-pixel space, computed from the cursor cell, cell size, and padding under the renderer mutex. GhosttySurface::inputMethodQuery(ImCursorRectangle) uses it (device px to logical via the surface DPR) so the IME candidate window appears at the cursor instead of a fixed corner. Co-Authored-By: claude-flow --- include/ghostty.h | 9 +++++++++ qt/src/GhosttySurface.cpp | 16 ++++++++++++---- src/apprt/embedded.zig | 28 ++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 40f7b04eb..310bcceb4 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -515,6 +515,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 @@ -1141,6 +1148,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, diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index ca5f34dcc..82305f2af 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -662,10 +662,18 @@ QVariant GhosttySurface::inputMethodQuery(Qt::InputMethodQuery query) const { switch (query) { case Qt::ImEnabled: return true; - case Qt::ImCursorRectangle: - // Approximate anchor for the candidate window; tracking the real - // terminal cursor cell is a follow-up. - return QRect(4, height() - 4, 1, 1); + case Qt::ImCursorRectangle: { + // Anchor the IME candidate window at the terminal cursor. + // libghostty reports the cursor in device pixels; the IME wants + // logical widget coordinates, so divide by the surface's DPR. + if (!m_surface) return QRect(); + const ghostty_surface_cursor_position_s c = + ghostty_surface_cursor_position(m_surface); + const double dpr = m_fbDpr > 0 ? m_fbDpr : 1.0; + return QRect(static_cast(c.x / dpr), static_cast(c.y / dpr), + std::max(1, static_cast(c.width / dpr)), + std::max(1, static_cast(c.height / dpr))); + } default: return QWidget::inputMethodQuery(query); } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 97c769013..07a47c4f9 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1343,6 +1343,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, @@ -1771,6 +1779,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; From aa95c8fb59b51221c37c690d2f165fc78852e3f1 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 09:56:59 -0500 Subject: [PATCH 39/75] qt: honor bell-features for the terminal bell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ringBell() reads the bell-features config (a packed struct, read as a bitfield via ghostty_config_get) and honors `attention` (taskbar flash), `system` (QApplication::beep), and `audio` — playing bell-audio-path at bell-audio-volume through a lazily-created QMediaPlayer (new Qt6::Multimedia dependency). bell-audio-path / bell-audio-volume are read by scanning the config file (the configHasCustomShader pattern, generalized to configValue). The `title` and `border` bell features are not yet handled. Co-Authored-By: claude-flow --- qt/CMakeLists.txt | 3 ++- qt/src/MainWindow.cpp | 61 +++++++++++++++++++++++++++++++++++++++---- qt/src/MainWindow.h | 10 +++++++ 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/qt/CMakeLists.txt b/qt/CMakeLists.txt index 197746c3e..455039b29 100644 --- a/qt/CMakeLists.txt +++ b/qt/CMakeLists.txt @@ -28,7 +28,7 @@ set(CMAKE_AUTOMOC ON) include(GNUInstallDirs) -find_package(Qt6 REQUIRED COMPONENTS Gui Widgets OpenGL DBus) +find_package(Qt6 REQUIRED COMPONENTS Gui Widgets OpenGL DBus Multimedia) # libghostty is built out-of-tree by Zig. get_filename_component(GHOSTTY_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/.." ABSOLUTE) @@ -59,6 +59,7 @@ target_link_libraries(ghostty PRIVATE Qt6::Widgets Qt6::OpenGL Qt6::DBus + Qt6::Multimedia "${GHOSTTY_LIB_DIR}/libghostty.so" ) diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index 67b2a8344..4be8b2a1c 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -15,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -93,6 +95,27 @@ static bool configHasCustomShader() { return false; } +// Scan the primary Ghostty config file for `key = value`, returning the +// last matching value (empty if absent). For keys not cleanly exposed by +// ghostty_config_get. +static QString configValue(const QString &key) { + QString dir = qEnvironmentVariable("XDG_CONFIG_HOME"); + if (dir.isEmpty()) dir = QDir::homePath() + QStringLiteral("/.config"); + + QFile f(dir + QStringLiteral("/ghostty/config")); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) return {}; + + const QByteArray wanted = key.toUtf8(); + QString value; + while (!f.atEnd()) { + const QByteArray line = f.readLine().trimmed(); + const int eq = line.indexOf('='); + if (eq < 0 || line.left(eq).trimmed() != wanted) continue; + value = QString::fromUtf8(line.mid(eq + 1).trimmed()); + } + return value; +} + // Post a desktop notification via the freedesktop D-Bus service. static void postNotification(const QString &title, const QString &body) { QDBusMessage msg = QDBusMessage::createMethodCall( @@ -466,6 +489,37 @@ void MainWindow::moveTab(int amount) { if (QTabBar *bar = m_tabs->findChild()) bar->moveTab(from, to); } +void MainWindow::ringBell() { + // bell-features is a packed struct, returned by ghostty_config_get as + // a bitfield: bit 0 system, 1 audio, 2 attention, 3 title, 4 border. + unsigned int features = 1u << 2; // fall back to `attention` + ghostty_config_get(m_config, &features, "bell-features", + qstrlen("bell-features")); + if (features & (1u << 2)) QApplication::alert(this); // attention + if (features & (1u << 0)) QApplication::beep(); // system + if (features & (1u << 1)) playBellAudio(); // audio +} + +void MainWindow::playBellAudio() { + QString path = configValue(QStringLiteral("bell-audio-path")); + if (path.isEmpty()) return; + if (path.startsWith(QLatin1String("~/"))) + path = QDir::homePath() + path.mid(1); + + bool ok = false; + const double volume = + configValue(QStringLiteral("bell-audio-volume")).toDouble(&ok); + + if (!m_bellPlayer) { + m_bellAudio = new QAudioOutput(this); + m_bellPlayer = new QMediaPlayer(this); + m_bellPlayer->setAudioOutput(m_bellAudio); + } + m_bellAudio->setVolume(ok ? volume : 0.5); + m_bellPlayer->setSource(QUrl::fromLocalFile(path)); + m_bellPlayer->play(); +} + // Push `config` to the app and every surface, and adopt it as the live // config. Takes ownership of `config` (frees the previous one). void MainWindow::applyConfig(ghostty_config_t config) { @@ -756,11 +810,8 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, return true; case GHOSTTY_ACTION_RING_BELL: - // Taskbar/window attention hint. Honoring `bell-features` config - // (audio file, volume) is a future refinement. - QMetaObject::invokeMethod( - self, [self]() { QApplication::alert(self); }, - Qt::QueuedConnection); + QMetaObject::invokeMethod(self, [self]() { self->ringBell(); }, + Qt::QueuedConnection); return true; case GHOSTTY_ACTION_MOUSE_SHAPE: { diff --git a/qt/src/MainWindow.h b/qt/src/MainWindow.h index f2239271e..d66c2c4c8 100644 --- a/qt/src/MainWindow.h +++ b/qt/src/MainWindow.h @@ -7,6 +7,8 @@ #include "ghostty.h" +class QAudioOutput; +class QMediaPlayer; class QShowEvent; class QSplitter; class QTabWidget; @@ -81,6 +83,10 @@ private: 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(); + void playBellAudio(); + // Config: rebuild from disk (reloadConfig) or apply one libghostty // handed us (applyConfig), pushing it to the app and every surface. void reloadConfig(); @@ -120,4 +126,8 @@ private: 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; }; From 2db4f345e7f09e2dcc97cdcfe0ce6bb2a14c60fb Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 10:07:03 -0500 Subject: [PATCH 40/75] qt: honor bell-features title and border ringBell now handles all five bell-features flags. `border` flashes a brief red rect over the terminal via GhosttySurface::flashBorder(); `title` marks a background tab's title with a bullet prefix while a surface in it has an unacknowledged bell, cleared when the tab is viewed. RING_BELL passes the originating surface through so the right pane/tab is marked. Co-Authored-By: claude-flow --- qt/src/GhosttySurface.cpp | 17 +++++++++++++++++ qt/src/GhosttySurface.h | 9 +++++++++ qt/src/MainWindow.cpp | 36 +++++++++++++++++++++++++++++++++--- qt/src/MainWindow.h | 7 ++++++- 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index 82305f2af..ebb5dbd5f 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -249,6 +249,23 @@ void GhosttySurface::paintEvent(QPaintEvent *) { // the terminal image, alpha included, so its translucency is kept. painter.setCompositionMode(QPainter::CompositionMode_Source); painter.drawImage(QPointF(0, 0), m_image); + + // Bell `border` feature: a brief attention flash over the terminal. + if (m_bellFlash) { + painter.setCompositionMode(QPainter::CompositionMode_SourceOver); + painter.setPen(QPen(QColor(255, 96, 96, 230), 3)); + painter.setBrush(Qt::NoBrush); + painter.drawRect(QRectF(rect()).adjusted(1.5, 1.5, -1.5, -1.5)); + } +} + +void GhosttySurface::flashBorder() { + m_bellFlash = true; + update(); + QTimer::singleShot(160, this, [this]() { + m_bellFlash = false; + update(); + }); } void GhosttySurface::showChildExited(int exitCode) { diff --git a/qt/src/GhosttySurface.h b/qt/src/GhosttySurface.h index cb6a584f9..2f6ef73b6 100644 --- a/qt/src/GhosttySurface.h +++ b/qt/src/GhosttySurface.h @@ -68,6 +68,13 @@ public: // viewport-top row, and the visible row count. void updateScrollbar(uint64_t total, uint64_t offset, uint64_t len); + // 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; @@ -139,5 +146,7 @@ private: QLabel *m_exitOverlay = nullptr; // "process exited" banner; lazily made QScrollBar *m_scrollbar = nullptr; // scrollback scrollbar; hidden by default 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 std::atomic m_dirty{false}; // a frame render is pending }; diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index 4be8b2a1c..a599f08f3 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -33,6 +33,9 @@ #include "GhosttySurface.h" +// Prefix marking a tab with an unacknowledged bell (bell-features title). +static const QString kBellMark = QStringLiteral("● "); + MainWindow::MainWindow() { setWindowTitle(QStringLiteral("Ghostty (Qt)")); // Let a translucent terminal background show through to the desktop. @@ -321,7 +324,8 @@ void MainWindow::setSurfaceTitle(GhosttySurface *surface, const QString &title) { const int index = tabIndexForSurface(surface); if (index < 0) return; - m_tabs->setTabText(index, title); + m_tabs->setTabText(index, + tabBellMarked(index) ? kBellMark + title : title); if (index == m_tabs->currentIndex()) setWindowTitle(title + QStringLiteral(" — Ghostty")); } @@ -347,6 +351,9 @@ void MainWindow::onCurrentChanged(int index) { GhosttySurface *s = surfaceAt(index); if (!s) return; s->setFocus(); + // Acknowledge any bell `title` mark now that the tab is visible. + for (GhosttySurface *surf : surfacesInTab(index)) surf->setBellTitle(false); + refreshTabTitle(index); setWindowTitle(m_tabs->tabText(index) + QStringLiteral(" — Ghostty")); } @@ -489,7 +496,7 @@ void MainWindow::moveTab(int amount) { if (QTabBar *bar = m_tabs->findChild()) bar->moveTab(from, to); } -void MainWindow::ringBell() { +void MainWindow::ringBell(GhosttySurface *surface) { // bell-features is a packed struct, returned by ghostty_config_get as // a bitfield: bit 0 system, 1 audio, 2 attention, 3 title, 4 border. unsigned int features = 1u << 2; // fall back to `attention` @@ -498,6 +505,29 @@ void MainWindow::ringBell() { if (features & (1u << 2)) QApplication::alert(this); // attention if (features & (1u << 0)) QApplication::beep(); // system if (features & (1u << 1)) playBellAudio(); // audio + + if (!surface) return; + if (features & (1u << 4)) surface->flashBorder(); // border + if (features & (1u << 3)) { // title + const int tab = tabIndexForSurface(surface); + // Marking the current tab is pointless — you are looking at it. + if (tab >= 0 && tab != m_tabs->currentIndex()) { + surface->setBellTitle(true); + refreshTabTitle(tab); + } + } +} + +bool MainWindow::tabBellMarked(int tab) const { + for (GhosttySurface *s : surfacesInTab(tab)) + if (s->bellTitle()) return true; + return false; +} + +void MainWindow::refreshTabTitle(int tab) { + QString text = m_tabs->tabText(tab); + if (text.startsWith(kBellMark)) text = text.mid(kBellMark.size()); + m_tabs->setTabText(tab, tabBellMarked(tab) ? kBellMark + text : text); } void MainWindow::playBellAudio() { @@ -810,7 +840,7 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, return true; case GHOSTTY_ACTION_RING_BELL: - QMetaObject::invokeMethod(self, [self]() { self->ringBell(); }, + QMetaObject::invokeMethod(self, [self, src]() { self->ringBell(src); }, Qt::QueuedConnection); return true; diff --git a/qt/src/MainWindow.h b/qt/src/MainWindow.h index d66c2c4c8..7081b15c1 100644 --- a/qt/src/MainWindow.h +++ b/qt/src/MainWindow.h @@ -84,9 +84,14 @@ private: void moveTab(int amount); // reorder the current tab by `amount` // Ring the terminal bell, honoring the `bell-features` config. - void ringBell(); + 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; + void refreshTabTitle(int tab); + // Config: rebuild from disk (reloadConfig) or apply one libghostty // handed us (applyConfig), pushing it to the app and every surface. void reloadConfig(); From 3403232bfb8a2adfddfc9a84c6be781251d6319b Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 10:31:28 -0500 Subject: [PATCH 41/75] qt: honor Tier 2 apprt-level config options Add typed ghostty_config_get wrappers (configString handles enum keys, which libghostty returns as tag-name strings) and honor six apprt-level config options the Qt frontend previously ignored: - maximize / fullscreen: applied as startup window state. - window-show-tab-bar: always / auto / never tab-bar visibility. - window-new-tab-position: insert after current, or append. - focus-follows-mouse: GhosttySurface grabs focus on pointer enter. - window-theme: force the Qt chrome's light/dark scheme (ghostty derives it from the background colour luminance); needs Qt 6.8+. - window-decoration: `none` drops the native window frame. Tab-bar and theme settings re-apply on config reload via applyConfig. Co-Authored-By: claude-flow --- qt/src/GhosttySurface.cpp | 5 +++ qt/src/GhosttySurface.h | 2 + qt/src/MainWindow.cpp | 84 ++++++++++++++++++++++++++++++++++++++- qt/src/MainWindow.h | 13 ++++++ 4 files changed, 103 insertions(+), 1 deletion(-) diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index ebb5dbd5f..9fc6a51aa 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -638,6 +638,11 @@ void GhosttySurface::wheelEvent(QWheelEvent *ev) { ghostty_surface_mouse_scroll(m_surface, d.x() / 120.0, d.y() / 120.0, 0); } +void GhosttySurface::enterEvent(QEnterEvent *) { + // focus-follows-mouse: take focus when the pointer enters this pane. + if (m_owner && m_owner->focusFollowsMouse() && !hasFocus()) setFocus(); +} + void GhosttySurface::focusInEvent(QFocusEvent *) { if (m_surface) ghostty_surface_set_focus(m_surface, true); } diff --git a/qt/src/GhosttySurface.h b/qt/src/GhosttySurface.h index 2f6ef73b6..d0bef75cd 100644 --- a/qt/src/GhosttySurface.h +++ b/qt/src/GhosttySurface.h @@ -11,6 +11,7 @@ class MainWindow; class QContextMenuEvent; class QDragEnterEvent; class QDropEvent; +class QEnterEvent; class QInputMethodEvent; class QKeySequence; class QLabel; @@ -89,6 +90,7 @@ protected: 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; diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index a599f08f3..f52c7ffbd 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -164,6 +165,22 @@ bool MainWindow::initialize() { m_needsPremultiply = configHasCustomShader(); + // Startup-only window state, applied before main() calls show(). + // window-decoration `none` drops the native frame; `auto`/`server`/ + // `client` keep a decorated window (the compositor picks the side on + // Wayland). + if (configString("window-decoration") == QLatin1String("none")) + setWindowFlag(Qt::FramelessWindowHint, true); + // fullscreen wins over maximize; its enum is `false` when unset. + const QString fullscreen = configString("fullscreen"); + if (!fullscreen.isEmpty() && fullscreen != QLatin1String("false")) + setWindowState(windowState() | Qt::WindowFullScreen); + else if (configBool("maximize", false)) + setWindowState(windowState() | Qt::WindowMaximized); + + // Tab-bar policy and colour scheme. + applyWindowConfig(); + ghostty_runtime_config_s rt = {}; rt.userdata = this; rt.supports_selection_clipboard = true; @@ -229,7 +246,15 @@ GhosttySurface *MainWindow::newTab(ghostty_surface_t parent) { pageLayout->setContentsMargins(0, 0, 0, 0); pageLayout->addWidget(surface); - const int index = m_tabs->addTab(page, QStringLiteral("Ghostty")); + // window-new-tab-position: place the tab right after the current one, + // or append it at the end (the default). + int index; + if (configString("window-new-tab-position") == QLatin1String("current") && + m_tabs->count() > 0) + index = m_tabs->insertTab(m_tabs->currentIndex() + 1, page, + QStringLiteral("Ghostty")); + else + index = m_tabs->addTab(page, QStringLiteral("Ghostty")); m_tabs->setCurrentIndex(index); surface->setFocus(); return surface; @@ -561,6 +586,9 @@ void MainWindow::applyConfig(ghostty_config_t config) { if (m_config && m_config != config) ghostty_config_free(m_config); m_config = config; m_needsPremultiply = configHasCustomShader(); + + // Re-apply window settings that a reload may have changed. + applyWindowConfig(); } void MainWindow::reloadConfig() { @@ -573,6 +601,60 @@ void MainWindow::reloadConfig() { applyConfig(config); } +QString MainWindow::configString(const char *key) const { + const char *value = nullptr; + if (!m_config || + !ghostty_config_get(m_config, &value, key, qstrlen(key)) || !value) + return {}; + return QString::fromUtf8(value); +} + +bool MainWindow::configBool(const char *key, bool fallback) const { + bool value = fallback; // ghostty_config_get leaves it untouched if absent + if (m_config) ghostty_config_get(m_config, &value, key, qstrlen(key)); + return value; +} + +bool MainWindow::focusFollowsMouse() const { + return configBool("focus-follows-mouse", false); +} + +void MainWindow::applyWindowConfig() { + // window-show-tab-bar: always shown / auto-hidden with a lone tab / + // never shown. + const QString tabBar = configString("window-show-tab-bar"); + if (tabBar == QLatin1String("never")) { + m_tabs->setTabBarAutoHide(false); + m_tabs->tabBar()->hide(); + } else if (tabBar == QLatin1String("always")) { + m_tabs->setTabBarAutoHide(false); + m_tabs->tabBar()->show(); + } else { // auto (the default) + m_tabs->tabBar()->show(); + m_tabs->setTabBarAutoHide(true); + } + + // window-theme: force a light/dark scheme, or follow the OS. `auto` + // is rewritten to `system` on Linux by libghostty; `ghostty` follows + // the configured background colour's luminance. +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + const QString theme = configString("window-theme"); + Qt::ColorScheme scheme = Qt::ColorScheme::Unknown; // Unknown = follow OS + if (theme == QLatin1String("dark")) { + scheme = Qt::ColorScheme::Dark; + } else if (theme == QLatin1String("light")) { + scheme = Qt::ColorScheme::Light; + } else if (theme == QLatin1String("ghostty")) { + ghostty_config_color_s bg{}; + if (ghostty_config_get(m_config, &bg, "background", qstrlen("background"))) { + const double luma = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b; + scheme = luma < 128.0 ? Qt::ColorScheme::Dark : Qt::ColorScheme::Light; + } + } + QGuiApplication::styleHints()->setColorScheme(scheme); +#endif +} + void MainWindow::toggleSplitZoom(GhosttySurface *surface) { // Already zoomed: restore the surface into its splitter and the // stashed tree back into the tab page. diff --git a/qt/src/MainWindow.h b/qt/src/MainWindow.h index 7081b15c1..bfea7af3c 100644 --- a/qt/src/MainWindow.h +++ b/qt/src/MainWindow.h @@ -53,6 +53,10 @@ public: // before Qt composites (see GhosttySurface::premultiplyFramebuffer). bool needsPremultiply() const { return m_needsPremultiply; } + // Whether `focus-follows-mouse` is enabled — a GhosttySurface grabs + // focus when the pointer enters it. + bool focusFollowsMouse() const; + public slots: void tick(); @@ -97,6 +101,15 @@ private: void reloadConfig(); void applyConfig(ghostty_config_t config); + // 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(); + // Toggle a split pane filling its tab. Re-parents the surface out of // / back into the splitter tree. void toggleSplitZoom(GhosttySurface *surface); From 7ab79bf8614273e68114cab5ab4b0dc72398c36f Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 10:49:52 -0500 Subject: [PATCH 42/75] qt: real multi-window, close confirmation, quit handling The libghostty app and config become process-shared statics across all windows, tracked in an s_windows registry; each window self-owns via WA_DeleteOnClose and the shared app is freed with the last window. Actions are routed to the correct window through the target surface's owner() rather than a single window. - NEW_WINDOW now opens a genuine separate top-level window (it was aliased to newTab); new windows inherit the cwd from the originator. - confirm-close-surface: closing a window/tab/pane with a running process prompts via ghostty_surface_needs_confirm_quit; quit and close-all prompt once for the whole app. - quit-after-last-window-closed (+delay): the default uses Qt's native quit-on-last-window; `false` keeps the process alive windowless; a configured delay is honored through the libghostty quit_timer action. Co-Authored-By: claude-flow --- qt/src/MainWindow.cpp | 398 ++++++++++++++++++++++++++++++------------ qt/src/MainWindow.h | 63 +++++-- qt/src/main.cpp | 7 +- 3 files changed, 335 insertions(+), 133 deletions(-) diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index f52c7ffbd..ac1aa9d3a 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -37,6 +38,15 @@ // Prefix marking a tab with an unacknowledged bell (bell-features title). static const QString kBellMark = QStringLiteral("● "); +// Process-shared libghostty state — see MainWindow.h. +ghostty_app_t MainWindow::s_app = nullptr; +ghostty_config_t MainWindow::s_config = nullptr; +bool MainWindow::s_needsPremultiply = false; +QList MainWindow::s_windows; +QTimer *MainWindow::s_quitTimer = nullptr; +int MainWindow::s_quitDelayMs = 0; +std::atomic MainWindow::s_tickPending{false}; + MainWindow::MainWindow() { setWindowTitle(QStringLiteral("Ghostty (Qt)")); // Let a translucent terminal background show through to the desktop. @@ -71,12 +81,28 @@ MainWindow::MainWindow() { } MainWindow::~MainWindow() { - // Destroy the surfaces (freeing their ghostty_surface_t) before the - // shared app; Qt's own child cleanup runs after this body. + s_windows.removeOne(this); + + // Destroy this window's surfaces (freeing their ghostty_surface_t) + // before any app teardown; Qt's own child cleanup runs after this body. qDeleteAll(m_surfaces); m_surfaces.clear(); - if (m_app) ghostty_app_free(m_app); - if (m_config) ghostty_config_free(m_config); + + // The shared app and config outlive every window but the last. + if (s_windows.isEmpty()) { + if (s_quitTimer) { + delete s_quitTimer; + s_quitTimer = nullptr; + } + if (s_app) { + ghostty_app_free(s_app); + s_app = nullptr; + } + if (s_config) { + ghostty_config_free(s_config); + s_config = nullptr; + } + } } // Whether the Ghostty config enables a custom shader. libghostty does @@ -156,16 +182,50 @@ static void postProgress(bool visible, double fraction) { } bool MainWindow::initialize() { - // Load configuration in the same order as the reference apprt. - m_config = ghostty_config_new(); - ghostty_config_load_default_files(m_config); - ghostty_config_load_cli_args(m_config); - ghostty_config_load_recursive_files(m_config); - ghostty_config_finalize(m_config); + s_windows.append(this); - m_needsPremultiply = configHasCustomShader(); + // The first window builds the shared libghostty app and config; every + // later window reuses them. + if (!s_app) { + // Load configuration in the same order as the reference apprt. + s_config = ghostty_config_new(); + ghostty_config_load_default_files(s_config); + ghostty_config_load_cli_args(s_config); + ghostty_config_load_recursive_files(s_config); + ghostty_config_finalize(s_config); + s_needsPremultiply = configHasCustomShader(); - // Startup-only window state, applied before main() calls show(). + ghostty_runtime_config_s rt = {}; + // No app userdata: actions are routed to a window via their target + // surface, and app-level actions via the s_windows registry. + rt.userdata = nullptr; + rt.supports_selection_clipboard = true; + rt.wakeup_cb = onWakeup; + rt.action_cb = onAction; + rt.read_clipboard_cb = onReadClipboard; + rt.confirm_read_clipboard_cb = onConfirmReadClipboard; + rt.write_clipboard_cb = onWriteClipboard; + rt.close_surface_cb = onCloseSurface; + + s_app = ghostty_app_new(&rt, s_config); + if (!s_app) { + std::fprintf(stderr, "[ghostty] ghostty_app_new failed\n"); + return false; + } + + // quit-after-last-window-closed: Qt's native "quit on last window" + // covers the common (no-delay) case; a configured delay is honored + // through the libghostty quit_timer action (see handleQuitTimer). + const bool quitAfter = configBool("quit-after-last-window-closed", true); + unsigned long long delayNs = 0; + ghostty_config_get(s_config, &delayNs, + "quit-after-last-window-closed-delay", + qstrlen("quit-after-last-window-closed-delay")); + s_quitDelayMs = quitAfter ? static_cast(delayNs / 1000000ULL) : 0; + QApplication::setQuitOnLastWindowClosed(quitAfter && s_quitDelayMs == 0); + } + + // Per-window startup window state, applied before show(). // window-decoration `none` drops the native frame; `auto`/`server`/ // `client` keep a decorated window (the compositor picks the side on // Wayland). @@ -181,22 +241,6 @@ bool MainWindow::initialize() { // Tab-bar policy and colour scheme. applyWindowConfig(); - ghostty_runtime_config_s rt = {}; - rt.userdata = this; - rt.supports_selection_clipboard = true; - rt.wakeup_cb = onWakeup; - rt.action_cb = onAction; - rt.read_clipboard_cb = onReadClipboard; - rt.confirm_read_clipboard_cb = onConfirmReadClipboard; - rt.write_clipboard_cb = onWriteClipboard; - rt.close_surface_cb = onCloseSurface; - - m_app = ghostty_app_new(&rt, m_config); - if (!m_app) { - std::fprintf(stderr, "[ghostty] ghostty_app_new failed\n"); - return false; - } - // 60fps frame timer: a backstop tick plus rendering. onWakeup drives // extra ticks between frames for input responsiveness. auto *timer = new QTimer(this); @@ -207,6 +251,19 @@ bool MainWindow::initialize() { return true; } +MainWindow *MainWindow::newWindow(ghostty_surface_t parent) { + auto *w = new MainWindow; + w->setAttribute(Qt::WA_DeleteOnClose); // self-destruct when closed + w->m_firstTabParent = parent; // first tab inherits from `parent` + if (!w->initialize()) { + delete w; + return nullptr; + } + w->resize(800, 600); + w->show(); + return w; +} + void MainWindow::showEvent(QShowEvent *event) { QWidget::showEvent(event); @@ -232,11 +289,12 @@ bool MainWindow::event(QEvent *e) { void MainWindow::createFirstTab() { if (!m_firstTabPending) return; m_firstTabPending = false; - newTab(nullptr); + newTab(m_firstTabParent); + m_firstTabParent = nullptr; } GhosttySurface *MainWindow::newTab(ghostty_surface_t parent) { - auto *surface = new GhosttySurface(m_app, this, parent); + auto *surface = new GhosttySurface(s_app, this, parent); m_surfaces.append(surface); // The tab page hosts the tab's split tree (initially one surface). @@ -269,7 +327,7 @@ GhosttySurface *MainWindow::splitSurface( const bool newAfter = dir == GHOSTTY_SPLIT_DIRECTION_RIGHT || dir == GHOSTTY_SPLIT_DIRECTION_DOWN; - auto *surface = new GhosttySurface(m_app, this, target->surface()); + auto *surface = new GhosttySurface(s_app, this, target->surface()); auto *splitter = new QSplitter(horizontal ? Qt::Horizontal : Qt::Vertical); splitter->setChildrenCollapsible(false); @@ -332,7 +390,12 @@ void MainWindow::removeSurface(GhosttySurface *surface) { const int index = m_tabs->indexOf(parent); if (index >= 0) m_tabs->removeTab(index); if (parent) parent->deleteLater(); // page; destroys the surface too - if (m_tabs->count() == 0) close(); + // The surface close was already confirmed; don't re-prompt on the + // window close it may trigger. + if (m_tabs->count() == 0) { + m_skipCloseConfirm = true; + close(); + } } void MainWindow::closeTab(int index) { @@ -342,7 +405,10 @@ void MainWindow::closeTab(int index) { for (GhosttySurface *s : inTab) m_surfaces.removeOne(s); m_tabs->removeTab(index); page->deleteLater(); // destroys every surface in the tab - if (m_tabs->count() == 0) close(); + if (m_tabs->count() == 0) { + m_skipCloseConfirm = true; + close(); + } } void MainWindow::setSurfaceTitle(GhosttySurface *surface, @@ -355,22 +421,81 @@ void MainWindow::setSurfaceTitle(GhosttySurface *surface, setWindowTitle(title + QStringLiteral(" — Ghostty")); } -void MainWindow::tick() { - // Cleared first so a wakeup during the tick re-queues another. - m_tickPending.store(false); - if (!m_app) return; - ghostty_app_tick(m_app); -} - void MainWindow::frame() { - if (!m_app) return; - ghostty_app_tick(m_app); + if (!s_app) return; + ghostty_app_tick(s_app); // Rendering happens only here, so a flood of RENDER actions cannot // saturate the GUI thread — each surface renders at most once a frame. for (GhosttySurface *s : m_surfaces) s->renderIfDirty(); } -void MainWindow::onTabCloseRequested(int index) { closeTab(index); } +void MainWindow::onTabCloseRequested(int index) { + if (!confirmCloseSurfaces(surfacesInTab(index))) return; + closeTab(index); +} + +void MainWindow::closeEvent(QCloseEvent *e) { + // confirm-close-surface: prompt once for the whole window unless this + // close was already confirmed (e.g. the last tab/surface closing). + if (!m_skipCloseConfirm && !confirmCloseSurfaces(m_surfaces)) { + e->ignore(); + return; + } + e->accept(); +} + +bool MainWindow::confirmCloseSurfaces( + const QList &surfaces) { + bool needsConfirm = false; + for (GhosttySurface *s : surfaces) + if (s->surface() && ghostty_surface_needs_confirm_quit(s->surface())) + needsConfirm = true; + if (!needsConfirm) return true; + + const auto choice = QMessageBox::question( + this, QStringLiteral("Close"), + QStringLiteral("There are still running processes. Close anyway?"), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + return choice == QMessageBox::Yes; +} + +void MainWindow::closeAllWindows() { + // One process-level prompt covers every window. + if (s_app && ghostty_app_needs_confirm_quit(s_app)) { + const auto choice = QMessageBox::question( + s_windows.isEmpty() ? nullptr : s_windows.first(), + QStringLiteral("Quit"), + QStringLiteral("There are still running processes. Quit anyway?"), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + if (choice != QMessageBox::Yes) return; + } + // Copy: each close() may delete the window and mutate s_windows. + const QList windows = s_windows; + for (MainWindow *w : windows) { + w->m_skipCloseConfirm = true; + w->close(); + } + // An explicit quit/close-all should end the process even when + // quit-after-last-window-closed left quitOnLastWindowClosed off. + qApp->quit(); +} + +void MainWindow::handleQuitTimer(bool start) { + // Only meaningful when a delay is configured; otherwise Qt's + // quitOnLastWindowClosed already handles the quit. + if (s_quitDelayMs <= 0) return; + if (start) { + if (!s_quitTimer) { + s_quitTimer = new QTimer; + s_quitTimer->setSingleShot(true); + QObject::connect(s_quitTimer, &QTimer::timeout, qApp, + &QApplication::quit); + } + s_quitTimer->start(s_quitDelayMs); + } else if (s_quitTimer) { + s_quitTimer->stop(); + } +} void MainWindow::onCurrentChanged(int index) { GhosttySurface *s = surfaceAt(index); @@ -525,7 +650,7 @@ void MainWindow::ringBell(GhosttySurface *surface) { // bell-features is a packed struct, returned by ghostty_config_get as // a bitfield: bit 0 system, 1 audio, 2 attention, 3 title, 4 border. unsigned int features = 1u << 2; // fall back to `attention` - ghostty_config_get(m_config, &features, "bell-features", + ghostty_config_get(s_config, &features, "bell-features", qstrlen("bell-features")); if (features & (1u << 2)) QApplication::alert(this); // attention if (features & (1u << 0)) QApplication::beep(); // system @@ -575,20 +700,22 @@ void MainWindow::playBellAudio() { m_bellPlayer->play(); } -// Push `config` to the app and every surface, and adopt it as the live -// config. Takes ownership of `config` (frees the previous one). +// Push `config` to the shared app and every surface of every window, +// and adopt it as the live config. Takes ownership of `config` (frees +// the previous one). void MainWindow::applyConfig(ghostty_config_t config) { if (!config) return; - ghostty_app_update_config(m_app, config); - for (GhosttySurface *s : m_surfaces) - if (s->surface()) ghostty_surface_update_config(s->surface(), config); + ghostty_app_update_config(s_app, config); + for (MainWindow *w : s_windows) + for (GhosttySurface *s : w->m_surfaces) + if (s->surface()) ghostty_surface_update_config(s->surface(), config); - if (m_config && m_config != config) ghostty_config_free(m_config); - m_config = config; - m_needsPremultiply = configHasCustomShader(); + if (s_config && s_config != config) ghostty_config_free(s_config); + s_config = config; + s_needsPremultiply = configHasCustomShader(); // Re-apply window settings that a reload may have changed. - applyWindowConfig(); + for (MainWindow *w : s_windows) w->applyWindowConfig(); } void MainWindow::reloadConfig() { @@ -603,15 +730,15 @@ void MainWindow::reloadConfig() { QString MainWindow::configString(const char *key) const { const char *value = nullptr; - if (!m_config || - !ghostty_config_get(m_config, &value, key, qstrlen(key)) || !value) + if (!s_config || + !ghostty_config_get(s_config, &value, key, qstrlen(key)) || !value) return {}; return QString::fromUtf8(value); } bool MainWindow::configBool(const char *key, bool fallback) const { bool value = fallback; // ghostty_config_get leaves it untouched if absent - if (m_config) ghostty_config_get(m_config, &value, key, qstrlen(key)); + if (s_config) ghostty_config_get(s_config, &value, key, qstrlen(key)); return value; } @@ -646,7 +773,7 @@ void MainWindow::applyWindowConfig() { scheme = Qt::ColorScheme::Light; } else if (theme == QLatin1String("ghostty")) { ghostty_config_color_s bg{}; - if (ghostty_config_get(m_config, &bg, "background", qstrlen("background"))) { + if (ghostty_config_get(s_config, &bg, "background", qstrlen("background"))) { const double luma = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b; scheme = luma < 128.0 ? Qt::ColorScheme::Dark : Qt::ColorScheme::Light; } @@ -699,12 +826,19 @@ void MainWindow::toggleSplitZoom(GhosttySurface *surface) { // --- libghostty runtime callbacks ------------------------------------ -void MainWindow::onWakeup(void *ud) { - // app userdata. Coalesce: queue a tick only when one is not already - // pending, so a chatty surface cannot flood the event loop. - auto *self = static_cast(ud); - if (!self->m_tickPending.exchange(true)) - QMetaObject::invokeMethod(self, "tick", Qt::QueuedConnection); +void MainWindow::onWakeup(void *) { + // Coalesce: queue a shared-app tick only when one is not already + // pending, so a chatty surface cannot flood the event loop. May be + // called off-thread, so it marshals onto qApp (always alive) rather + // than any particular window. + if (s_tickPending.exchange(true)) return; + QMetaObject::invokeMethod( + qApp, + []() { + s_tickPending.store(false); + if (s_app) ghostty_app_tick(s_app); + }, + Qt::QueuedConnection); } // Map a libghostty mouse shape to the nearest Qt cursor. @@ -744,17 +878,20 @@ static Qt::CursorShape mouseShapeToCursor(ghostty_action_mouse_shape_e s) { } } -bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, +bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target, ghostty_action_s action) { - auto *self = static_cast(ghostty_app_userdata(app)); - if (!self) return false; - // The surface this action targets, if any. GhosttySurface *src = nullptr; if (target.tag == GHOSTTY_TARGET_SURFACE && target.target.surface) src = static_cast( ghostty_surface_userdata(target.target.surface)); + // The window the action applies to: the target surface's window, or + // (for app-level actions) any live window. Surface/window work is + // marshalled onto `win` so it is cancelled if that window goes away. + MainWindow *win = src ? src->owner() + : (s_windows.isEmpty() ? nullptr : s_windows.first()); + // Actions may be dispatched from non-GUI threads, so window-touching // work is marshalled onto the GUI thread. switch (action.tag) { @@ -764,11 +901,19 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, if (src) src->markDirty(); return true; - case GHOSTTY_ACTION_NEW_TAB: + case GHOSTTY_ACTION_NEW_TAB: { + if (!win) return false; + ghostty_surface_t parent = src ? src->surface() : nullptr; + QMetaObject::invokeMethod( + win, [win, parent]() { win->newTab(parent); }, + Qt::QueuedConnection); + return true; + } + case GHOSTTY_ACTION_NEW_WINDOW: { ghostty_surface_t parent = src ? src->surface() : nullptr; QMetaObject::invokeMethod( - self, [self, parent]() { self->newTab(parent); }, + qApp, [parent]() { MainWindow::newWindow(parent); }, Qt::QueuedConnection); return true; } @@ -777,7 +922,7 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, if (!src) return false; const ghostty_action_split_direction_e dir = action.action.new_split; QMetaObject::invokeMethod( - self, [self, src, dir]() { self->splitSurface(src, dir); }, + win, [win, src, dir]() { win->splitSurface(src, dir); }, Qt::QueuedConnection); return true; } @@ -785,7 +930,10 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, case GHOSTTY_ACTION_CLOSE_TAB: if (src) QMetaObject::invokeMethod( - self, [self, src]() { self->removeSurface(src); }, + win, + [win, src]() { + if (win->confirmCloseSurfaces({src})) win->removeSurface(src); + }, Qt::QueuedConnection); return true; @@ -794,15 +942,16 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, if (!title || !src) return true; const QString t = QString::fromUtf8(title); QMetaObject::invokeMethod( - self, [self, src, t]() { self->setSurfaceTitle(src, t); }, + win, [win, src, t]() { win->setSurfaceTitle(src, t); }, Qt::QueuedConnection); return true; } case GHOSTTY_ACTION_GOTO_TAB: { + if (!win) return false; const ghostty_action_goto_tab_e tab = action.action.goto_tab; QMetaObject::invokeMethod( - self, [self, tab]() { self->gotoTab(tab); }, Qt::QueuedConnection); + win, [win, tab]() { win->gotoTab(tab); }, Qt::QueuedConnection); return true; } @@ -810,7 +959,7 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, if (!src) return false; const ghostty_action_goto_split_e dir = action.action.goto_split; QMetaObject::invokeMethod( - self, [self, src, dir]() { self->gotoSplit(src, dir); }, + win, [win, src, dir]() { win->gotoSplit(src, dir); }, Qt::QueuedConnection); return true; } @@ -819,7 +968,7 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, if (!src) return false; const ghostty_action_resize_split_s rs = action.action.resize_split; QMetaObject::invokeMethod( - self, [self, src, rs]() { self->resizeSplit(src, rs); }, + win, [win, src, rs]() { win->resizeSplit(src, rs); }, Qt::QueuedConnection); return true; } @@ -827,40 +976,51 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, case GHOSTTY_ACTION_EQUALIZE_SPLITS: if (src) QMetaObject::invokeMethod( - self, [self, src]() { self->equalizeSplits(src); }, + win, [win, src]() { win->equalizeSplits(src); }, Qt::QueuedConnection); return true; case GHOSTTY_ACTION_TOGGLE_FULLSCREEN: + if (!win) return false; QMetaObject::invokeMethod( - self, - [self]() { - if (self->isFullScreen()) - self->showNormal(); + win, + [win]() { + if (win->isFullScreen()) + win->showNormal(); else - self->showFullScreen(); + win->showFullScreen(); }, Qt::QueuedConnection); return true; case GHOSTTY_ACTION_TOGGLE_MAXIMIZE: + if (!win) return false; QMetaObject::invokeMethod( - self, - [self]() { - if (self->isMaximized()) - self->showNormal(); + win, + [win]() { + if (win->isMaximized()) + win->showNormal(); else - self->showMaximized(); + win->showMaximized(); }, Qt::QueuedConnection); return true; case GHOSTTY_ACTION_QUIT: case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: - QMetaObject::invokeMethod( - self, [self]() { self->close(); }, Qt::QueuedConnection); + QMetaObject::invokeMethod(qApp, []() { MainWindow::closeAllWindows(); }, + Qt::QueuedConnection); return true; + case GHOSTTY_ACTION_QUIT_TIMER: { + const bool start = + action.action.quit_timer == GHOSTTY_QUIT_TIMER_START; + QMetaObject::invokeMethod( + qApp, [start]() { MainWindow::handleQuitTimer(start); }, + Qt::QueuedConnection); + return true; + } + case GHOSTTY_ACTION_SHOW_CHILD_EXITED: { if (!src) return false; const int code = @@ -874,7 +1034,7 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, case GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM: if (src) QMetaObject::invokeMethod( - self, [self, src]() { self->toggleSplitZoom(src); }, + win, [win, src]() { win->toggleSplitZoom(src); }, Qt::QueuedConnection); return true; @@ -887,43 +1047,51 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, } case GHOSTTY_ACTION_RELOAD_CONFIG: - QMetaObject::invokeMethod( - self, [self]() { self->reloadConfig(); }, Qt::QueuedConnection); + if (win) + QMetaObject::invokeMethod( + win, [win]() { win->reloadConfig(); }, Qt::QueuedConnection); return true; case GHOSTTY_ACTION_CONFIG_CHANGE: { // Clone libghostty's config so it outlives this callback; applyConfig - // adopts the clone as the live config. + // adopts the clone as the live config (and applies it to every + // window). Free the clone ourselves if there is no window to adopt it. ghostty_config_t cfg = ghostty_config_clone(action.action.config_change.config); - QMetaObject::invokeMethod( - self, [self, cfg]() { self->applyConfig(cfg); }, - Qt::QueuedConnection); + if (win) + QMetaObject::invokeMethod( + win, [win, cfg]() { win->applyConfig(cfg); }, + Qt::QueuedConnection); + else + ghostty_config_free(cfg); return true; } case GHOSTTY_ACTION_INITIAL_SIZE: { + if (!win) return false; const ghostty_action_initial_size_s sz = action.action.initial_size; QMetaObject::invokeMethod( - self, - [self, sz]() { + win, + [win, sz]() { // The action carries device pixels; resize() takes logical. - const double dpr = self->devicePixelRatioF(); - self->resize(static_cast(sz.width / dpr), - static_cast(sz.height / dpr)); + const double dpr = win->devicePixelRatioF(); + win->resize(static_cast(sz.width / dpr), + static_cast(sz.height / dpr)); }, Qt::QueuedConnection); return true; } case GHOSTTY_ACTION_CLOSE_WINDOW: - QMetaObject::invokeMethod( - self, [self]() { self->close(); }, Qt::QueuedConnection); + if (win) + QMetaObject::invokeMethod(win, [win]() { win->close(); }, + Qt::QueuedConnection); return true; case GHOSTTY_ACTION_RING_BELL: - QMetaObject::invokeMethod(self, [self, src]() { self->ringBell(src); }, - Qt::QueuedConnection); + if (win) + QMetaObject::invokeMethod(win, [win, src]() { win->ringBell(src); }, + Qt::QueuedConnection); return true; case GHOSTTY_ACTION_MOUSE_SHAPE: { @@ -951,7 +1119,7 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, if (!u.url || !u.len) return true; const QString s = QString::fromUtf8(u.url, static_cast(u.len)); QMetaObject::invokeMethod( - self, + qApp, [s]() { QDesktopServices::openUrl( QUrl::fromUserInput(s, QString(), QUrl::AssumeLocalFile)); @@ -966,7 +1134,7 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, const QString title = QString::fromUtf8(n.title ? n.title : ""); const QString body = QString::fromUtf8(n.body ? n.body : ""); QMetaObject::invokeMethod( - self, [title, body]() { postNotification(title, body); }, + qApp, [title, body]() { postNotification(title, body); }, Qt::QueuedConnection); return true; } @@ -989,9 +1157,10 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, case GHOSTTY_ACTION_MOVE_TAB: { const int amount = static_cast(action.action.move_tab.amount); - QMetaObject::invokeMethod( - self, [self, amount]() { self->moveTab(amount); }, - Qt::QueuedConnection); + if (win) + QMetaObject::invokeMethod( + win, [win, amount]() { win->moveTab(amount); }, + Qt::QueuedConnection); return true; } @@ -1028,7 +1197,7 @@ bool MainWindow::onAction(ghostty_app_t app, ghostty_target_s target, const bool visible = p.state != GHOSTTY_PROGRESS_STATE_REMOVE; const double fraction = p.progress >= 0 ? p.progress / 100.0 : 0.0; QMetaObject::invokeMethod( - self, [visible, fraction]() { postProgress(visible, fraction); }, + qApp, [visible, fraction]() { postProgress(visible, fraction); }, Qt::QueuedConnection); return true; } @@ -1104,11 +1273,16 @@ void MainWindow::onWriteClipboard(void *ud, ghostty_clipboard_e loc, } void MainWindow::onCloseSurface(void *ud, bool) { - // surface userdata. + // surface userdata. Deferred out of this callback so the confirm + // dialog cannot spin a nested event loop back into libghostty. auto *surface = static_cast(ud); if (!surface) return; MainWindow *self = surface->owner(); QMetaObject::invokeMethod( - self, [self, surface]() { self->removeSurface(surface); }, + self, + [self, surface]() { + if (self->confirmCloseSurfaces({surface})) + self->removeSurface(surface); + }, Qt::QueuedConnection); } diff --git a/qt/src/MainWindow.h b/qt/src/MainWindow.h index bfea7af3c..58644bfac 100644 --- a/qt/src/MainWindow.h +++ b/qt/src/MainWindow.h @@ -8,14 +8,17 @@ #include "ghostty.h" class QAudioOutput; +class QCloseEvent; class QMediaPlayer; class QShowEvent; class QSplitter; class QTabWidget; +class QTimer; class GhosttySurface; -// The top-level window. Owns the shared ghostty_app_t and presents -// terminal surfaces as tabs; each tab may be subdivided into splits. +// 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 @@ -27,9 +30,14 @@ public: MainWindow(); ~MainWindow() override; - // Create the libghostty app and the first tab. Call once before show(). + // 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); + // 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); @@ -46,23 +54,23 @@ public: void setSurfaceTitle(GhosttySurface *surface, const QString &title); // The live libghostty config (for keybind lookups, etc.). - ghostty_config_t config() const { return m_config; } + 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 m_needsPremultiply; } + bool needsPremultiply() const { return s_needsPremultiply; } // Whether `focus-follows-mouse` is enabled — a GhosttySurface grabs // focus when the pointer enters it. bool focusFollowsMouse() const; -public slots: - void tick(); - 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; private slots: void onTabCloseRequested(int index); @@ -110,12 +118,25 @@ private: // tab-bar visibility policy and the light/dark colour scheme. void applyWindowConfig(); + // Prompt (per `confirm-close-surface`) before closing `surfaces`. + // Returns true if the close may proceed. + bool confirmCloseSurfaces(const QList &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 carry the - // app userdata; clipboard/close carry the surface userdata. + // 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); @@ -126,17 +147,25 @@ private: bool); static void onCloseSurface(void *ud, bool process_active); - ghostty_config_t m_config = nullptr; - ghostty_app_t m_app = nullptr; QTabWidget *m_tabs = nullptr; - QList m_surfaces; // every live surface - bool m_needsPremultiply = false; // a custom shader is configured + QList 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 + + // 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 s_windows; + static QTimer *s_quitTimer; // delayed quit-after-last-window + static int s_quitDelayMs; // 0 = no delay configured // Coalesces wakeup-driven ticks: a tick is queued at most once at a - // time, so a busy surface can't flood the event loop. Set from - // onWakeup (possibly off-thread), cleared at the start of tick(). - std::atomic m_tickPending{false}; + // time, so a busy surface can't flood the event loop. + static std::atomic s_tickPending; // Split-zoom state: the surface temporarily filling its tab, the // splitter it came from, its index there, and the stashed tree root. diff --git a/qt/src/main.cpp b/qt/src/main.cpp index e042fecdd..cdfe9f3da 100644 --- a/qt/src/main.cpp +++ b/qt/src/main.cpp @@ -47,13 +47,12 @@ int main(int argc, char **argv) { return 1; } - MainWindow window; - if (!window.initialize()) { + // 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, "[ghostty] window initialization failed\n"); return 1; } - window.resize(800, 600); - window.show(); return app.exec(); } From 08176e36dadd4764d876cf82b870fb7f8ed418d1 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 11:14:27 -0500 Subject: [PATCH 43/75] =?UTF-8?q?qt:=20Tier=203=20polish=20=E2=80=94=20tit?= =?UTF-8?q?les,=20key-sequence,=20resize/dim=20overlays?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SET_TAB_TITLE / PROMPT_TITLE: a tab now carries a {base, override} title pair in tab data; SET_TITLE sets the base, SET_TAB_TITLE the manual override, updateTabText picks which to show. This closes the context-menu "Change Tab Title" loop, which previously round-tripped through core and was dropped on the way back. - COPY_TITLE_TO_CLIPBOARD: copies the current tab's effective title. - RESET_WINDOW_SIZE: restores the window to its startup size (captured from the INITIAL_SIZE action). - KEY_SEQUENCE: a pending keybind chord shows as a bottom-left overlay, cleared when the sequence completes or aborts. - Resize overlay: a transient "cols x rows" overlay on a grid-size change, honoring resize-overlay / -position / -duration. - Unfocused-split dimming: an inactive split pane is dimmed per unfocused-split-opacity / unfocused-split-fill. Skipped (not worth it for Qt): undo/redo and secure-input are macOS-only, the inspector needs a new OpenGL inspector C-API. Co-Authored-By: claude-flow --- qt/src/GhosttySurface.cpp | 143 ++++++++++++++++++++++++++++++++++---- qt/src/GhosttySurface.h | 19 +++++ qt/src/MainWindow.cpp | 138 ++++++++++++++++++++++++++++++++---- qt/src/MainWindow.h | 13 +++- 4 files changed, 285 insertions(+), 28 deletions(-) diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index 9fc6a51aa..315296e23 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -163,6 +164,9 @@ void GhosttySurface::resizeEvent(QResizeEvent *) { layoutScrollbar(); syncSurfaceSize(); if (m_exitOverlay) m_exitOverlay->setGeometry(rect()); + if (m_keySeqOverlay && m_keySeqOverlay->isVisible()) + m_keySeqOverlay->move(8, height() - m_keySeqOverlay->height() - 8); + showResizeOverlay(); } bool GhosttySurface::event(QEvent *e) { @@ -250,6 +254,26 @@ void GhosttySurface::paintEvent(QPaintEvent *) { painter.setCompositionMode(QPainter::CompositionMode_Source); painter.drawImage(QPointF(0, 0), m_image); + // Unfocused-split dimming: a translucent fill over an inactive pane. + // Only split panes (a QSplitter parent) are dimmed, matching GTK. + if (!hasFocus() && qobject_cast(parentWidget())) { + ghostty_config_t cfg = m_owner ? m_owner->config() : nullptr; + double opacity = 0.7; + if (cfg) + ghostty_config_get(cfg, &opacity, "unfocused-split-opacity", + qstrlen("unfocused-split-opacity")); + if (opacity < 1.0) { + QColor fill(0, 0, 0); // default: dim toward black + ghostty_config_color_s c{}; + if (cfg && ghostty_config_get(cfg, &c, "unfocused-split-fill", + qstrlen("unfocused-split-fill"))) + fill = QColor(c.r, c.g, c.b); + fill.setAlphaF(1.0 - opacity); + painter.setCompositionMode(QPainter::CompositionMode_SourceOver); + painter.fillRect(rect(), fill); + } + } + // Bell `border` feature: a brief attention flash over the terminal. if (m_bellFlash) { painter.setCompositionMode(QPainter::CompositionMode_SourceOver); @@ -268,6 +292,106 @@ void GhosttySurface::flashBorder() { }); } +// A small translucent overlay label (key-sequence / resize display). +static QLabel *makeOverlayLabel(QWidget *parent) { + auto *label = new QLabel(parent); + label->setAttribute(Qt::WA_TransparentForMouseEvents); + label->setStyleSheet(QStringLiteral( + "background: rgba(0,0,0,0.75); color: #f0f0f0; font-size: 13px;" + "padding: 4px 10px; border-radius: 4px;")); + label->hide(); + return label; +} + +// Read a string/enum config value (enums arrive as their tag name). +static QString cfgString(ghostty_config_t cfg, const char *key) { + const char *v = nullptr; + if (cfg && ghostty_config_get(cfg, &v, key, qstrlen(key)) && v) + return QString::fromUtf8(v); + return {}; +} + +void GhosttySurface::promptTitle(bool tabScope) { + bool ok = false; + const QString title = QInputDialog::getText( + this, + tabScope ? QStringLiteral("Change Tab Title") + : QStringLiteral("Change Title"), + QStringLiteral("Title:"), QLineEdit::Normal, QString(), &ok); + if (!ok || !m_surface) return; + // The keybind action round-trips through libghostty, which emits + // SET_TAB_TITLE / SET_TITLE back to apply it (an empty title resets). + const QByteArray act = + (tabScope ? QByteArrayLiteral("set_tab_title:") + : QByteArrayLiteral("set_surface_title:")) + + title.toUtf8(); + ghostty_surface_binding_action(m_surface, act.constData(), act.size()); +} + +void GhosttySurface::pushKeySequence(const QString &chord) { + m_keySeq.append(chord); + if (!m_keySeqOverlay) m_keySeqOverlay = makeOverlayLabel(this); + m_keySeqOverlay->setText(m_keySeq.join(QStringLiteral(" "))); + m_keySeqOverlay->adjustSize(); + m_keySeqOverlay->move(8, height() - m_keySeqOverlay->height() - 8); + m_keySeqOverlay->show(); + m_keySeqOverlay->raise(); +} + +void GhosttySurface::endKeySequence() { + m_keySeq.clear(); + if (m_keySeqOverlay) m_keySeqOverlay->hide(); +} + +void GhosttySurface::showResizeOverlay() { + if (!m_surface || !m_owner) return; + const ghostty_surface_size_s sz = ghostty_surface_size(m_surface); + // Only a grid-size change is a "resize" worth announcing. + if (sz.columns == m_lastCols && sz.rows == m_lastRows) return; + m_lastCols = sz.columns; + m_lastRows = sz.rows; + + ghostty_config_t cfg = m_owner->config(); + const QString mode = cfgString(cfg, "resize-overlay"); + const bool first = !m_firstGridSeen; + m_firstGridSeen = true; + if (mode == QLatin1String("never")) return; + if (mode == QLatin1String("after-first") && first) return; + + if (!m_resizeOverlay) m_resizeOverlay = makeOverlayLabel(this); + m_resizeOverlay->setText( + QStringLiteral("%1 × %2").arg(sz.columns).arg(sz.rows)); + m_resizeOverlay->adjustSize(); + + // resize-overlay-position: center / {top,bottom}-{left,center,right}. + const QString pos = cfgString(cfg, "resize-overlay-position"); + const int m = 8; + int x = (width() - m_resizeOverlay->width()) / 2; + int y = (height() - m_resizeOverlay->height()) / 2; + if (pos.contains(QLatin1String("left"))) x = m; + else if (pos.contains(QLatin1String("right"))) + x = width() - m_resizeOverlay->width() - m; + if (pos.contains(QLatin1String("top"))) y = m; + else if (pos.contains(QLatin1String("bottom"))) + y = height() - m_resizeOverlay->height() - m; + m_resizeOverlay->move(x, y); + m_resizeOverlay->show(); + m_resizeOverlay->raise(); + + unsigned long long durNs = 0; + ghostty_config_get(cfg, &durNs, "resize-overlay-duration", + qstrlen("resize-overlay-duration")); + const int durMs = durNs ? static_cast(durNs / 1000000ULL) : 750; + if (!m_resizeHideTimer) { + m_resizeHideTimer = new QTimer(this); + m_resizeHideTimer->setSingleShot(true); + connect(m_resizeHideTimer, &QTimer::timeout, this, [this]() { + if (m_resizeOverlay) m_resizeOverlay->hide(); + }); + } + m_resizeHideTimer->start(durMs); +} + void GhosttySurface::showChildExited(int exitCode) { if (m_exitOverlay) return; // already shown @@ -571,23 +695,10 @@ void GhosttySurface::contextMenuEvent(QContextMenuEvent *ev) { } // The title items have no apprt-side prompt in libghostty: collect the - // text here and apply it with the set_*_title keybind action (an empty - // title resets it). + // text here and apply it via promptTitle (the set_*_title keybind). if (data == QLatin1String("prompt_surface_title") || data == QLatin1String("prompt_tab_title")) { - const bool surfaceTitle = data == QLatin1String("prompt_surface_title"); - bool ok = false; - const QString title = QInputDialog::getText( - this, - surfaceTitle ? QStringLiteral("Change Title") - : QStringLiteral("Change Tab Title"), - QStringLiteral("Title:"), QLineEdit::Normal, QString(), &ok); - if (!ok) return; - const QByteArray act = - (surfaceTitle ? QByteArrayLiteral("set_surface_title:") - : QByteArrayLiteral("set_tab_title:")) + - title.toUtf8(); - ghostty_surface_binding_action(m_surface, act.constData(), act.size()); + promptTitle(data == QLatin1String("prompt_tab_title")); return; } @@ -645,10 +756,12 @@ void GhosttySurface::enterEvent(QEnterEvent *) { void GhosttySurface::focusInEvent(QFocusEvent *) { if (m_surface) ghostty_surface_set_focus(m_surface, true); + update(); // repaint without the unfocused-split dim } void GhosttySurface::focusOutEvent(QFocusEvent *) { if (m_surface) ghostty_surface_set_focus(m_surface, false); + update(); // repaint with the unfocused-split dim (if a split pane) } // Insert a string of committed text (an IME commit) as terminal input. diff --git a/qt/src/GhosttySurface.h b/qt/src/GhosttySurface.h index d0bef75cd..b96077470 100644 --- a/qt/src/GhosttySurface.h +++ b/qt/src/GhosttySurface.h @@ -3,6 +3,7 @@ #include #include +#include #include #include "ghostty.h" @@ -12,6 +13,7 @@ class QContextMenuEvent; class QDragEnterEvent; class QDropEvent; class QEnterEvent; +class QTimer; class QInputMethodEvent; class QKeySequence; class QLabel; @@ -69,6 +71,15 @@ public: // 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(); + // Bell `border` feature: briefly flash a border over the terminal. void flashBorder(); // Bell `title` feature: mark/unmark an unacknowledged bell. MainWindow @@ -106,6 +117,7 @@ private: void layoutScrollbar(); // position the scrollbar at the edge bool scrollbarAllowed() const; // false when `scrollbar = never` void buildExitOverlay(int exitCode); + void showResizeOverlay(); // transient grid-size overlay on resize void sendKey(QKeyEvent *, ghostty_input_action_e action); void commitText(const QString &text); void sendMouseButton(QMouseEvent *, ghostty_input_mouse_state_e state); @@ -146,6 +158,13 @@ private: 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; QScrollBar *m_scrollbar = nullptr; // scrollback scrollbar; hidden by default bool m_notifyOnCommand = false; // one-shot: notify on next cmd finish bool m_bellFlash = false; // bell `border` flash in progress diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index ac1aa9d3a..82932a077 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -415,10 +415,33 @@ void MainWindow::setSurfaceTitle(GhosttySurface *surface, const QString &title) { const int index = tabIndexForSurface(surface); if (index < 0) return; - m_tabs->setTabText(index, - tabBellMarked(index) ? kBellMark + title : title); - if (index == m_tabs->currentIndex()) - setWindowTitle(title + QStringLiteral(" — Ghostty")); + // Store the terminal title as the tab's base; updateTabText decides + // whether it or a manual override is shown. + QStringList data = m_tabs->tabBar()->tabData(index).toStringList(); + while (data.size() < 2) data.append(QString()); + data[0] = title; + m_tabs->tabBar()->setTabData(index, data); + updateTabText(index); +} + +void MainWindow::setTabTitleOverride(GhosttySurface *surface, + const QString &title) { + const int index = tabIndexForSurface(surface); + if (index < 0) return; + QStringList data = m_tabs->tabBar()->tabData(index).toStringList(); + while (data.size() < 2) data.append(QString()); + data[1] = title; // empty clears the override + m_tabs->tabBar()->setTabData(index, data); + updateTabText(index); +} + +void MainWindow::copyTitleToClipboard() { + const int tab = m_tabs->currentIndex(); + if (tab < 0) return; + const QStringList data = m_tabs->tabBar()->tabData(tab).toStringList(); + const QString title = + !data.value(1).isEmpty() ? data.value(1) : data.value(0); + if (!title.isEmpty()) QGuiApplication::clipboard()->setText(title); } void MainWindow::frame() { @@ -503,8 +526,7 @@ void MainWindow::onCurrentChanged(int index) { s->setFocus(); // Acknowledge any bell `title` mark now that the tab is visible. for (GhosttySurface *surf : surfacesInTab(index)) surf->setBellTitle(false); - refreshTabTitle(index); - setWindowTitle(m_tabs->tabText(index) + QStringLiteral(" — Ghostty")); + updateTabText(index); } GhosttySurface *MainWindow::surfaceAt(int index) const { @@ -663,7 +685,7 @@ void MainWindow::ringBell(GhosttySurface *surface) { // Marking the current tab is pointless — you are looking at it. if (tab >= 0 && tab != m_tabs->currentIndex()) { surface->setBellTitle(true); - refreshTabTitle(tab); + updateTabText(tab); } } } @@ -674,10 +696,17 @@ bool MainWindow::tabBellMarked(int tab) const { return false; } -void MainWindow::refreshTabTitle(int tab) { - QString text = m_tabs->tabText(tab); - if (text.startsWith(kBellMark)) text = text.mid(kBellMark.size()); +void MainWindow::updateTabText(int tab) { + if (tab < 0 || tab >= m_tabs->count()) return; + const QStringList data = m_tabs->tabBar()->tabData(tab).toStringList(); + const QString base = data.value(0); + const QString override = data.value(1); + QString text = !override.isEmpty() ? override + : !base.isEmpty() ? base + : QStringLiteral("Ghostty"); m_tabs->setTabText(tab, tabBellMarked(tab) ? kBellMark + text : text); + if (tab == m_tabs->currentIndex()) + setWindowTitle(text + QStringLiteral(" — Ghostty")); } void MainWindow::playBellAudio() { @@ -878,6 +907,34 @@ static Qt::CursorShape mouseShapeToCursor(ghostty_action_mouse_shape_e s) { } } +// Format a keybind trigger as a human-readable chord, e.g. "Ctrl+B". +static 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+"); + switch (t.tag) { + case GHOSTTY_TRIGGER_UNICODE: + s += QString(QChar(t.key.unicode)).toUpper(); + break; + case GHOSTTY_TRIGGER_PHYSICAL: { + const ghostty_input_key_e k = t.key.physical; + if (k >= GHOSTTY_KEY_DIGIT_0 && k <= GHOSTTY_KEY_DIGIT_9) + s += QChar('0' + (k - GHOSTTY_KEY_DIGIT_0)); + else if (k >= GHOSTTY_KEY_A && k <= GHOSTTY_KEY_Z) + s += QChar('A' + (k - GHOSTTY_KEY_A)); + else + s += QStringLiteral("•"); // an unmapped physical key + break; + } + default: + s += QStringLiteral("…"); // catch-all + break; + } + return s; +} + bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target, ghostty_action_s action) { // The surface this action targets, if any. @@ -947,6 +1004,61 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target, return true; } + case GHOSTTY_ACTION_SET_TAB_TITLE: { + // A manual tab-title override (an empty string clears it). + if (!src) return true; + const char *title = action.action.set_tab_title.title; + const QString t = QString::fromUtf8(title ? title : ""); + QMetaObject::invokeMethod( + win, [win, src, t]() { win->setTabTitleOverride(src, t); }, + Qt::QueuedConnection); + return true; + } + + case GHOSTTY_ACTION_PROMPT_TITLE: { + if (!src) return true; + const bool tabScope = + action.action.prompt_title == GHOSTTY_PROMPT_TITLE_TAB; + QMetaObject::invokeMethod( + src, [src, tabScope]() { src->promptTitle(tabScope); }, + Qt::QueuedConnection); + return true; + } + + case GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD: + if (win) + QMetaObject::invokeMethod( + win, [win]() { win->copyTitleToClipboard(); }, + Qt::QueuedConnection); + return true; + + case GHOSTTY_ACTION_RESET_WINDOW_SIZE: + if (win) + QMetaObject::invokeMethod( + win, + [win]() { + win->resize(win->m_defaultWindowSize.isValid() + ? win->m_defaultWindowSize + : QSize(800, 600)); + }, + Qt::QueuedConnection); + return true; + + case GHOSTTY_ACTION_KEY_SEQUENCE: { + if (!src) return true; + const ghostty_action_key_sequence_s ks = action.action.key_sequence; + if (!ks.active) { + QMetaObject::invokeMethod(src, [src]() { src->endKeySequence(); }, + Qt::QueuedConnection); + return true; + } + const QString chord = formatTrigger(ks.trigger); + QMetaObject::invokeMethod( + src, [src, chord]() { src->pushKeySequence(chord); }, + Qt::QueuedConnection); + return true; + } + case GHOSTTY_ACTION_GOTO_TAB: { if (!win) return false; const ghostty_action_goto_tab_e tab = action.action.goto_tab; @@ -1075,8 +1187,10 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target, [win, sz]() { // The action carries device pixels; resize() takes logical. const double dpr = win->devicePixelRatioF(); - win->resize(static_cast(sz.width / dpr), - static_cast(sz.height / dpr)); + const QSize logical(static_cast(sz.width / dpr), + static_cast(sz.height / dpr)); + win->m_defaultWindowSize = logical; // for RESET_WINDOW_SIZE + win->resize(logical); }, Qt::QueuedConnection); return true; diff --git a/qt/src/MainWindow.h b/qt/src/MainWindow.h index 58644bfac..bb000b2d5 100644 --- a/qt/src/MainWindow.h +++ b/qt/src/MainWindow.h @@ -3,6 +3,7 @@ #include #include +#include #include #include "ghostty.h" @@ -102,7 +103,16 @@ private: // Bell `title` feature: prefix a tab's title while any surface in it // has an unacknowledged bell. bool tabBellMarked(int tab) const; - void refreshTabTitle(int tab); + + // 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(); // Config: rebuild from disk (reloadConfig) or apply one libghostty // handed us (applyConfig), pushing it to the app and every surface. @@ -152,6 +162,7 @@ private: 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 + 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 From 2a5efd6c074f097f4a348cce176924a79bdb88b9 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 11:25:51 -0500 Subject: [PATCH 44/75] qt: honor background-blur via the KWin compositor Apply KWin's "blur behind" effect when background-blur is enabled, using the native compositor protocols directly (no KF6 dependency): - Wayland: the org_kde_kwin_blur protocol, vendored as protocols/ blur.xml and turned into client glue by wayland-scanner at build time. The blur manager is bound by enumerating the registry on a private event queue so Qt's own Wayland queue is never disturbed, then moved back to the default queue. - X11: the _KDE_NET_WM_BLUR_BEHIND_REGION property via xcb. The whole window is blurred; only the terminal's translucent pixels show the effect. MainWindow::applyBlur reads background-blur (>0 = on) on first show and on config reload. No-op on non-KWin compositors. Co-Authored-By: claude-flow --- qt/CMakeLists.txt | 36 +++++++++++- qt/protocols/blur.xml | 28 +++++++++ qt/src/MainWindow.cpp | 21 ++++++- qt/src/MainWindow.h | 4 ++ qt/src/WindowBlur.cpp | 131 ++++++++++++++++++++++++++++++++++++++++++ qt/src/WindowBlur.h | 13 +++++ 6 files changed, 229 insertions(+), 4 deletions(-) create mode 100644 qt/protocols/blur.xml create mode 100644 qt/src/WindowBlur.cpp create mode 100644 qt/src/WindowBlur.h diff --git a/qt/CMakeLists.txt b/qt/CMakeLists.txt index 455039b29..1f19b277e 100644 --- a/qt/CMakeLists.txt +++ b/qt/CMakeLists.txt @@ -1,5 +1,6 @@ cmake_minimum_required(VERSION 3.16) -project(ghostty LANGUAGES CXX) +# C is needed for the wayland-scanner-generated protocol code. +project(ghostty LANGUAGES CXX C) # A Qt6 frontend for Ghostty that embeds libghostty through the # GHOSTTY_PLATFORM_OPENGL C API. libghostty's OpenGL renderer draws each @@ -28,7 +29,27 @@ set(CMAKE_AUTOMOC ON) include(GNUInstallDirs) -find_package(Qt6 REQUIRED COMPONENTS Gui Widgets OpenGL DBus Multimedia) +find_package(Qt6 REQUIRED COMPONENTS Gui GuiPrivate Widgets OpenGL DBus + Multimedia) + +# 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) +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) @@ -50,16 +71,25 @@ add_executable(ghostty src/main.cpp src/GhosttySurface.cpp src/MainWindow.cpp + src/WindowBlur.cpp + "${BLUR_CODE}" + "${BLUR_HEADER}" ) -target_include_directories(ghostty PRIVATE "${GHOSTTY_ROOT}/include") +target_include_directories(ghostty PRIVATE + "${GHOSTTY_ROOT}/include" + "${CMAKE_CURRENT_BINARY_DIR}" # generated blur-client-protocol.h +) target_link_libraries(ghostty PRIVATE Qt6::Gui + Qt6::GuiPrivate Qt6::Widgets Qt6::OpenGL Qt6::DBus Qt6::Multimedia + PkgConfig::WAYLAND_CLIENT + PkgConfig::XCB "${GHOSTTY_LIB_DIR}/libghostty.so" ) diff --git a/qt/protocols/blur.xml b/qt/protocols/blur.xml new file mode 100644 index 000000000..477bd72af --- /dev/null +++ b/qt/protocols/blur.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index 82932a077..508929425 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -34,6 +34,7 @@ #include #include "GhosttySurface.h" +#include "WindowBlur.h" // Prefix marking a tab with an unacknowledged bell (bell-features title). static const QString kBellMark = QStringLiteral("● "); @@ -276,6 +277,10 @@ void MainWindow::showEvent(QShowEvent *event) { // the ratio was already correct at show. if (m_firstTabPending) QTimer::singleShot(250, this, [this] { createFirstTab(); }); + + // Apply background blur once the native (Wayland/X11) surface exists; + // a zero-delay timer defers past the platform-window creation. + QTimer::singleShot(0, this, [this] { applyBlur(); }); } bool MainWindow::event(QEvent *e) { @@ -744,7 +749,10 @@ void MainWindow::applyConfig(ghostty_config_t config) { s_needsPremultiply = configHasCustomShader(); // Re-apply window settings that a reload may have changed. - for (MainWindow *w : s_windows) w->applyWindowConfig(); + for (MainWindow *w : s_windows) { + w->applyWindowConfig(); + w->applyBlur(); + } } void MainWindow::reloadConfig() { @@ -811,6 +819,17 @@ void MainWindow::applyWindowConfig() { #endif } +void MainWindow::applyBlur() { + // background-blur is a union whose C value is an i16: 0 (and the + // macOS-only negatives) means off, a positive radius means on. KWin + // uses its own configured radius, so only on/off matters here. + short blur = 0; + if (s_config) + ghostty_config_get(s_config, &blur, "background-blur", + qstrlen("background-blur")); + applyWindowBlur(this, blur > 0); +} + void MainWindow::toggleSplitZoom(GhosttySurface *surface) { // Already zoomed: restore the surface into its splitter and the // stashed tree back into the tab page. diff --git a/qt/src/MainWindow.h b/qt/src/MainWindow.h index bb000b2d5..b4a491435 100644 --- a/qt/src/MainWindow.h +++ b/qt/src/MainWindow.h @@ -128,6 +128,10 @@ private: // 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(); + // Prompt (per `confirm-close-surface`) before closing `surfaces`. // Returns true if the close may proceed. bool confirmCloseSurfaces(const QList &surfaces); diff --git a/qt/src/WindowBlur.cpp b/qt/src/WindowBlur.cpp new file mode 100644 index 000000000..bf1f68314 --- /dev/null +++ b/qt/src/WindowBlur.cpp @@ -0,0 +1,131 @@ +#include "WindowBlur.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include + +#include + +#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(data); + if (std::strcmp(interface, org_kde_kwin_blur_manager_interface.name) == 0) + g->manager = static_cast(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(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(globals.manager), nullptr); + wl_event_queue_destroy(queue); + return globals.manager; +} + +void applyWayland(QWindow *window, bool enabled) { + QPlatformNativeInterface *native = QGuiApplication::platformNativeInterface(); + if (!native) return; + auto *display = static_cast( + native->nativeResourceForIntegration("wl_display")); + auto *surface = static_cast( + native->nativeResourceForWindow("surface", window)); + if (!display || !surface) return; + + org_kde_kwin_blur_manager *manager = blurManager(display); + if (!manager) return; // compositor advertises no blur support + + // The live blur object per window — kept so it can be released when + // blur is turned off or re-applied on a config change. + static QHash blurs; + 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); + } 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( + native->nativeResourceForIntegration("connection")); + if (!conn) return; + const auto xid = static_cast(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); +} diff --git a/qt/src/WindowBlur.h b/qt/src/WindowBlur.h new file mode 100644 index 000000000..2053da7cf --- /dev/null +++ b/qt/src/WindowBlur.h @@ -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); From 82292ee098138711633b60944c85841ea0ab7be2 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 11:35:11 -0500 Subject: [PATCH 45/75] qt: add a command palette (TOGGLE_COMMAND_PALETTE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A searchable command palette shown as a Qt::Popup over the window, so Qt anchors it to the parent (correct placement on Wayland) and dismisses it on an outside click. It loads the `command-palette-entry` config — a large built-in command set plus any user additions — via ghostty_config_get, filters it case-insensitively on title and action name, and runs the chosen command's keybind action on the active surface through ghostty_surface_binding_action. Up/Down navigate, Enter runs, Escape dismisses. The palette is created lazily per window. No libghostty changes — reachable through the existing C API. Co-Authored-By: claude-flow --- qt/CMakeLists.txt | 1 + qt/src/CommandPalette.cpp | 147 ++++++++++++++++++++++++++++++++++++++ qt/src/CommandPalette.h | 44 ++++++++++++ qt/src/MainWindow.cpp | 15 +++- qt/src/MainWindow.h | 8 +++ 5 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 qt/src/CommandPalette.cpp create mode 100644 qt/src/CommandPalette.h diff --git a/qt/CMakeLists.txt b/qt/CMakeLists.txt index 1f19b277e..e9987bb22 100644 --- a/qt/CMakeLists.txt +++ b/qt/CMakeLists.txt @@ -69,6 +69,7 @@ file(CREATE_LINK "ghostty-internal.so" "${GHOSTTY_LIB_DIR}/libghostty.so" add_executable(ghostty src/main.cpp + src/CommandPalette.cpp src/GhosttySurface.cpp src/MainWindow.cpp src/WindowBlur.cpp diff --git a/qt/src/CommandPalette.cpp b/qt/src/CommandPalette.cpp new file mode 100644 index 000000000..cc8eda612 --- /dev/null +++ b/qt/src/CommandPalette.cpp @@ -0,0 +1,147 @@ +#include "CommandPalette.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "GhosttySurface.h" +#include "MainWindow.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); + + connect(m_search, &QLineEdit::textChanged, this, [this](const QString &t) { + m_filter->setFilterFixedString(t); + selectFirstRow(); + }); + connect(m_list, &QListView::activated, this, + [this](const QModelIndex &) { runSelected(); }); + hide(); +} + +void CommandPalette::toggleFor(GhosttySurface *surface) { + if (isVisible()) { + hide(); + 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. + if (m_owner) { + 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 (!ghostty_config_get(cfg, &list, "command-palette-entry", + qstrlen("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(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); +} diff --git a/qt/src/CommandPalette.h b/qt/src/CommandPalette.h new file mode 100644 index 000000000..7bc36e86a --- /dev/null +++ b/qt/src/CommandPalette.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +class GhosttySurface; +class QLineEdit; +class QListView; +class QSortFilterProxyModel; +class QStandardItemModel; + +// 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(); + + QWidget *m_owner; // the window the palette centres over + QLineEdit *m_search = nullptr; + QListView *m_list = nullptr; + QStandardItemModel *m_model = nullptr; + QSortFilterProxyModel *m_filter = nullptr; + QPointer m_surface; // active surface; may go away +}; diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index 508929425..73ab5aac4 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -33,6 +33,7 @@ #include #include +#include "CommandPalette.h" #include "GhosttySurface.h" #include "WindowBlur.h" @@ -819,6 +820,11 @@ void MainWindow::applyWindowConfig() { #endif } +void MainWindow::toggleCommandPalette(GhosttySurface *surface) { + if (!m_commandPalette) m_commandPalette = new CommandPalette(this); + m_commandPalette->toggleFor(surface); +} + void MainWindow::applyBlur() { // background-blur is a union whose C value is an i16: 0 (and the // macOS-only negatives) means off, a positive radius means on. KWin @@ -1335,8 +1341,15 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target, return true; } + case GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE: + if (win) + QMetaObject::invokeMethod( + win, [win, src]() { win->toggleCommandPalette(src); }, + Qt::QueuedConnection); + return true; + default: - // Inspector, command palette, search, etc. are not handled yet. + // Inspector and in-terminal search are not handled yet. return false; } } diff --git a/qt/src/MainWindow.h b/qt/src/MainWindow.h index b4a491435..f50f72901 100644 --- a/qt/src/MainWindow.h +++ b/qt/src/MainWindow.h @@ -15,6 +15,7 @@ class QShowEvent; class QSplitter; class QTabWidget; class QTimer; +class CommandPalette; class GhosttySurface; // A top-level window presenting terminal surfaces as tabs; each tab may @@ -132,6 +133,10 @@ private: // compositor (see WindowBlur). void applyBlur(); + // 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 &surfaces); @@ -192,4 +197,7 @@ private: // 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; }; From 8b7204c7b45dc68a454b79a53a4314a7f74918d7 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 11:47:10 -0500 Subject: [PATCH 46/75] qt: add in-terminal search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A search bar overlaying the terminal, driven entirely through the existing C API — no libghostty change. It feeds the query to libghostty via keybind actions (search:, navigate_search:, end_search), the same path the macOS frontend uses, and mirrors the SEARCH_TOTAL / SEARCH_SELECTED actions in an "n/total" counter. Match highlighting in the terminal comes from the shared core renderer. - SearchBar: a themed QFrame overlay (field + prev/next/close); the panel, field and buttons follow the active Qt style and palette. The match counter sits inside the field at its right edge. - onAction handles START_SEARCH (show, prefill), END_SEARCH (hide), SEARCH_TOTAL / SEARCH_SELECTED (counter). - Keystrokes are debounced 200ms; Enter / Shift+Enter and the buttons navigate matches; Escape closes. - A "Find…" context-menu entry triggers start_search. Co-Authored-By: claude-flow --- qt/CMakeLists.txt | 1 + qt/src/GhosttySurface.cpp | 31 +++++++ qt/src/GhosttySurface.h | 11 +++ qt/src/MainWindow.cpp | 36 +++++++- qt/src/SearchBar.cpp | 175 ++++++++++++++++++++++++++++++++++++++ qt/src/SearchBar.h | 49 +++++++++++ 6 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 qt/src/SearchBar.cpp create mode 100644 qt/src/SearchBar.h diff --git a/qt/CMakeLists.txt b/qt/CMakeLists.txt index e9987bb22..506c42d88 100644 --- a/qt/CMakeLists.txt +++ b/qt/CMakeLists.txt @@ -72,6 +72,7 @@ add_executable(ghostty src/CommandPalette.cpp src/GhosttySurface.cpp src/MainWindow.cpp + src/SearchBar.cpp src/WindowBlur.cpp "${BLUR_CODE}" "${BLUR_HEADER}" diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index 315296e23..6da6fd930 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -1,6 +1,7 @@ #include "GhosttySurface.h" #include "MainWindow.h" +#include "SearchBar.h" #include #include @@ -166,6 +167,7 @@ void GhosttySurface::resizeEvent(QResizeEvent *) { if (m_exitOverlay) m_exitOverlay->setGeometry(rect()); if (m_keySeqOverlay && m_keySeqOverlay->isVisible()) m_keySeqOverlay->move(8, height() - m_keySeqOverlay->height() - 8); + layoutSearchBar(); showResizeOverlay(); } @@ -343,6 +345,34 @@ void GhosttySurface::endKeySequence() { if (m_keySeqOverlay) m_keySeqOverlay->hide(); } +void GhosttySurface::openSearch(const QString &prefill) { + if (!m_searchBar) m_searchBar = new SearchBar(this); + m_searchBar->open(prefill); + layoutSearchBar(); +} + +void GhosttySurface::closeSearch() { + if (m_searchBar) m_searchBar->hide(); +} + +void GhosttySurface::setSearchTotal(int total) { + if (m_searchBar) m_searchBar->setTotal(total); +} + +void GhosttySurface::setSearchSelected(int selected) { + if (m_searchBar) m_searchBar->setSelected(selected); +} + +void GhosttySurface::layoutSearchBar() { + if (!m_searchBar || !m_searchBar->isVisible()) return; + m_searchBar->adjustSize(); + // Top-right, clear of the scrollbar. + const int sbw = (m_scrollbar && m_scrollbar->isVisible()) + ? m_scrollbar->sizeHint().width() + : 0; + m_searchBar->move(width() - m_searchBar->width() - sbw - 8, 8); +} + void GhosttySurface::showResizeOverlay() { if (!m_surface || !m_owner) return; const ghostty_surface_size_s sz = ghostty_surface_size(m_surface); @@ -651,6 +681,7 @@ void GhosttySurface::contextMenuEvent(QContextMenuEvent *ev) { add(&menu, "Paste", "edit-paste", "paste_from_clipboard", !QGuiApplication::clipboard()->text().isEmpty()); add(&menu, "Select All", "edit-select-all", "select_all", true); + add(&menu, "Find…", "edit-find", "start_search", true); add(&menu, "Notify on Next Command Finish", "preferences-desktop-notification", "@notify-command", true); menu.addSeparator(); diff --git a/qt/src/GhosttySurface.h b/qt/src/GhosttySurface.h index b96077470..59fe0ed9e 100644 --- a/qt/src/GhosttySurface.h +++ b/qt/src/GhosttySurface.h @@ -14,6 +14,7 @@ class QDragEnterEvent; class QDropEvent; class QEnterEvent; class QTimer; +class SearchBar; class QInputMethodEvent; class QKeySequence; class QLabel; @@ -80,6 +81,14 @@ public: void pushKeySequence(const QString &chord); void endKeySequence(); + // 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 @@ -118,6 +127,7 @@ private: bool scrollbarAllowed() const; // false when `scrollbar = never` 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); @@ -165,6 +175,7 @@ private: 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 QScrollBar *m_scrollbar = nullptr; // scrollback scrollbar; hidden by default bool m_notifyOnCommand = false; // one-shot: notify on next cmd finish bool m_bellFlash = false; // bell `border` flash in progress diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index 73ab5aac4..e2477091c 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -1348,8 +1348,42 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target, Qt::QueuedConnection); return true; + case GHOSTTY_ACTION_START_SEARCH: { + if (!src) return true; + const char *needle = action.action.start_search.needle; + const QString n = QString::fromUtf8(needle ? needle : ""); + QMetaObject::invokeMethod(src, [src, n]() { src->openSearch(n); }, + Qt::QueuedConnection); + return true; + } + + case GHOSTTY_ACTION_END_SEARCH: + if (src) + QMetaObject::invokeMethod(src, [src]() { src->closeSearch(); }, + Qt::QueuedConnection); + return true; + + case GHOSTTY_ACTION_SEARCH_TOTAL: { + if (!src) return true; + const int total = static_cast(action.action.search_total.total); + QMetaObject::invokeMethod( + src, [src, total]() { src->setSearchTotal(total); }, + Qt::QueuedConnection); + return true; + } + + case GHOSTTY_ACTION_SEARCH_SELECTED: { + if (!src) return true; + const int sel = + static_cast(action.action.search_selected.selected); + QMetaObject::invokeMethod( + src, [src, sel]() { src->setSearchSelected(sel); }, + Qt::QueuedConnection); + return true; + } + default: - // Inspector and in-terminal search are not handled yet. + // The terminal inspector is not handled yet. return false; } } diff --git a/qt/src/SearchBar.cpp b/qt/src/SearchBar.cpp new file mode 100644 index 000000000..07c765921 --- /dev/null +++ b/qt/src/SearchBar.cpp @@ -0,0 +1,175 @@ +#include "SearchBar.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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->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(event); + switch (ke->key()) { + case Qt::Key_Escape: + runAction("end_search"); + hide(); + 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); +} diff --git a/qt/src/SearchBar.h b/qt/src/SearchBar.h new file mode 100644 index 000000000..c1f107716 --- /dev/null +++ b/qt/src/SearchBar.h @@ -0,0 +1,49 @@ +#pragma once + +#include + +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:` + 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; +}; From 7b7c9cee22a5e640cf585131a9a7b56d21b52985 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 11:56:58 -0500 Subject: [PATCH 47/75] qt: handle TOGGLE_VISIBILITY Show or hide every window at once: if any window is visible they are all hidden, otherwise they are all shown and raised. This is the first piece of the quick-terminal work; the dropdown window and the global hotkey follow. Co-Authored-By: claude-flow --- qt/src/MainWindow.cpp | 25 +++++++++++++++++++++++++ qt/src/MainWindow.h | 3 +++ 2 files changed, 28 insertions(+) diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index e2477091c..200efa186 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -509,6 +509,25 @@ void MainWindow::closeAllWindows() { qApp->quit(); } +void MainWindow::toggleVisibility() { + // If anything is showing, hide everything; otherwise reveal it all. + bool anyVisible = false; + for (MainWindow *w : s_windows) + if (w->isVisible()) { + anyVisible = true; + break; + } + for (MainWindow *w : s_windows) { + if (anyVisible) { + w->hide(); + } else { + w->show(); + w->raise(); + w->activateWindow(); + } + } +} + void MainWindow::handleQuitTimer(bool start) { // Only meaningful when a delay is configured; otherwise Qt's // quitOnLastWindowClosed already handles the quit. @@ -1341,6 +1360,12 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target, return true; } + case GHOSTTY_ACTION_TOGGLE_VISIBILITY: + QMetaObject::invokeMethod(qApp, + []() { MainWindow::toggleVisibility(); }, + Qt::QueuedConnection); + return true; + case GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE: if (win) QMetaObject::invokeMethod( diff --git a/qt/src/MainWindow.h b/qt/src/MainWindow.h index f50f72901..01c68e980 100644 --- a/qt/src/MainWindow.h +++ b/qt/src/MainWindow.h @@ -145,6 +145,9 @@ private: // via ghostty_app_needs_confirm_quit. static void closeAllWindows(); + // Show or hide every window at once (TOGGLE_VISIBILITY). + static void toggleVisibility(); + // Wire the libghostty quit_timer action to a delayed QApplication // quit, gated on `quit-after-last-window-closed`. static void handleQuitTimer(bool start); From 118cd1bf43701a4442761c5f9997a9da6d42d14d Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 12:09:09 -0500 Subject: [PATCH 48/75] qt: add the dropdown quick terminal (TOGGLE_QUICK_TERMINAL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quick terminal is a dedicated MainWindow turned into a wlr-layer-shell surface via LayerShellQt: anchored to a screen edge per quick-terminal-position, sized from quick-terminal-size, with keyboard interactivity from quick-terminal-keyboard-interactivity. There is one per process — toggle_quick_terminal creates it on first use, then just shows/hides it. quick-terminal-autohide drops it on focus loss. It reuses the normal MainWindow machinery (tabs, splits, actions); only the decorated-window startup state (decoration/maximize/fullscreen) is skipped, since a layer-shell surface is inherently borderless. The toggle still needs a global hotkey to be useful while Ghostty is unfocused — that follows next. Co-Authored-By: claude-flow --- qt/CMakeLists.txt | 4 ++ qt/src/MainWindow.cpp | 136 ++++++++++++++++++++++++++++++++++++++---- qt/src/MainWindow.h | 12 ++++ 3 files changed, 140 insertions(+), 12 deletions(-) diff --git a/qt/CMakeLists.txt b/qt/CMakeLists.txt index 506c42d88..c2efce6e1 100644 --- a/qt/CMakeLists.txt +++ b/qt/CMakeLists.txt @@ -32,6 +32,9 @@ include(GNUInstallDirs) find_package(Qt6 REQUIRED COMPONENTS Gui GuiPrivate Widgets OpenGL DBus Multimedia) +# 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. @@ -92,6 +95,7 @@ target_link_libraries(ghostty PRIVATE Qt6::Multimedia PkgConfig::WAYLAND_CLIENT PkgConfig::XCB + LayerShellQt::Interface "${GHOSTTY_LIB_DIR}/libghostty.so" ) diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index 200efa186..7bf4e0b2b 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -32,6 +33,9 @@ #include #include #include +#include + +#include #include "CommandPalette.h" #include "GhosttySurface.h" @@ -47,6 +51,7 @@ bool MainWindow::s_needsPremultiply = false; QList MainWindow::s_windows; QTimer *MainWindow::s_quitTimer = nullptr; int MainWindow::s_quitDelayMs = 0; +MainWindow *MainWindow::s_quickTerminal = nullptr; std::atomic MainWindow::s_tickPending{false}; MainWindow::MainWindow() { @@ -84,6 +89,7 @@ MainWindow::MainWindow() { MainWindow::~MainWindow() { s_windows.removeOne(this); + if (this == s_quickTerminal) s_quickTerminal = nullptr; // Destroy this window's surfaces (freeing their ghostty_surface_t) // before any app teardown; Qt's own child cleanup runs after this body. @@ -227,18 +233,21 @@ bool MainWindow::initialize() { QApplication::setQuitOnLastWindowClosed(quitAfter && s_quitDelayMs == 0); } - // Per-window startup window state, applied before show(). - // window-decoration `none` drops the native frame; `auto`/`server`/ - // `client` keep a decorated window (the compositor picks the side on - // Wayland). - if (configString("window-decoration") == QLatin1String("none")) - setWindowFlag(Qt::FramelessWindowHint, true); - // fullscreen wins over maximize; its enum is `false` when unset. - const QString fullscreen = configString("fullscreen"); - if (!fullscreen.isEmpty() && fullscreen != QLatin1String("false")) - setWindowState(windowState() | Qt::WindowFullScreen); - else if (configBool("maximize", false)) - setWindowState(windowState() | Qt::WindowMaximized); + // Per-window startup window state, applied before show(). None of it + // applies to the quick terminal — that is a layer-shell surface. + if (!m_quickTerminal) { + // window-decoration `none` drops the native frame; `auto`/`server`/ + // `client` keep a decorated window (the compositor picks the side + // on Wayland). + if (configString("window-decoration") == QLatin1String("none")) + setWindowFlag(Qt::FramelessWindowHint, true); + // fullscreen wins over maximize; its enum is `false` when unset. + const QString fullscreen = configString("fullscreen"); + if (!fullscreen.isEmpty() && fullscreen != QLatin1String("false")) + setWindowState(windowState() | Qt::WindowFullScreen); + else if (configBool("maximize", false)) + setWindowState(windowState() | Qt::WindowMaximized); + } // Tab-bar policy and colour scheme. applyWindowConfig(); @@ -528,6 +537,103 @@ void MainWindow::toggleVisibility() { } } +void MainWindow::toggleQuickTerminal() { + if (s_quickTerminal) { + if (s_quickTerminal->isVisible()) { + s_quickTerminal->hide(); + } else { + s_quickTerminal->show(); + s_quickTerminal->raise(); + s_quickTerminal->activateWindow(); + } + return; + } + // First use: build the dedicated quick-terminal window. + auto *w = new MainWindow; + w->m_quickTerminal = true; + w->setAttribute(Qt::WA_DeleteOnClose); + if (!w->initialize()) { + delete w; + return; + } + s_quickTerminal = w; + w->setupLayerShell(); + w->show(); +} + +void MainWindow::setupLayerShell() { + // LayerShellQt attaches to the native window; force it into being. + winId(); + QWindow *handle = windowHandle(); + if (!handle) return; + LayerShellQt::Window *ls = LayerShellQt::Window::get(handle); + if (!ls) return; + using LSW = LayerShellQt::Window; + + ls->setLayer(LSW::LayerTop); + const QString ki = configString("quick-terminal-keyboard-interactivity"); + ls->setKeyboardInteractivity( + ki == QLatin1String("exclusive") ? LSW::KeyboardInteractivityExclusive + : ki == QLatin1String("none") ? LSW::KeyboardInteractivityNone + : LSW::KeyboardInteractivityOnDemand); + + QScreen *screen = handle->screen(); + const QSize scr = screen ? screen->size() : QSize(1920, 1080); + + // quick-terminal-size: primary is the edge-perpendicular extent. + ghostty_config_quick_terminal_size_s qsz = {}; + ghostty_config_get(s_config, &qsz, "quick-terminal-size", + qstrlen("quick-terminal-size")); + const auto toPx = [](const ghostty_quick_terminal_size_s &s, int dim, + int fallback) -> int { + switch (s.tag) { + case GHOSTTY_QUICK_TERMINAL_SIZE_PERCENTAGE: + return static_cast(s.value.percentage / 100.0f * dim); + case GHOSTTY_QUICK_TERMINAL_SIZE_PIXELS: + return static_cast(s.value.pixels); + default: + return fallback; + } + }; + + const QString pos = configString("quick-terminal-position"); + LSW::Anchors anchors; + QSize size; + if (pos == QLatin1String("bottom")) { + anchors = LSW::Anchors(LSW::AnchorBottom) | LSW::AnchorLeft | + LSW::AnchorRight; + size = {scr.width(), toPx(qsz.primary, scr.height(), 400)}; + } else if (pos == QLatin1String("left")) { + anchors = LSW::Anchors(LSW::AnchorLeft) | LSW::AnchorTop | + LSW::AnchorBottom; + size = {toPx(qsz.primary, scr.width(), 400), scr.height()}; + } else if (pos == QLatin1String("right")) { + anchors = LSW::Anchors(LSW::AnchorRight) | LSW::AnchorTop | + LSW::AnchorBottom; + size = {toPx(qsz.primary, scr.width(), 400), scr.height()}; + } else if (pos == QLatin1String("center")) { + anchors = LSW::Anchors(LSW::AnchorNone); + size = {toPx(qsz.primary, scr.width(), 800), + toPx(qsz.secondary, scr.height(), 400)}; + } else { // top (the default) + anchors = LSW::Anchors(LSW::AnchorTop) | LSW::AnchorLeft | + LSW::AnchorRight; + size = {scr.width(), toPx(qsz.primary, scr.height(), 400)}; + } + ls->setAnchors(anchors); + ls->setDesiredSize(size); + resize(size); +} + +void MainWindow::changeEvent(QEvent *e) { + // quick-terminal-autohide: drop the dropdown when it loses focus. + if (e->type() == QEvent::ActivationChange && m_quickTerminal && + isVisible() && !isActiveWindow() && + configBool("quick-terminal-autohide", true)) + hide(); + QWidget::changeEvent(e); +} + void MainWindow::handleQuitTimer(bool start) { // Only meaningful when a delay is configured; otherwise Qt's // quitOnLastWindowClosed already handles the quit. @@ -1366,6 +1472,12 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target, Qt::QueuedConnection); return true; + case GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL: + QMetaObject::invokeMethod(qApp, + []() { MainWindow::toggleQuickTerminal(); }, + Qt::QueuedConnection); + return true; + case GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE: if (win) QMetaObject::invokeMethod( diff --git a/qt/src/MainWindow.h b/qt/src/MainWindow.h index 01c68e980..0e7b4d855 100644 --- a/qt/src/MainWindow.h +++ b/qt/src/MainWindow.h @@ -73,6 +73,8 @@ protected: // 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); @@ -133,6 +135,10 @@ private: // 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); @@ -148,6 +154,10 @@ private: // 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(); + // Wire the libghostty quit_timer action to a delayed QApplication // quit, gated on `quit-after-last-window-closed`. static void handleQuitTimer(bool start); @@ -174,6 +184,7 @@ private: 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 @@ -185,6 +196,7 @@ private: static QList 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 // Coalesces wakeup-driven ticks: a tick is queued at most once at a // time, so a busy surface can't flood the event loop. From 0932104ff9fe648ca346469b8f90ee24632f5df6 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 12:39:35 -0500 Subject: [PATCH 49/75] qt: global shortcuts via the GlobalShortcuts XDG portal A quick terminal is only useful if its toggle works while Ghostty is unfocused, which on Wayland needs a compositor-level shortcut. The new GlobalShortcuts client registers shortcuts with the org.freedesktop.portal.GlobalShortcuts portal over QtDBus: - Opens a portal session, then BindShortcuts for `toggle-quick-terminal` (preferred trigger Super+`) and `toggle-visibility`. - A single broad subscription to the portal Request `Response` signal, registered before the event loop, so no response can outrun its match rule (binding the response per-request mid-loop lost the race). - The Activated signal dispatches to MainWindow::toggleQuickTerminal / toggleVisibility. The desktop owns the actual key assignment (KDE System Settings -> Shortcuts). This completes the quick-terminal feature. Co-Authored-By: claude-flow --- qt/CMakeLists.txt | 1 + qt/src/GlobalShortcuts.cpp | 152 +++++++++++++++++++++++++++++++++++++ qt/src/GlobalShortcuts.h | 46 +++++++++++ qt/src/main.cpp | 13 ++++ 4 files changed, 212 insertions(+) create mode 100644 qt/src/GlobalShortcuts.cpp create mode 100644 qt/src/GlobalShortcuts.h diff --git a/qt/CMakeLists.txt b/qt/CMakeLists.txt index c2efce6e1..0d1a5cd19 100644 --- a/qt/CMakeLists.txt +++ b/qt/CMakeLists.txt @@ -74,6 +74,7 @@ add_executable(ghostty src/main.cpp src/CommandPalette.cpp src/GhosttySurface.cpp + src/GlobalShortcuts.cpp src/MainWindow.cpp src/SearchBar.cpp src/WindowBlur.cpp diff --git a/qt/src/GlobalShortcuts.cpp b/qt/src/GlobalShortcuts.cpp new file mode 100644 index 000000000..3c5d35960 --- /dev/null +++ b/qt/src/GlobalShortcuts.cpp @@ -0,0 +1,152 @@ +#include "GlobalShortcuts.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(); + qDBusRegisterMetaType>(); + + 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 + m_requests.insert(requestPath(token), 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 only so a failed invocation is not silently swallowed. + auto *watcher = new QDBusPendingCallWatcher( + QDBusConnection::sessionBus().asyncCall(msg), this); + connect(watcher, &QDBusPendingCallWatcher::finished, this, + [method](QDBusPendingCallWatcher *w) { + QDBusPendingReply reply = *w; + if (reply.isError()) + std::fprintf(stderr, "[ghostty] portal %s failed: %s\n", + method.toUtf8().constData(), + reply.error().message().toUtf8().constData()); + 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(args.at(1)) : QVariantMap(); + if (code != 0) + std::fprintf(stderr, "[ghostty] 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 shortcuts; + shortcuts.append( + {QStringLiteral("toggle-quick-terminal"), + {{QStringLiteral("description"), + QStringLiteral("Toggle the Ghostty quick terminal")}, + {QStringLiteral("preferred_trigger"), QStringLiteral("LOGO+grave")}}}); + shortcuts.append( + {QStringLiteral("toggle-visibility"), + {{QStringLiteral("description"), + QStringLiteral("Toggle Ghostty 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()); +} diff --git a/qt/src/GlobalShortcuts.h b/qt/src/GlobalShortcuts.h new file mode 100644 index 000000000..23e699599 --- /dev/null +++ b/qt/src/GlobalShortcuts.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include + +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 m_requests; // request path -> method name + int m_tokenCounter = 0; +}; diff --git a/qt/src/main.cpp b/qt/src/main.cpp index cdfe9f3da..f209609cd 100644 --- a/qt/src/main.cpp +++ b/qt/src/main.cpp @@ -3,6 +3,7 @@ #include #include +#include "GlobalShortcuts.h" #include "MainWindow.h" #include "ghostty.h" @@ -54,5 +55,17 @@ int main(int argc, char **argv) { 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(); } From 26daf3c1137dec09b6992554a87830430c30c0fd Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 12:57:00 -0500 Subject: [PATCH 50/75] qt: terminal inspector via a new OpenGL inspector C-API The embedded inspector C-API only exposed Metal rendering. The ImGui OpenGL3 backend is already compiled into libghostty on non-Apple targets, so this adds the matching OpenGL render path: - libghostty: an `.opengl` variant of the embedded Inspector's Backend enum plus initOpenGL/renderOpenGL, exported as ghostty_inspector_opengl_init / _render / _shutdown. The render path draws the inspector's ImGui into the bound GL framebuffer. - qt: InspectorWindow renders the inspector handle into an offscreen FBO (the same readback pattern as the terminal) at ~30fps and forwards mouse / text / edit-key input. The INSPECTOR action shows/hides/toggles it per surface. Co-Authored-By: claude-flow --- include/ghostty.h | 8 ++ qt/CMakeLists.txt | 1 + qt/src/GhosttySurface.cpp | 23 +++++ qt/src/GhosttySurface.h | 5 + qt/src/InspectorWindow.cpp | 192 +++++++++++++++++++++++++++++++++++++ qt/src/InspectorWindow.h | 54 +++++++++++ qt/src/MainWindow.cpp | 10 +- src/apprt/embedded.zig | 65 +++++++++++++ 8 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 qt/src/InspectorWindow.cpp create mode 100644 qt/src/InspectorWindow.h diff --git a/include/ghostty.h b/include/ghostty.h index 310bcceb4..cb3aa5907 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -1229,6 +1229,14 @@ 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). +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*); diff --git a/qt/CMakeLists.txt b/qt/CMakeLists.txt index 0d1a5cd19..5c29439a0 100644 --- a/qt/CMakeLists.txt +++ b/qt/CMakeLists.txt @@ -75,6 +75,7 @@ add_executable(ghostty src/CommandPalette.cpp src/GhosttySurface.cpp src/GlobalShortcuts.cpp + src/InspectorWindow.cpp src/MainWindow.cpp src/SearchBar.cpp src/WindowBlur.cpp diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index 6da6fd930..af7b37763 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -1,5 +1,6 @@ #include "GhosttySurface.h" +#include "InspectorWindow.h" #include "MainWindow.h" #include "SearchBar.h" @@ -110,6 +111,9 @@ GhosttySurface::GhosttySurface(ghostty_app_t app, MainWindow *owner, } GhosttySurface::~GhosttySurface() { + // The inspector window holds m_surface; destroy it before m_surface. + delete m_inspectorWindow; + // Release GL-owning objects with the context current. if (makeCurrent()) { if (m_surface) ghostty_surface_free(m_surface); @@ -345,6 +349,25 @@ void GhosttySurface::endKeySequence() { if (m_keySeqOverlay) m_keySeqOverlay->hide(); } +void GhosttySurface::toggleInspector(ghostty_action_inspector_e mode) { + const bool visible = m_inspectorWindow && m_inspectorWindow->isVisible(); + bool show; + switch (mode) { + case GHOSTTY_INSPECTOR_SHOW: show = true; break; + case GHOSTTY_INSPECTOR_HIDE: show = false; break; + default: show = !visible; break; // GHOSTTY_INSPECTOR_TOGGLE + } + if (show) { + if (!m_inspectorWindow) + m_inspectorWindow = new InspectorWindow(m_surface); + m_inspectorWindow->show(); + m_inspectorWindow->raise(); + m_inspectorWindow->activateWindow(); + } else if (m_inspectorWindow) { + m_inspectorWindow->hide(); + } +} + void GhosttySurface::openSearch(const QString &prefill) { if (!m_searchBar) m_searchBar = new SearchBar(this); m_searchBar->open(prefill); diff --git a/qt/src/GhosttySurface.h b/qt/src/GhosttySurface.h index 59fe0ed9e..23089b782 100644 --- a/qt/src/GhosttySurface.h +++ b/qt/src/GhosttySurface.h @@ -14,6 +14,7 @@ class QDragEnterEvent; class QDropEvent; class QEnterEvent; class QTimer; +class InspectorWindow; class SearchBar; class QInputMethodEvent; class QKeySequence; @@ -81,6 +82,9 @@ public: 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. @@ -176,6 +180,7 @@ private: int m_lastCols = 0; // last grid size, to detect changes int m_lastRows = 0; SearchBar *m_searchBar = nullptr; // in-terminal search; lazily made + InspectorWindow *m_inspectorWindow = nullptr; // terminal inspector; lazily made QScrollBar *m_scrollbar = nullptr; // scrollback scrollbar; hidden by default bool m_notifyOnCommand = false; // one-shot: notify on next cmd finish bool m_bellFlash = false; // bell `border` flash in progress diff --git a/qt/src/InspectorWindow.cpp b/qt/src/InspectorWindow.cpp new file mode 100644 index 000000000..1c759af80 --- /dev/null +++ b/qt/src/InspectorWindow.cpp @@ -0,0 +1,192 @@ +#include "InspectorWindow.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +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(r); +} + +// 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("Ghostty 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() { + if (m_inspector && makeCurrent()) { + ghostty_inspector_opengl_shutdown(m_inspector); + delete m_fbo; + m_context->doneCurrent(); + } + if (m_surface) ghostty_inspector_free(m_surface); + 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(); + ghostty_inspector_set_content_scale(m_inspector, dpr, dpr); + ghostty_inspector_set_size(m_inspector, + static_cast(width() * dpr), + static_cast(height() * dpr)); +} + +void InspectorWindow::renderFrame() { + if (!isVisible() || !m_inspector || !makeCurrent()) return; + syncSize(); + + const qreal dpr = devicePixelRatioF(); + const int w = qMax(1, static_cast(width() * dpr)); + const int h = qMax(1, static_cast(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::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. + const QByteArray text = ev->text().toUtf8(); + if (!text.isEmpty() && static_cast(text.at(0)) >= 0x20) + 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); +} diff --git a/qt/src/InspectorWindow.h b/qt/src/InspectorWindow.h new file mode 100644 index 000000000..c765ee884 --- /dev/null +++ b/qt/src/InspectorWindow.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include + +#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. +class InspectorWindow : public QWidget { + Q_OBJECT + +public: + // `surface` is the terminal surface being inspected. + explicit InspectorWindow(ghostty_surface_t surface); + ~InspectorWindow() override; + +protected: + 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 +}; diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index 7bf4e0b2b..16f857bdb 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -1519,8 +1519,16 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target, return true; } + case GHOSTTY_ACTION_INSPECTOR: { + if (!src) return true; + const ghostty_action_inspector_e mode = action.action.inspector; + QMetaObject::invokeMethod( + src, [src, mode]() { src->toggleInspector(mode); }, + Qt::QueuedConnection); + return true; + } + default: - // The terminal inspector is not handled yet. return false; } } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 07a47c4f9..4ddd67425 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1064,10 +1064,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(), } } }; @@ -1168,6 +1170,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); @@ -2187,6 +2235,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. From 47f82b713847c86dd6928d2ce62e4e528f721d43 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 13:06:13 -0500 Subject: [PATCH 51/75] qt: scale the inspector font to the real DPI The embedded inspector font is baked at a hardcoded 2x content scale, so on a non-2x display the glyphs were oversized relative to the DPI-scaled widget style. updateContentScale now also sets style.FontScaleMain (= content_scale / 2) so the text tracks the actual DPI. io.FontGlobalScale was removed in ImGui 1.92, hence FontScaleMain. InspectorWindow pushes the content scale only when it changes, since updateContentScale rebuilds the ImGui style on every call. Co-Authored-By: claude-flow --- qt/src/InspectorWindow.cpp | 7 ++++++- qt/src/InspectorWindow.h | 1 + src/apprt/embedded.zig | 7 +++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/qt/src/InspectorWindow.cpp b/qt/src/InspectorWindow.cpp index 1c759af80..8851e81e9 100644 --- a/qt/src/InspectorWindow.cpp +++ b/qt/src/InspectorWindow.cpp @@ -86,7 +86,12 @@ bool InspectorWindow::makeCurrent() { void InspectorWindow::syncSize() { if (!m_inspector) return; const qreal dpr = devicePixelRatioF(); - ghostty_inspector_set_content_scale(m_inspector, dpr, dpr); + // 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(width() * dpr), static_cast(height() * dpr)); diff --git a/qt/src/InspectorWindow.h b/qt/src/InspectorWindow.h index c765ee884..dfd90e2ff 100644 --- a/qt/src/InspectorWindow.h +++ b/qt/src/InspectorWindow.h @@ -51,4 +51,5 @@ private: 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 }; diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 4ddd67425..3daf0a18c 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1229,6 +1229,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; } From 1e3148976fc9c516b9d32c16cc563d4c4106deb3 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 13:16:34 -0500 Subject: [PATCH 52/75] qt: fix open-config, reload-config freeze, and tab-bar on reload - Open Config did nothing: ghostty_config_open_path only creates the config file and returns its path, it does not launch an editor. The handler now opens that path via QDesktopServices::openUrl. - Reload Config froze the app: ghostty_app_update_config -> App.updateConfig emits CONFIG_CHANGE, and the CONFIG_CHANGE handler called applyConfig -> ghostty_app_update_config again, looping forever. reloadConfig now pushes to libghostty once and adopts the config; CONFIG_CHANGE is treated as a notification that only refreshes window chrome. The redundant per-surface ghostty_surface_update_config loop is dropped (App.updateConfig already propagates to every surface). - The tab bar stayed visible after a reload with a single tab: the `auto` branch forced tabBar()->show() before enabling auto-hide, which does not retroactively hide it. It now sets the bar's visibility from the current tab count. Co-Authored-By: claude-flow --- qt/src/MainWindow.cpp | 72 +++++++++++++++++++++++-------------------- qt/src/MainWindow.h | 21 +++++++------ 2 files changed, 50 insertions(+), 43 deletions(-) diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index 16f857bdb..fec862812 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -860,21 +860,9 @@ void MainWindow::playBellAudio() { m_bellPlayer->play(); } -// Push `config` to the shared app and every surface of every window, -// and adopt it as the live config. Takes ownership of `config` (frees -// the previous one). -void MainWindow::applyConfig(ghostty_config_t config) { - if (!config) return; - ghostty_app_update_config(s_app, config); - for (MainWindow *w : s_windows) - for (GhosttySurface *s : w->m_surfaces) - if (s->surface()) ghostty_surface_update_config(s->surface(), config); - - if (s_config && s_config != config) ghostty_config_free(s_config); - s_config = config; - s_needsPremultiply = configHasCustomShader(); - - // Re-apply window settings that a reload may have changed. +// Refresh every window's chrome (tab-bar policy, colour scheme, blur) +// from the current s_config. +void MainWindow::refreshChrome() { for (MainWindow *w : s_windows) { w->applyWindowConfig(); w->applyBlur(); @@ -888,7 +876,20 @@ void MainWindow::reloadConfig() { ghostty_config_load_cli_args(config); ghostty_config_load_recursive_files(config); ghostty_config_finalize(config); - applyConfig(config); + + // Push to libghostty. App.updateConfig propagates the config to every + // surface and fires CONFIG_CHANGE back at us — which only refreshes + // chrome, never re-pushes, so this does not loop. + ghostty_app_update_config(s_app, config); + + // Adopt the new config. libghostty keeps borrowed references to it + // (the surface message queue), so it must outlive this call — which + // it does, as the live s_config. + if (s_config && s_config != config) ghostty_config_free(s_config); + s_config = config; + s_needsPremultiply = configHasCustomShader(); + + refreshChrome(); } QString MainWindow::configString(const char *key) const { @@ -919,9 +920,11 @@ void MainWindow::applyWindowConfig() { } else if (tabBar == QLatin1String("always")) { m_tabs->setTabBarAutoHide(false); m_tabs->tabBar()->show(); - } else { // auto (the default) - m_tabs->tabBar()->show(); + } else { // auto (the default): hidden while there is a lone tab m_tabs->setTabBarAutoHide(true); + // setTabBarAutoHide does not retroactively correct an explicitly + // shown/hidden bar, so set the right state for the current count. + m_tabs->tabBar()->setVisible(m_tabs->count() > 1); } // window-theme: force a light/dark scheme, or follow the OS. `auto` @@ -1301,9 +1304,19 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target, return true; case GHOSTTY_ACTION_OPEN_CONFIG: { - // libghostty opens the config file in the user's editor itself and - // returns the path; we only need to free that string. + // ghostty_config_open_path creates the config file if missing and + // returns its path; opening it is the apprt's job. ghostty_string_s path = ghostty_config_open_path(); + if (path.ptr && path.len) { + const QString p = + QString::fromUtf8(path.ptr, static_cast(path.len)); + QMetaObject::invokeMethod( + qApp, + [p]() { + QDesktopServices::openUrl(QUrl::fromLocalFile(p)); + }, + Qt::QueuedConnection); + } ghostty_string_free(path); return true; } @@ -1314,20 +1327,13 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target, win, [win]() { win->reloadConfig(); }, Qt::QueuedConnection); return true; - case GHOSTTY_ACTION_CONFIG_CHANGE: { - // Clone libghostty's config so it outlives this callback; applyConfig - // adopts the clone as the live config (and applies it to every - // window). Free the clone ourselves if there is no window to adopt it. - ghostty_config_t cfg = - ghostty_config_clone(action.action.config_change.config); - if (win) - QMetaObject::invokeMethod( - win, [win, cfg]() { win->applyConfig(cfg); }, - Qt::QueuedConnection); - else - ghostty_config_free(cfg); + case GHOSTTY_ACTION_CONFIG_CHANGE: + // A notification: libghostty already holds the new config (this + // often fires as the echo of our own ghostty_app_update_config). + // Re-pushing it would loop, so just refresh window chrome. + QMetaObject::invokeMethod(qApp, []() { MainWindow::refreshChrome(); }, + Qt::QueuedConnection); return true; - } case GHOSTTY_ACTION_INITIAL_SIZE: { if (!win) return false; diff --git a/qt/src/MainWindow.h b/qt/src/MainWindow.h index 0e7b4d855..4d5357138 100644 --- a/qt/src/MainWindow.h +++ b/qt/src/MainWindow.h @@ -40,6 +40,13 @@ public: // 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); @@ -117,10 +124,11 @@ private: // Copy the current tab's effective title to the clipboard. void copyTitleToClipboard(); - // Config: rebuild from disk (reloadConfig) or apply one libghostty - // handed us (applyConfig), pushing it to the app and every surface. + // Rebuild the config from disk and push it to libghostty. void reloadConfig(); - void applyConfig(ghostty_config_t config); + // 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. @@ -151,13 +159,6 @@ private: // via ghostty_app_needs_confirm_quit. static void closeAllWindows(); - // 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(); - // Wire the libghostty quit_timer action to a delayed QApplication // quit, gated on `quit-after-last-window-closed`. static void handleQuitTimer(bool start); From ad87ba898c1c15824ff8c913d6e1811aa0db0f79 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 13:44:52 -0500 Subject: [PATCH 53/75] qt: replace the scrollbar with a floating overlay widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal scrollbar was a docked QScrollBar that reserved a strip and shrank the grid — unlike the GTK4/macOS frontends, whose native toolkit scrollbars are floating overlays. Qt's QScrollBar has no overlay mode, so this adds a purpose-built OverlayScrollbar widget: - custom-painted, antialiased rounded-pill handle; - fades in on scroll activity and out after ~1.4s idle (real animation via QPropertyAnimation); - expands and brightens on hover / while dragging; - handle colour derived from the terminal background luminance; - drag to scroll, click the trough to page; ignores output that merely follows the bottom of the buffer. It floats over the terminal, so the grid keeps its full width. When faded out the widget is hidden (so the terminal gets edge clicks); GhosttySurface re-reveals it when the pointer reaches the right edge. Co-Authored-By: claude-flow --- qt/CMakeLists.txt | 1 + qt/src/GhosttySurface.cpp | 93 ++++++++++++-------- qt/src/GhosttySurface.h | 6 +- qt/src/OverlayScrollbar.cpp | 170 ++++++++++++++++++++++++++++++++++++ qt/src/OverlayScrollbar.h | 63 +++++++++++++ 5 files changed, 295 insertions(+), 38 deletions(-) create mode 100644 qt/src/OverlayScrollbar.cpp create mode 100644 qt/src/OverlayScrollbar.h diff --git a/qt/CMakeLists.txt b/qt/CMakeLists.txt index 5c29439a0..c4c8b0b1f 100644 --- a/qt/CMakeLists.txt +++ b/qt/CMakeLists.txt @@ -77,6 +77,7 @@ add_executable(ghostty src/GlobalShortcuts.cpp src/InspectorWindow.cpp src/MainWindow.cpp + src/OverlayScrollbar.cpp src/SearchBar.cpp src/WindowBlur.cpp "${BLUR_CODE}" diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index af7b37763..1938937a8 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -2,6 +2,7 @@ #include "InspectorWindow.h" #include "MainWindow.h" +#include "OverlayScrollbar.h" #include "SearchBar.h" #include @@ -32,8 +33,6 @@ #include #include #include -#include -#include #include #include #include @@ -50,15 +49,17 @@ GhosttySurface::GhosttySurface(ghostty_app_t app, MainWindow *owner, setAttribute(Qt::WA_InputMethodEnabled, true); // IME composition setAcceptDrops(true); // file / text drops - // Scrollback scrollbar: driven by SCROLLBAR actions, hidden until one - // reports scrollback. Dragging it runs libghostty's scroll_to_row. - m_scrollbar = new QScrollBar(Qt::Vertical, this); - m_scrollbar->hide(); - connect(m_scrollbar, &QScrollBar::valueChanged, this, [this](int v) { - if (!m_surface) return; - const QByteArray a = "scroll_to_row:" + QByteArray::number(v); - ghostty_surface_binding_action(m_surface, a.constData(), a.size()); - }); + // Scrollback scrollbar: a floating overlay driven by SCROLLBAR + // actions. Dragging it runs libghostty's scroll_to_row. + m_scrollbar = new OverlayScrollbar(this); + connect(m_scrollbar, &OverlayScrollbar::scrollToRow, this, + [this](int row) { + if (!m_surface) return; + const QByteArray a = + "scroll_to_row:" + QByteArray::number(row); + ghostty_surface_binding_action(m_surface, a.constData(), + a.size()); + }); // The widget paints a per-pixel-alpha QImage of the terminal; a // translucent background lets that alpha reach the desktop. setAttribute(Qt::WA_TranslucentBackground); @@ -142,11 +143,9 @@ void GhosttySurface::syncSurfaceSize() { // is the true (possibly fractional) scale because main() selects the // PassThrough rounding policy. const double dpr = devicePixelRatioF(); - // The terminal renders into the width left of the scrollbar strip. - const int sbw = (m_scrollbar && m_scrollbar->isVisible()) - ? m_scrollbar->sizeHint().width() - : 0; - const int w = std::max(1, static_cast((width() - sbw) * dpr)); + // The terminal fills the full width; the scrollbar is a thin overlay + // floating on top, so it does not subtract from the grid. + const int w = std::max(1, static_cast(width() * dpr)); const int h = std::max(1, static_cast(height() * dpr)); if (w == m_fbw && h == m_fbh && dpr == m_fbDpr) return; m_fbw = w; @@ -190,9 +189,11 @@ void GhosttySurface::renderIfDirty() { } void GhosttySurface::layoutScrollbar() { - if (!m_scrollbar || !m_scrollbar->isVisible()) return; - const int w = m_scrollbar->sizeHint().width(); - m_scrollbar->setGeometry(width() - w, 0, w, height()); + if (!m_scrollbar) return; + // Always positioned (even while faded out) so it is placed correctly + // the moment it is revealed. + m_scrollbar->setGeometry(width() - OverlayScrollbar::kWidth, 0, + OverlayScrollbar::kWidth, height()); } // `scrollbar = never` in the config hides the scrollbar unconditionally; @@ -209,20 +210,35 @@ bool GhosttySurface::scrollbarAllowed() const { void GhosttySurface::updateScrollbar(uint64_t total, uint64_t offset, uint64_t len) { - const bool visible = scrollbarAllowed() && total > len; - if (visible != m_scrollbar->isVisible()) { - m_scrollbar->setVisible(visible); - layoutScrollbar(); - syncSurfaceSize(); // the terminal's available width changed + if (!m_scrollbar) return; + if (!scrollbarAllowed() || total <= len) { + m_scrollbar->setMetrics(0, 0, 0); + m_scrollbar->hide(); + return; } - if (!visible) return; + m_scrollbar->setMetrics(total, offset, len); - // Update the range without echoing back through valueChanged as a - // scroll_to_row (which would fight libghostty's own scroll state). - const QSignalBlocker block(m_scrollbar); - m_scrollbar->setRange(0, static_cast(total - len)); - m_scrollbar->setPageStep(static_cast(len)); - m_scrollbar->setValue(static_cast(offset)); + // Overlay behaviour: reveal the scrollbar on scroll activity, but not + // for output that merely follows the bottom of the buffer. + const bool atBottom = offset + len >= total; + if (!atBottom || !m_scrollAtBottom) flashScrollbar(); + m_scrollAtBottom = atBottom; +} + +// Reveal the overlay scrollbar (it fades itself back out when idle). +void GhosttySurface::flashScrollbar() { + if (!m_scrollbar || !scrollbarAllowed()) return; + // Handle colour: light on a dark terminal, dark on a light one. + ghostty_config_color_s bg{}; + if (m_owner && m_owner->config() && + ghostty_config_get(m_owner->config(), &bg, "background", + qstrlen("background"))) { + const double luma = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b; + m_scrollbar->setHandleColor(luma < 128.0 ? QColor(235, 235, 235) + : QColor(45, 45, 45)); + } + layoutScrollbar(); + m_scrollbar->reveal(); } void GhosttySurface::renderTerminal() { @@ -389,11 +405,9 @@ void GhosttySurface::setSearchSelected(int selected) { void GhosttySurface::layoutSearchBar() { if (!m_searchBar || !m_searchBar->isVisible()) return; m_searchBar->adjustSize(); - // Top-right, clear of the scrollbar. - const int sbw = (m_scrollbar && m_scrollbar->isVisible()) - ? m_scrollbar->sizeHint().width() - : 0; - m_searchBar->move(width() - m_searchBar->width() - sbw - 8, 8); + // Top-right, kept clear of the overlay scrollbar's strip. + m_searchBar->move( + width() - m_searchBar->width() - OverlayScrollbar::kWidth - 8, 8); } void GhosttySurface::showResizeOverlay() { @@ -794,6 +808,12 @@ void GhosttySurface::mouseMoveEvent(QMouseEvent *ev) { ghostty_surface_mouse_pos(m_surface, ev->position().x(), ev->position().y(), translateMods(ev->modifiers())); + + // Reveal the overlay scrollbar when the pointer reaches the right + // edge. While it is visible the scrollbar widget grabs the strip + // itself; this only fires once it has faded out and been hidden. + if (ev->position().x() >= width() - OverlayScrollbar::kWidth) + flashScrollbar(); } void GhosttySurface::wheelEvent(QWheelEvent *ev) { @@ -801,6 +821,7 @@ void GhosttySurface::wheelEvent(QWheelEvent *ev) { // angleDelta is in eighths of a degree; 120 units == one wheel notch. const QPoint d = ev->angleDelta(); ghostty_surface_mouse_scroll(m_surface, d.x() / 120.0, d.y() / 120.0, 0); + flashScrollbar(); // mouse-wheel scrolling reveals the overlay scrollbar } void GhosttySurface::enterEvent(QEnterEvent *) { diff --git a/qt/src/GhosttySurface.h b/qt/src/GhosttySurface.h index 23089b782..73333ca15 100644 --- a/qt/src/GhosttySurface.h +++ b/qt/src/GhosttySurface.h @@ -24,7 +24,7 @@ class QOpenGLContext; class QOpenGLFramebufferObject; class QOpenGLShaderProgram; class QOpenGLVertexArrayObject; -class QScrollBar; +class OverlayScrollbar; // One Ghostty terminal pane. // @@ -129,6 +129,7 @@ private: 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 @@ -181,7 +182,8 @@ private: int m_lastRows = 0; SearchBar *m_searchBar = nullptr; // in-terminal search; lazily made InspectorWindow *m_inspectorWindow = nullptr; // terminal inspector; lazily made - QScrollBar *m_scrollbar = nullptr; // scrollback scrollbar; hidden by default + 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 diff --git a/qt/src/OverlayScrollbar.cpp b/qt/src/OverlayScrollbar.cpp new file mode 100644 index 000000000..d33dd09a9 --- /dev/null +++ b/qt/src/OverlayScrollbar.cpp @@ -0,0 +1,170 @@ +#include "OverlayScrollbar.h" + +#include + +#include +#include +#include +#include +#include + +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; + if (isVisible()) 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(static_cast(trackH) * m_len / + static_cast(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(static_cast(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(top - kMargin) / travel + : 0.0; + frac = std::clamp(frac, 0.0, 1.0); + emit scrollToRow(static_cast(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(m_len); + qint64 row = static_cast(m_offset) + + (pos.y() < handle.top() ? -page : page); + row = std::clamp(row, 0, + static_cast(m_total - m_len)); + emit scrollToRow(static_cast(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(); +} diff --git a/qt/src/OverlayScrollbar.h b/qt/src/OverlayScrollbar.h new file mode 100644 index 000000000..1e7b9555d --- /dev/null +++ b/qt/src/OverlayScrollbar.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include + +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; +}; From ac9ea41b693e3d4eedd26b3c6c3067c92543df42 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 14:21:13 -0500 Subject: [PATCH 54/75] qt: tear a tab off into a new window by dragging it out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging a tab clear of the tab bar starts a QDrag — a snapshot of the tab follows the cursor. Releasing it on a tab bar cancels the tear-off; releasing it anywhere else (the terminal, the desktop, another window) moves the tab, and its split tree of surfaces, into a brand-new window. - TabBar/TabWidget: detect the off-bar drag, run the QDrag, and emit tabTornOff. The drop decision keys off a flag set by a tab bar's own dropEvent — QDrag::exec()'s result is unreliable across surfaces on Wayland. The dragged tab is found via currentIndex() so an in-bar reorder during the gesture cannot select the wrong one. - MainWindow::detachTab / adoptTab move a tab's page and re-home its GhosttySurfaces (the shared libghostty surfaces are untouched). - GhosttySurface accepts the tear-off MIME so no "forbidden" cursor is drawn while a torn-off tab hovers the terminal. Co-Authored-By: claude-flow --- qt/CMakeLists.txt | 1 + qt/src/GhosttySurface.cpp | 14 ++++- qt/src/GhosttySurface.h | 2 + qt/src/MainWindow.cpp | 47 +++++++++++++++- qt/src/MainWindow.h | 8 ++- qt/src/TabWidget.cpp | 109 ++++++++++++++++++++++++++++++++++++++ qt/src/TabWidget.h | 55 +++++++++++++++++++ 7 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 qt/src/TabWidget.cpp create mode 100644 qt/src/TabWidget.h diff --git a/qt/CMakeLists.txt b/qt/CMakeLists.txt index c4c8b0b1f..f523cb752 100644 --- a/qt/CMakeLists.txt +++ b/qt/CMakeLists.txt @@ -79,6 +79,7 @@ add_executable(ghostty src/MainWindow.cpp src/OverlayScrollbar.cpp src/SearchBar.cpp + src/TabWidget.cpp src/WindowBlur.cpp "${BLUR_CODE}" "${BLUR_HEADER}" diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index 1938937a8..75d3e11f9 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -4,6 +4,7 @@ #include "MainWindow.h" #include "OverlayScrollbar.h" #include "SearchBar.h" +#include "TabWidget.h" #include #include @@ -776,12 +777,23 @@ void GhosttySurface::contextMenuEvent(QContextMenuEvent *ev) { } void GhosttySurface::dragEnterEvent(QDragEnterEvent *ev) { - if (ev->mimeData()->hasUrls() || ev->mimeData()->hasText()) + // Accept a tab tear-off drag too — not to handle it, but so Qt does + // not paint a "forbidden" cursor while a torn-off tab hovers the + // terminal. The tear-off still completes as a new window (only a tab + // bar's drop cancels it). + if (ev->mimeData()->hasUrls() || ev->mimeData()->hasText() || + ev->mimeData()->hasFormat(QString::fromLatin1(kGhosttyTabMime))) ev->acceptProposedAction(); } void GhosttySurface::dropEvent(QDropEvent *ev) { const QMimeData *mime = ev->mimeData(); + // A tab tear-off released on the terminal: accept it cleanly and let + // the tear-off code turn it into a new window. + if (mime->hasFormat(QString::fromLatin1(kGhosttyTabMime))) { + ev->acceptProposedAction(); + return; + } QString text; if (mime->hasUrls()) { // Dropped files are inserted as shell-quoted, space-separated paths. diff --git a/qt/src/GhosttySurface.h b/qt/src/GhosttySurface.h index 73333ca15..d5537683a 100644 --- a/qt/src/GhosttySurface.h +++ b/qt/src/GhosttySurface.h @@ -48,6 +48,8 @@ public: 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). diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index fec862812..38ec61771 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -39,6 +40,7 @@ #include "CommandPalette.h" #include "GhosttySurface.h" +#include "TabWidget.h" #include "WindowBlur.h" // Prefix marking a tab with an unacknowledged bell (bell-features title). @@ -59,7 +61,7 @@ MainWindow::MainWindow() { // Let a translucent terminal background show through to the desktop. setAttribute(Qt::WA_TranslucentBackground); - m_tabs = new QTabWidget(this); + m_tabs = new TabWidget(this); m_tabs->setTabsClosable(true); m_tabs->setMovable(true); m_tabs->setDocumentMode(true); @@ -85,6 +87,7 @@ MainWindow::MainWindow() { &MainWindow::onTabCloseRequested); connect(m_tabs, &QTabWidget::currentChanged, this, &MainWindow::onCurrentChanged); + connect(m_tabs, &TabWidget::tabTornOff, this, &MainWindow::detachTab); } MainWindow::~MainWindow() { @@ -426,6 +429,48 @@ void MainWindow::closeTab(int index) { } } +void MainWindow::adoptTab(MainWindow *src, QWidget *page) { + const int srcIndex = src->m_tabs->indexOf(page); + if (srcIndex < 0 || src == this) return; + + // Re-home every surface in the tab — the libghostty surfaces are + // unaffected (the app is shared), only the owning window changes. + for (GhosttySurface *s : page->findChildren()) { + src->m_surfaces.removeOne(s); + if (!m_surfaces.contains(s)) m_surfaces.append(s); + s->setOwner(this); + } + + const QString text = src->m_tabs->tabText(srcIndex); + const QVariant data = src->m_tabs->tabBar()->tabData(srcIndex); + src->m_tabs->removeTab(srcIndex); // page is now parentless + const int index = m_tabs->addTab(page, text); // reparents page here + m_tabs->tabBar()->setTabData(index, data); + m_tabs->setCurrentIndex(index); + + if (src->m_tabs->count() == 0) { + src->m_skipCloseConfirm = true; + src->close(); + } +} + +void MainWindow::detachTab(int index) { + QWidget *page = m_tabs->widget(index); + if (!page || m_tabs->count() <= 1) return; // never tear off a lone tab + + auto *w = new MainWindow; + w->setAttribute(Qt::WA_DeleteOnClose); + w->m_firstTabPending = false; // it is handed the torn-off tab instead + if (!w->initialize()) { + delete w; + return; + } + w->adoptTab(this, page); + w->resize(size()); + w->show(); + w->move(QCursor::pos()); // a hint; Wayland leaves placement to KWin +} + void MainWindow::setSurfaceTitle(GhosttySurface *surface, const QString &title) { const int index = tabIndexForSurface(surface); diff --git a/qt/src/MainWindow.h b/qt/src/MainWindow.h index 4d5357138..33baed960 100644 --- a/qt/src/MainWindow.h +++ b/qt/src/MainWindow.h @@ -13,7 +13,7 @@ class QCloseEvent; class QMediaPlayer; class QShowEvent; class QSplitter; -class QTabWidget; +class TabWidget; class QTimer; class CommandPalette; class GhosttySurface; @@ -95,6 +95,10 @@ private: 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 surfacesInTab(int index) const; @@ -180,7 +184,7 @@ private: bool); static void onCloseSurface(void *ud, bool process_active); - QTabWidget *m_tabs = nullptr; + TabWidget *m_tabs = nullptr; QList 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 diff --git a/qt/src/TabWidget.cpp b/qt/src/TabWidget.cpp new file mode 100644 index 000000000..5d32ae9a4 --- /dev/null +++ b/qt/src/TabWidget.cpp @@ -0,0 +1,109 @@ +#include "TabWidget.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { +// Set by a TabBar::dropEvent during an in-flight tear-off. It is the +// reliable "released on a tab bar" signal: QDrag::exec()'s return value +// cannot be trusted across surfaces on Wayland. +bool g_tabDropHandled = false; +} // namespace + +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()); + 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. g_tabDropHandled — set by TabBar::dropEvent — is the + // signal, since QDrag::exec()'s result is unreliable across surfaces + // on Wayland. + g_tabDropHandled = false; + drag->exec(Qt::MoveAction); + + m_tearing = false; + m_pressIndex = -1; + if (!g_tabDropHandled) 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. + if (e->mimeData()->hasFormat(QString::fromLatin1(kGhosttyTabMime))) { + g_tabDropHandled = true; + 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); +} diff --git a/qt/src/TabWidget.h b/qt/src/TabWidget.h new file mode 100644 index 000000000..fe449e407 --- /dev/null +++ b/qt/src/TabWidget.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include + +class QDragEnterEvent; +class QDropEvent; +class QMouseEvent; + +// MIME type marking a Ghostty 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 Ghostty window). +inline constexpr char kGhosttyTabMime[] = "application/x-ghostty-tab"; + +// 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) : QTabBar(parent) {} + +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; + +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 +}; + +// 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); +}; From e94de8fb2ee45d013aa9df51922a8ac294a6d926 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 15:22:00 -0500 Subject: [PATCH 55/75] qt: rebrand to Ghastty with a custom glitch-ghost app icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the project, target, desktop entry, window titles and D-Bus launcher identity from "ghostty" to "ghastty", and add a custom scalable app icon at qt/dist/ghastty.svg — a glitch/corrupted ghost on a CRT terminal screen. The SVG is authored within QtSvg's supported subset (no clipPath, mask, filter or blend modes): shading is layered gradient-filled copies of the ghost path, and the bezel is a thick stroked ring that masks screen-content overflow. The icon is embedded as a Qt resource (:/ghastty.svg) so it works from the build tree, and installed into hicolor/scalable/apps, replacing the reused upstream PNG icons. Co-Authored-By: claude-flow --- qt/CMakeLists.txt | 36 ++-- qt/dist/{ghostty.desktop => ghastty.desktop} | 8 +- qt/dist/ghastty.svg | 177 +++++++++++++++++++ qt/src/MainWindow.cpp | 20 +-- qt/src/main.cpp | 9 +- 5 files changed, 218 insertions(+), 32 deletions(-) rename qt/dist/{ghostty.desktop => ghastty.desktop} (75%) create mode 100644 qt/dist/ghastty.svg diff --git a/qt/CMakeLists.txt b/qt/CMakeLists.txt index f523cb752..b069e500c 100644 --- a/qt/CMakeLists.txt +++ b/qt/CMakeLists.txt @@ -1,8 +1,8 @@ cmake_minimum_required(VERSION 3.16) # C is needed for the wayland-scanner-generated protocol code. -project(ghostty LANGUAGES CXX C) +project(ghastty LANGUAGES CXX C) -# A Qt6 frontend for Ghostty that embeds libghostty through the +# 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 @@ -20,7 +20,7 @@ project(ghostty LANGUAGES CXX C) # Then build, run, and (optionally) install this app: # # cmake -S qt -B qt/build && cmake --build qt/build -# ./qt/build/ghostty +# ./qt/build/ghastty # cmake --install qt/build --prefix ~/.local set(CMAKE_CXX_STANDARD 17) @@ -30,7 +30,7 @@ set(CMAKE_AUTOMOC ON) include(GNUInstallDirs) find_package(Qt6 REQUIRED COMPONENTS Gui GuiPrivate Widgets OpenGL DBus - Multimedia) + Multimedia Svg) # LayerShellQt: the quick terminal is a wlr-layer-shell dropdown window. find_package(LayerShellQt REQUIRED) @@ -70,7 +70,7 @@ endif() file(CREATE_LINK "ghostty-internal.so" "${GHOSTTY_LIB_DIR}/libghostty.so" SYMBOLIC) -add_executable(ghostty +add_executable(ghastty src/main.cpp src/CommandPalette.cpp src/GhosttySurface.cpp @@ -85,18 +85,25 @@ add_executable(ghostty "${BLUR_HEADER}" ) -target_include_directories(ghostty PRIVATE +# 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 ) -target_link_libraries(ghostty PRIVATE +target_link_libraries(ghastty PRIVATE Qt6::Gui Qt6::GuiPrivate Qt6::Widgets Qt6::OpenGL Qt6::DBus Qt6::Multimedia + Qt6::Svg PkgConfig::WAYLAND_CLIENT PkgConfig::XCB LayerShellQt::Interface @@ -105,25 +112,22 @@ target_link_libraries(ghostty PRIVATE # RPATH: the build-tree binary finds libghostty.so in zig-out/lib; the # installed binary finds it next to itself ($ORIGIN/../lib). -set_target_properties(ghostty PROPERTIES +set_target_properties(ghastty PROPERTIES BUILD_RPATH "${GHOSTTY_LIB_DIR}" INSTALL_RPATH "$ORIGIN/../${CMAKE_INSTALL_LIBDIR}" ) # --- install --------------------------------------------------------- -install(TARGETS ghostty RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") +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/ghostty.desktop +install(FILES dist/ghastty.desktop DESTINATION "${CMAKE_INSTALL_DATADIR}/applications") -# Reuse Ghostty's own PNG icons, installed into the hicolor theme. -foreach(sz 16 32 128 256 512) - install(FILES "${GHOSTTY_ROOT}/images/icons/icon_${sz}.png" - DESTINATION "${CMAKE_INSTALL_DATADIR}/icons/hicolor/${sz}x${sz}/apps" - RENAME ghostty.png) -endforeach() +# The custom scalable app icon. +install(FILES dist/ghastty.svg + DESTINATION "${CMAKE_INSTALL_DATADIR}/icons/hicolor/scalable/apps") diff --git a/qt/dist/ghostty.desktop b/qt/dist/ghastty.desktop similarity index 75% rename from qt/dist/ghostty.desktop rename to qt/dist/ghastty.desktop index df90eee55..3ea8ad459 100644 --- a/qt/dist/ghostty.desktop +++ b/qt/dist/ghastty.desktop @@ -1,13 +1,13 @@ [Desktop Entry] Version=1.0 Type=Application -Name=Ghostty +Name=Ghastty GenericName=Terminal Comment=A terminal emulator -Exec=ghostty -Icon=ghostty +Exec=ghastty +Icon=ghastty Categories=System;TerminalEmulator; Keywords=terminal;tty;pty; StartupNotify=true -StartupWMClass=ghostty +StartupWMClass=ghastty Terminal=false diff --git a/qt/dist/ghastty.svg b/qt/dist/ghastty.svg new file mode 100644 index 000000000..40678d8cc --- /dev/null +++ b/qt/dist/ghastty.svg @@ -0,0 +1,177 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index 38ec61771..f61a88180 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -57,7 +57,7 @@ MainWindow *MainWindow::s_quickTerminal = nullptr; std::atomic MainWindow::s_tickPending{false}; MainWindow::MainWindow() { - setWindowTitle(QStringLiteral("Ghostty (Qt)")); + setWindowTitle(QStringLiteral("Ghastty")); // Let a translucent terminal background show through to the desktop. setAttribute(Qt::WA_TranslucentBackground); @@ -165,9 +165,9 @@ static void postNotification(const QString &title, const QString &body) { QStringLiteral("org.freedesktop.Notifications"), QStringLiteral("Notify")); msg.setArguments({ - QStringLiteral("Ghostty"), // app_name + QStringLiteral("Ghastty"), // app_name uint(0), // replaces_id - QStringLiteral("utilities-terminal"), // app_icon + QStringLiteral("ghastty"), // app_icon title, // summary body, // body QStringList(), // actions @@ -178,17 +178,17 @@ static void postNotification(const QString &title, const QString &body) { } // Drive the taskbar progress bar via the Unity LauncherEntry D-Bus API -// (honored by the KDE task manager), keyed to ghostty.desktop. +// (honored by the KDE task manager), keyed to ghastty.desktop. static void postProgress(bool visible, double fraction) { QDBusMessage msg = QDBusMessage::createSignal( - QStringLiteral("/com/canonical/unity/launcherentry/ghostty"), + QStringLiteral("/com/canonical/unity/launcherentry/ghastty"), QStringLiteral("com.canonical.Unity.LauncherEntry"), QStringLiteral("Update")); QVariantMap props; props[QStringLiteral("progress")] = fraction; props[QStringLiteral("progress-visible")] = visible; msg.setArguments( - {QStringLiteral("application://ghostty.desktop"), QVariant(props)}); + {QStringLiteral("application://ghastty.desktop"), QVariant(props)}); QDBusConnection::sessionBus().send(msg); } @@ -328,9 +328,9 @@ GhosttySurface *MainWindow::newTab(ghostty_surface_t parent) { if (configString("window-new-tab-position") == QLatin1String("current") && m_tabs->count() > 0) index = m_tabs->insertTab(m_tabs->currentIndex() + 1, page, - QStringLiteral("Ghostty")); + QStringLiteral("Ghastty")); else - index = m_tabs->addTab(page, QStringLiteral("Ghostty")); + index = m_tabs->addTab(page, QStringLiteral("Ghastty")); m_tabs->setCurrentIndex(index); surface->setFocus(); return surface; @@ -879,10 +879,10 @@ void MainWindow::updateTabText(int tab) { const QString override = data.value(1); QString text = !override.isEmpty() ? override : !base.isEmpty() ? base - : QStringLiteral("Ghostty"); + : QStringLiteral("Ghastty"); m_tabs->setTabText(tab, tabBellMarked(tab) ? kBellMark + text : text); if (tab == m_tabs->currentIndex()) - setWindowTitle(text + QStringLiteral(" — Ghostty")); + setWindowTitle(text + QStringLiteral(" — Ghastty")); } void MainWindow::playBellAudio() { diff --git a/qt/src/main.cpp b/qt/src/main.cpp index f209609cd..4271eb8ed 100644 --- a/qt/src/main.cpp +++ b/qt/src/main.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include "GlobalShortcuts.h" @@ -27,10 +28,14 @@ int main(int argc, char **argv) { QApplication app(argc, argv); - // Match the installed ghostty.desktop: this becomes the Wayland app-id + // 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("ghostty")); + 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 From f68f339ecf10b0bf672d4d6b81db0ba1e0d5fbcd Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 15:30:37 -0500 Subject: [PATCH 56/75] qt: rebrand to Ghastty with a custom glitch-ghost app icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the Qt frontend's identity from Ghostty to Ghastty so it is distinct from upstream: the CMake project and executable (ghastty), the desktop entry, the Wayland app-id, window titles, tab labels, the LauncherEntry D-Bus paths, the portal global-shortcut descriptions and the inspector window title. libghostty itself, the ~/.config/ghostty config path and libghostty config vocabulary are left untouched. Add a custom app icon (qt/dist/ghastty.svg) — a glitch/corrupted ghost on a CRT terminal screen — embedded as a Qt resource (so it shows when run from the build tree) and installed into the hicolor icon theme. Co-Authored-By: claude-flow --- qt/src/GlobalShortcuts.cpp | 4 ++-- qt/src/InspectorWindow.cpp | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/qt/src/GlobalShortcuts.cpp b/qt/src/GlobalShortcuts.cpp index 3c5d35960..f9b575f65 100644 --- a/qt/src/GlobalShortcuts.cpp +++ b/qt/src/GlobalShortcuts.cpp @@ -131,12 +131,12 @@ void GlobalShortcuts::handleCreateSession(uint code, shortcuts.append( {QStringLiteral("toggle-quick-terminal"), {{QStringLiteral("description"), - QStringLiteral("Toggle the Ghostty quick terminal")}, + QStringLiteral("Toggle the Ghastty quick terminal")}, {QStringLiteral("preferred_trigger"), QStringLiteral("LOGO+grave")}}}); shortcuts.append( {QStringLiteral("toggle-visibility"), {{QStringLiteral("description"), - QStringLiteral("Toggle Ghostty window visibility")}}}); + QStringLiteral("Toggle Ghastty window visibility")}}}); portalCall(QStringLiteral("BindShortcuts"), {QVariant::fromValue(QDBusObjectPath(m_sessionHandle)), diff --git a/qt/src/InspectorWindow.cpp b/qt/src/InspectorWindow.cpp index 8851e81e9..54c16cda8 100644 --- a/qt/src/InspectorWindow.cpp +++ b/qt/src/InspectorWindow.cpp @@ -47,7 +47,7 @@ ghostty_input_key_e translateKey(int key) { InspectorWindow::InspectorWindow(ghostty_surface_t surface) : m_surface(surface) { - setWindowTitle(QStringLiteral("Ghostty Inspector")); + setWindowTitle(QStringLiteral("Ghastty Inspector")); setFocusPolicy(Qt::StrongFocus); setMouseTracking(true); resize(800, 600); From 228d4570963a1150198ff4823babc31c368cd9dd Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 18:48:06 -0500 Subject: [PATCH 57/75] core: don't re-emit utf8 on key release in kitty encoder fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kitty encoder's 'no entry' fallback wrote event.utf8 whenever it was non-empty, including on release events. With report_events enabled (the default in nvim's kitty keyboard protocol use), this re-emitted the character on key-up, doubling every keystroke whose key lacked an entry — i.e. anything where the apprt didn't populate unshifted_codepoint. The parallel plain_text fast path already guards on action != .release; extend the same guard to the entry_ orelse fallback. --- src/input/key_encode.zig | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/input/key_encode.zig b/src/input/key_encode.zig index 6ab5a4cc8..474fb37d8 100644 --- a/src/input/key_encode.zig +++ b/src/input/key_encode.zig @@ -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; }; From 81c6517db2a7e4b7839d3df709dad35aadb8c82c Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 19 May 2026 18:48:18 -0500 Subject: [PATCH 58/75] qt: fix Tab key, IME double-typing, and Shift+punctuation via libxkbcommon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three intertwined keyboard bugs: - Tab key: Qt's QWidget::event consumed Tab/Backtab for focus traversal before they reached keyPressEvent. Override focusNextPrevChild to return false so they fall through. - nvim double-: and other punctuation: the Qt apprt didn't populate unshifted_codepoint (so libghostty fell into a now-fixed encoder fallback) or consumed_mods (so Shift+punctuation got emitted as a kitty CSI sequence neither the shell nor nvim could render). Wire up libxkbcommon: a process-singleton XkbState builds a keymap from the system XKB defaults and exposes both queries — unshiftedCodepoint via xkb_state_key_get_one_sym on a no-mods state, and consumedMods via xkb_state_key_get_consumed_mods2 (mirrors GTK's gdk_key_event_get_consumed_modifiers; also makes AltGr work). The A-Z/0-9 special case is gone — XKB returns the same values layout-correctly. - Wayland text-input-v3 double-typing on KDE Plasma 6 with no IME: the compositor sends a text-input commit for every ASCII char it also delivers as a key event. Only forward IME commits that follow real preedit composition; let keyPressEvent handle plain ASCII alone. --- qt/CMakeLists.txt | 6 ++ qt/src/GhosttySurface.cpp | 133 ++++++++++++++++++++++++++++++++++---- qt/src/GhosttySurface.h | 10 +++ 3 files changed, 135 insertions(+), 14 deletions(-) diff --git a/qt/CMakeLists.txt b/qt/CMakeLists.txt index b069e500c..426c514ff 100644 --- a/qt/CMakeLists.txt +++ b/qt/CMakeLists.txt @@ -41,6 +41,11 @@ find_package(LayerShellQt REQUIRED) 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. @@ -106,6 +111,7 @@ target_link_libraries(ghastty PRIVATE Qt6::Svg PkgConfig::WAYLAND_CLIENT PkgConfig::XCB + PkgConfig::XKBCOMMON LayerShellQt::Interface "${GHOSTTY_LIB_DIR}/libghostty.so" ) diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index 75d3e11f9..3bcd2f587 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -42,6 +42,8 @@ #include #include +#include + GhosttySurface::GhosttySurface(ghostty_app_t app, MainWindow *owner, ghostty_surface_t parent_surface) : m_app(app), m_owner(owner), m_parentSurface(parent_surface) { @@ -553,6 +555,94 @@ void GhosttySurface::premultiplyFramebuffer() { // --- input ---------------------------------------------------------- +// Wraps a libxkbcommon keymap + state derived from the system's XKB +// defaults (XKB_DEFAULT_LAYOUT etc.). We need this for two things: +// +// 1. The unshifted codepoint a key would produce with no modifiers — +// libghostty's kitty encoder uses it to find a key entry for +// printable keys (without it, punctuation falls into a fallback +// that mis-encodes release events). +// +// 2. Which modifiers the layout "consumed" to produce the event's +// text — e.g. Shift+; → ":" consumes Shift. The encoder uses this +// to decide between plain text and a modifier-bearing CSI; without +// it Shift+punctuation gets emitted as a kitty CSI the shell can't +// decode (Shift+letter happens to work because A-Z survive that +// path). +class XkbState { +public: + static XkbState &instance() { + static XkbState self; + return self; + } + + // Level-0 (unshifted) Unicode codepoint for `keycode`, or 0 if the + // key has no associated UTF-32 (function keys, modifiers, etc.). + uint32_t unshiftedCodepoint(uint32_t keycode) const { + if (!m_unshifted) return 0; + const xkb_keysym_t sym = + xkb_state_key_get_one_sym(m_unshifted, keycode); + if (sym == XKB_KEY_NoSymbol) return 0; + return xkb_keysym_to_utf32(sym); + } + + // Modifiers consumed by the layout to produce `keycode`'s text given + // `mods` are depressed. Returns the consumed subset, expressed as + // ghostty mod bits. + ghostty_input_mods_e consumedMods(uint32_t keycode, + ghostty_input_mods_e mods) const { + if (!m_query) return GHOSTTY_MODS_NONE; + xkb_mod_mask_t depressed = 0; + if ((mods & GHOSTTY_MODS_SHIFT) && m_idxShift != XKB_MOD_INVALID) + depressed |= (1u << m_idxShift); + if ((mods & GHOSTTY_MODS_CTRL) && m_idxCtrl != XKB_MOD_INVALID) + depressed |= (1u << m_idxCtrl); + if ((mods & GHOSTTY_MODS_ALT) && m_idxAlt != XKB_MOD_INVALID) + depressed |= (1u << m_idxAlt); + if ((mods & GHOSTTY_MODS_SUPER) && m_idxSuper != XKB_MOD_INVALID) + depressed |= (1u << m_idxSuper); + xkb_state_update_mask(m_query, depressed, 0, 0, 0, 0, 0); + const xkb_mod_mask_t consumed = xkb_state_key_get_consumed_mods2( + m_query, keycode, XKB_CONSUMED_MODE_XKB); + // Reset so the next query starts from no-mods. + xkb_state_update_mask(m_query, 0, 0, 0, 0, 0, 0); + int r = GHOSTTY_MODS_NONE; + if (m_idxShift != XKB_MOD_INVALID && (consumed & (1u << m_idxShift))) + r |= GHOSTTY_MODS_SHIFT; + if (m_idxCtrl != XKB_MOD_INVALID && (consumed & (1u << m_idxCtrl))) + r |= GHOSTTY_MODS_CTRL; + if (m_idxAlt != XKB_MOD_INVALID && (consumed & (1u << m_idxAlt))) + r |= GHOSTTY_MODS_ALT; + if (m_idxSuper != XKB_MOD_INVALID && (consumed & (1u << m_idxSuper))) + r |= GHOSTTY_MODS_SUPER; + return static_cast(r); + } + +private: + XkbState() { + m_ctx = xkb_context_new(XKB_CONTEXT_NO_FLAGS); + if (!m_ctx) return; + m_keymap = xkb_keymap_new_from_names(m_ctx, nullptr, + XKB_KEYMAP_COMPILE_NO_FLAGS); + if (!m_keymap) return; + m_unshifted = xkb_state_new(m_keymap); + m_query = xkb_state_new(m_keymap); + m_idxShift = xkb_keymap_mod_get_index(m_keymap, XKB_MOD_NAME_SHIFT); + m_idxCtrl = xkb_keymap_mod_get_index(m_keymap, XKB_MOD_NAME_CTRL); + m_idxAlt = xkb_keymap_mod_get_index(m_keymap, XKB_MOD_NAME_ALT); + m_idxSuper = xkb_keymap_mod_get_index(m_keymap, XKB_MOD_NAME_LOGO); + } + + struct xkb_context *m_ctx = nullptr; + struct xkb_keymap *m_keymap = nullptr; + struct xkb_state *m_unshifted = nullptr; // permanent no-mods state + struct xkb_state *m_query = nullptr; // reused for consumed-mods queries + xkb_mod_index_t m_idxShift = XKB_MOD_INVALID; + xkb_mod_index_t m_idxCtrl = XKB_MOD_INVALID; + xkb_mod_index_t m_idxAlt = XKB_MOD_INVALID; + xkb_mod_index_t m_idxSuper = XKB_MOD_INVALID; +}; + // Translate Qt keyboard modifiers into libghostty's modifier bitfield. static ghostty_input_mods_e translateMods(Qt::KeyboardModifiers m) { int r = GHOSTTY_MODS_NONE; @@ -575,23 +665,25 @@ void GhosttySurface::sendKey(QKeyEvent *ev, ghostty_input_action_e action) { static_cast(text.front()) >= 0x20 && static_cast(text.front()) != 0x7f; - // Unshifted codepoint, used for keybind matching (letters and digits). - uint32_t unshifted = 0; - const int key = ev->key(); - if (key >= Qt::Key_A && key <= Qt::Key_Z) - unshifted = static_cast('a' + (key - Qt::Key_A)); - else if (key >= Qt::Key_0 && key <= Qt::Key_9) - unshifted = static_cast('0' + (key - Qt::Key_0)); + // On xcb nativeScanCode() is the X11/XKB keycode; the Wayland plugin + // likewise reports the XKB keycode, which is libghostty's Linux native. + const uint32_t keycode = ev->nativeScanCode(); ghostty_input_key_s k = {}; k.action = action; k.mods = translateMods(ev->modifiers()); - k.consumed_mods = GHOSTTY_MODS_NONE; - // On xcb nativeScanCode() is the X11/XKB keycode; the Wayland plugin - // likewise reports the XKB keycode, which is libghostty's Linux native. - k.keycode = ev->nativeScanCode(); + k.keycode = keycode; k.text = printable ? text.constData() : nullptr; - k.unshifted_codepoint = unshifted; + // XKB lookups: unshifted codepoint (what this physical key would + // produce with no mods, e.g. ';' for the Shift+; → ':' event) and the + // modifiers the layout consumed to produce the event's text. Without + // consumed_mods, Shift+punctuation is emitted as a kitty CSI sequence + // the shell can't decode; with it set, libghostty's encoder falls + // back to plain text correctly. + k.unshifted_codepoint = XkbState::instance().unshiftedCodepoint(keycode); + k.consumed_mods = printable + ? XkbState::instance().consumedMods(keycode, k.mods) + : GHOSTTY_MODS_NONE; k.composing = false; ghostty_surface_key(m_surface, k); @@ -868,14 +960,27 @@ void GhosttySurface::commitText(const QString &text) { void GhosttySurface::inputMethodEvent(QInputMethodEvent *ev) { if (m_surface) { + const QString preeditStr = ev->preeditString(); + const QString commitStr = ev->commitString(); + // Forward the in-progress composition for inline display, then any // finalized text. A well-behaved IME sends an empty preedit string // alongside the commit, so this order matches GTK: clear, then commit. - const QByteArray preedit = ev->preeditString().toUtf8(); + const QByteArray preedit = preeditStr.toUtf8(); ghostty_surface_preedit( m_surface, preedit.isEmpty() ? nullptr : preedit.constData(), static_cast(preedit.size())); - if (!ev->commitString().isEmpty()) commitText(ev->commitString()); + + // Only commit when the text is the result of real IME composition — + // either the preceding event left us in preedit, or this event has + // active preedit alongside the commit. On Wayland's text-input-v3 + // (KDE Plasma 6 with no IME), the compositor sends a commit for + // every plain ASCII character it also delivers as a key event; + // forwarding both here would double every keystroke (the visible + // symptom: ":" in nvim arriving as "::"). + if (!commitStr.isEmpty() && (m_hadPreedit || !preeditStr.isEmpty())) + commitText(commitStr); + m_hadPreedit = !preeditStr.isEmpty(); } ev->accept(); } diff --git a/qt/src/GhosttySurface.h b/qt/src/GhosttySurface.h index d5537683a..9b3d0c879 100644 --- a/qt/src/GhosttySurface.h +++ b/qt/src/GhosttySurface.h @@ -107,6 +107,10 @@ protected: 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; @@ -189,5 +193,11 @@ private: 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 m_dirty{false}; // a frame render is pending }; From e50e87633426d30e4a749228f904dbf2b09200ec Mon Sep 17 00:00:00 2001 From: ntomsic Date: Wed, 20 May 2026 14:22:25 -0500 Subject: [PATCH 59/75] core+renderer: align loader cast, refresh docs, log render-pass errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A grab-bag of small libghostty fixes flushed out by review and a Linux/aarch64 build: - renderer/OpenGL: gladHostLoader was @ptrCast'ing the host's ?*anyopaque (alignment 1) directly to ?*const fn () callconv(.c) void (alignment 4). Zig flags this as "increases pointer alignment" on aarch64. eglGetProcAddress / glXGetProcAddress promise the returned pointer is suitable for the OpenGL ABI, so @alignCast is the assertion we want. - renderer/OpenGL: refresh the gl_host docstring. With must_draw_from_app_thread the callbacks now run on the app thread, not the renderer thread; the prior wording was actively misleading. - include/ghostty.h: same fix on the public ghostty_platform_opengl_s doc — embedders relying on it would over-engineer thread safety. - renderer/opengl/RenderPass: replace silent `catch return`s in step() with logged warnings. Pre-existing TODO; the embedded path will hit these much more than GTK ever did, so silent failure is not okay. - input/key_encode: regression tests for the kitty no-entry release fallback. A release event with non-empty utf8 and no kitty entry used to re-emit the character on key-up, doubling every keystroke with report_events on. Press still emits text — the fix is scoped to releases. Co-Authored-By: claude-flow --- include/ghostty.h | 10 +++++--- src/input/key_encode.zig | 41 ++++++++++++++++++++++++++++++ src/renderer/OpenGL.zig | 17 +++++++++---- src/renderer/opengl/RenderPass.zig | 28 +++++++++++++++----- 4 files changed, 81 insertions(+), 15 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index cb3aa5907..b562134aa 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -457,10 +457,12 @@ typedef struct { // 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. Ghostty's renderer -// runs on a dedicated thread and invokes these callbacks from that -// thread, so the context must be usable from a thread other than the -// one that created it. +// 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; diff --git a/src/input/key_encode.zig b/src/input/key_encode.zig index 474fb37d8..99e85f3b0 100644 --- a/src/input/key_encode.zig +++ b/src/input/key_encode.zig @@ -1867,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); diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 041baa64b..4be5b45d5 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -166,10 +166,12 @@ fn prepareContext(getProcAddress: anytype) !void { /// Host-provided OpenGL callbacks for the embedded apprt. /// -/// The renderer thread owns the host's GL context exclusively: it is made -/// current in `threadEnter`, used by `present`, and released in -/// `threadExit` — all on the same thread. We therefore stash the callbacks -/// thread-locally rather than threading them through `*const OpenGL`. +/// 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; @@ -181,7 +183,12 @@ fn gladHostLoader( name: [*:0]const u8, ) callconv(.c) ?*const fn () callconv(.c) void { const host = gl_host orelse return null; - return @ptrCast(host.get_proc_address(host.userdata, name)); + // 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. diff --git a/src/renderer/opengl/RenderPass.zig b/src/renderer/opengl/RenderPass.zig index c465638ef..4876de9ae 100644 --- a/src/renderer/opengl/RenderPass.zig +++ b/src/renderer/opengl/RenderPass.zig @@ -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; }; From 14fddb5fffa53f2baaca38e12b9a591d62a03db9 Mon Sep 17 00:00:00 2001 From: ntomsic Date: Wed, 20 May 2026 14:23:03 -0500 Subject: [PATCH 60/75] qt: extract shared helpers into Util.{h,cpp} and a typed TabData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three near-identical translateMods() copies and two key-name/trigger formatters lived across MainWindow, GhosttySurface and InspectorWindow — pulled into a single Util.{h,cpp} so each callsite uses one canonical implementation: - translateMods (Qt::KeyboardModifiers -> ghostty_input_mods_e) - triggerKeyName / formatTrigger (chord rendering) - BellFeature enum replaces hand-rolled (1u << 0..4) bit positions in MainWindow::ringBell. The bitfield layout is fixed by the C ABI and should not be open-coded in two places. - configGet(cfg, &out, "literal") template wrapper so callsites stop repeating qstrlen("literal"). Replace the QStringList-of-length-2 stored in QTabBar::tabData with a typed TabData { base, override_ } struct (Q_DECLARE_METATYPE'd so QVariant carries it across windows during a tear-off). The previous schema was comment-only — one off-by-one would have silently corrupted titles. CommandPalette and the rest are migrated to the new helpers. Net effect: one source of truth, no behavior change. Co-Authored-By: claude-flow --- qt/src/TabWidget.h | 19 ++++++++++++++++--- qt/src/Util.cpp | 43 ++++++++++++++++++++++++++++++++++++++++++ qt/src/Util.h | 47 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 qt/src/Util.cpp create mode 100644 qt/src/Util.h diff --git a/qt/src/TabWidget.h b/qt/src/TabWidget.h index fe449e407..a450e987c 100644 --- a/qt/src/TabWidget.h +++ b/qt/src/TabWidget.h @@ -1,6 +1,8 @@ #pragma once +#include #include +#include #include #include @@ -8,10 +10,20 @@ class QDragEnterEvent; class QDropEvent; class QMouseEvent; -// MIME type marking a Ghostty tab tear-off drag. Recognised by tab bars +// 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 Ghostty window). -inline constexpr char kGhosttyTabMime[] = "application/x-ghostty-tab"; +// 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 @@ -41,6 +53,7 @@ private: 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. diff --git a/qt/src/Util.cpp b/qt/src/Util.cpp new file mode 100644 index 000000000..01876d817 --- /dev/null +++ b/qt/src/Util.cpp @@ -0,0 +1,43 @@ +#include "Util.h" + +#include +#include + +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; +} diff --git a/qt/src/Util.h b/qt/src/Util.h new file mode 100644 index 000000000..08caee115 --- /dev/null +++ b/qt/src/Util.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include + +#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(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(). +template +inline bool configGet(ghostty_config_t cfg, T *out, const char (&key)[N]) { + return cfg && ghostty_config_get(cfg, out, key, N - 1); +} From e498ced9a3e055eb44964171a2a0b620bb02e6d2 Mon Sep 17 00:00:00 2001 From: ntomsic Date: Wed, 20 May 2026 14:23:38 -0500 Subject: [PATCH 61/75] qt: lifetime safety + correctness fixes from PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pass over the Qt frontend tightening object lifetime, dropping a few bugs, and consolidating the pieces a senior-engineer review surfaced. UAF risks in the action dispatcher: - MainWindow::onAction queued lambdas captured raw MainWindow* / GhosttySurface* by value. Qt's QueuedConnection cancels a slot if its receiver is gone, but cross-captured pointers (e.g. `src` when posting to `win`, or `win` when posting to `src`) are not protected — a multi-window + tear-off + close race could fire a slot whose body dereferences a deleted object. Every cross-pointer is now wrapped in QPointer; a small `post()` helper queues onto the receiver. Each lambda body re-checks both sides at execution time. - onCloseSurface / onConfirmReadClipboard / onWriteClipboard / onReadClipboard turn a libghostty userdata pointer into a GhosttySurface*. A worker-thread callback can race the surface's destructor; surfaceAlive() now validates against the s_windows registry before any deref. - onWakeup's queued tick still touches s_app inside the lambda; documented why that's the right guard against a last-window-closed race. Object lifetime cleanups: - WindowBlur leaked the org_kde_kwin_blur and dangled its QWindow* hash key when a window was destroyed. Connect to QWindow::destroyed to release the proxy and clear the entry. - InspectorWindow::closeEvent now hides instead of deleting. The owning GhosttySurface holds it as a QPointer and toggles visibility; deleting on WM-close would dangle that pointer and skip libghostty inspector teardown. - GhosttySurface::m_inspectorWindow is now a QPointer; ~Ghostty calls delete on the .data() so an already-destroyed inspector is a no-op. - XkbState (process singleton) gained a destructor and m_query was marked mutable. Documented the single-thread-only constraint — consumedMods mutates m_query. Tab tear-off race: - A namespace-scope `g_tabDropHandled` bool would race two tear-offs in flight in different windows. Replaced with a per-bar m_dropHandled flag, set via a pointer-to-originator carried in the drag's MIME payload. Other correctness: - LayerShellQt::Window::setDesiredSize doesn't exist on the Qt 6 / trixie branch — the layer-shell size comes from QWindow::resize(), which the next line already does. Drop the dead call. - MainWindow had a per-window QTimer firing ghostty_app_tick every 16ms. With N windows that's N redundant ticks for the *same* shared app per frame. One process-wide static timer parented to qApp; frame() is now static and walks every window's surfaces. - onConfirmReadClipboard truncated the warning preview by raw QString::left(200), risking slicing a surrogate pair half. Back off to a non-surrogate boundary first. - onWriteClipboard / OPEN_CONFIG: route via qApp instead of the source surface's owner — the clipboard is process-global; routing via a window that may already be closing was strictly worse. - OverlayScrollbar::setMetrics now repaints while m_opacity > 0 too, so a fading scrollbar tracks live scrollback updates instead of freezing on the last frame. - Unify log prefix to [ghastty] across qt/src/. Was [ghostty] in about half the fprintf calls; the rebrand left the rest stale. - Migrate every callsite to the shared Util.h helpers (translateMods, formatTrigger/triggerKeyName, BellFeature enum, configGet<>). Remove the per-file duplicates. Co-Authored-By: claude-flow --- qt/src/CommandPalette.cpp | 5 +- qt/src/GhosttySurface.cpp | 93 +++---- qt/src/GhosttySurface.h | 6 +- qt/src/GlobalShortcuts.cpp | 4 +- qt/src/InspectorWindow.cpp | 19 +- qt/src/InspectorWindow.h | 9 + qt/src/MainWindow.cpp | 519 ++++++++++++++++++------------------ qt/src/MainWindow.h | 17 +- qt/src/OverlayScrollbar.cpp | 5 +- qt/src/TabWidget.cpp | 54 +++- qt/src/WindowBlur.cpp | 21 +- qt/src/main.cpp | 4 +- 12 files changed, 404 insertions(+), 352 deletions(-) diff --git a/qt/src/CommandPalette.cpp b/qt/src/CommandPalette.cpp index cc8eda612..c8d014df8 100644 --- a/qt/src/CommandPalette.cpp +++ b/qt/src/CommandPalette.cpp @@ -12,6 +12,7 @@ #include "GhosttySurface.h" #include "MainWindow.h" +#include "Util.h" namespace { // Item data roles: the keybind action to run, and the text the filter @@ -86,9 +87,7 @@ void CommandPalette::populate() { // command-palette-entry defaults to a large built-in command set. ghostty_config_command_list_s list = {}; - if (!ghostty_config_get(cfg, &list, "command-palette-entry", - qstrlen("command-palette-entry"))) - return; + 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 : ""); diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index 3bcd2f587..6648ac136 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -5,6 +5,7 @@ #include "OverlayScrollbar.h" #include "SearchBar.h" #include "TabWidget.h" +#include "Util.h" #include #include @@ -73,7 +74,7 @@ GhosttySurface::GhosttySurface(ghostty_app_t app, MainWindow *owner, m_context = new QOpenGLContext(this); m_context->setFormat(QSurfaceFormat::defaultFormat()); if (!m_context->create()) { - std::fprintf(stderr, "[ghostty] GL context creation failed\n"); + std::fprintf(stderr, "[ghastty] GL context creation failed\n"); return; } m_offscreen = new QOffscreenSurface(nullptr, this); @@ -81,7 +82,7 @@ GhosttySurface::GhosttySurface(ghostty_app_t app, MainWindow *owner, m_offscreen->create(); if (!makeCurrent()) { - std::fprintf(stderr, "[ghostty] makeCurrent failed\n"); + std::fprintf(stderr, "[ghastty] makeCurrent failed\n"); return; } @@ -107,7 +108,7 @@ GhosttySurface::GhosttySurface(ghostty_app_t app, MainWindow *owner, m_surface = ghostty_surface_new(m_app, &sc); if (!m_surface) { - std::fprintf(stderr, "[ghostty] ghostty_surface_new failed\n"); + std::fprintf(stderr, "[ghastty] ghostty_surface_new failed\n"); return; } @@ -116,7 +117,8 @@ GhosttySurface::GhosttySurface(ghostty_app_t app, MainWindow *owner, GhosttySurface::~GhosttySurface() { // The inspector window holds m_surface; destroy it before m_surface. - delete m_inspectorWindow; + // QPointer auto-nulls on a destroyed QObject, so .data() is safe. + delete m_inspectorWindow.data(); // Release GL-owning objects with the context current. if (makeCurrent()) { @@ -204,9 +206,7 @@ void GhosttySurface::layoutScrollbar() { bool GhosttySurface::scrollbarAllowed() const { if (!m_owner || !m_owner->config()) return true; const char *value = nullptr; - if (ghostty_config_get(m_owner->config(), &value, "scrollbar", - qstrlen("scrollbar")) && - value) + if (configGet(m_owner->config(), &value, "scrollbar") && value) return qstrcmp(value, "never") != 0; return true; // unknown — default to showing } @@ -233,9 +233,7 @@ void GhosttySurface::flashScrollbar() { if (!m_scrollbar || !scrollbarAllowed()) return; // Handle colour: light on a dark terminal, dark on a light one. ghostty_config_color_s bg{}; - if (m_owner && m_owner->config() && - ghostty_config_get(m_owner->config(), &bg, "background", - qstrlen("background"))) { + if (m_owner && configGet(m_owner->config(), &bg, "background")) { const double luma = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b; m_scrollbar->setHandleColor(luma < 128.0 ? QColor(235, 235, 235) : QColor(45, 45, 45)); @@ -284,14 +282,11 @@ void GhosttySurface::paintEvent(QPaintEvent *) { if (!hasFocus() && qobject_cast(parentWidget())) { ghostty_config_t cfg = m_owner ? m_owner->config() : nullptr; double opacity = 0.7; - if (cfg) - ghostty_config_get(cfg, &opacity, "unfocused-split-opacity", - qstrlen("unfocused-split-opacity")); + configGet(cfg, &opacity, "unfocused-split-opacity"); if (opacity < 1.0) { QColor fill(0, 0, 0); // default: dim toward black ghostty_config_color_s c{}; - if (cfg && ghostty_config_get(cfg, &c, "unfocused-split-fill", - qstrlen("unfocused-split-fill"))) + if (configGet(cfg, &c, "unfocused-split-fill")) fill = QColor(c.r, c.g, c.b); fill.setAlphaF(1.0 - opacity); painter.setCompositionMode(QPainter::CompositionMode_SourceOver); @@ -449,8 +444,7 @@ void GhosttySurface::showResizeOverlay() { m_resizeOverlay->raise(); unsigned long long durNs = 0; - ghostty_config_get(cfg, &durNs, "resize-overlay-duration", - qstrlen("resize-overlay-duration")); + configGet(cfg, &durNs, "resize-overlay-duration"); const int durMs = durNs ? static_cast(durNs / 1000000ULL) : 750; if (!m_resizeHideTimer) { m_resizeHideTimer = new QTimer(this); @@ -569,6 +563,11 @@ void GhosttySurface::premultiplyFramebuffer() { // it Shift+punctuation gets emitted as a kitty CSI the shell can't // decode (Shift+letter happens to work because A-Z survive that // path). +// +// THREAD SAFETY: this is a process singleton accessed only from the Qt +// GUI thread (Qt key events are dispatched there, and so is libghostty's +// inputMethodEvent forwarding). consumedMods mutates m_query, so a +// second thread would race; do not call from worker threads. class XkbState { public: static XkbState &instance() { @@ -588,7 +587,8 @@ public: // Modifiers consumed by the layout to produce `keycode`'s text given // `mods` are depressed. Returns the consumed subset, expressed as - // ghostty mod bits. + // ghostty mod bits. Mutates m_query (mutable) — see thread-safety + // note on the class. ghostty_input_mods_e consumedMods(uint32_t keycode, ghostty_input_mods_e mods) const { if (!m_query) return GHOSTTY_MODS_NONE; @@ -633,32 +633,40 @@ private: m_idxSuper = xkb_keymap_mod_get_index(m_keymap, XKB_MOD_NAME_LOGO); } + ~XkbState() { + // Run on process exit when the static is destroyed. The OS would + // reclaim regardless, but explicit teardown silences leak checkers + // and documents the ownership chain. + if (m_query) xkb_state_unref(m_query); + if (m_unshifted) xkb_state_unref(m_unshifted); + if (m_keymap) xkb_keymap_unref(m_keymap); + if (m_ctx) xkb_context_unref(m_ctx); + } + + XkbState(const XkbState &) = delete; + XkbState &operator=(const XkbState &) = delete; + struct xkb_context *m_ctx = nullptr; struct xkb_keymap *m_keymap = nullptr; struct xkb_state *m_unshifted = nullptr; // permanent no-mods state - struct xkb_state *m_query = nullptr; // reused for consumed-mods queries + // Reused across consumedMods calls (mutated then reset). Mutable so + // consumedMods can stay logically const. + mutable struct xkb_state *m_query = nullptr; xkb_mod_index_t m_idxShift = XKB_MOD_INVALID; xkb_mod_index_t m_idxCtrl = XKB_MOD_INVALID; xkb_mod_index_t m_idxAlt = XKB_MOD_INVALID; xkb_mod_index_t m_idxSuper = XKB_MOD_INVALID; }; -// Translate Qt keyboard modifiers into libghostty's modifier bitfield. -static 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(r); -} - void GhosttySurface::sendKey(QKeyEvent *ev, ghostty_input_action_e action) { if (!m_surface) return; // Forward committed text only for printable input; control characters // and special keys (Enter, Tab, arrows, Ctrl+letter, ...) are encoded // by libghostty from the physical keycode + modifiers. + // The QByteArray below is stack-local; ghostty_surface_key is + // synchronous and copies any text it needs internally, so the buffer + // only has to live across this call. const QByteArray text = ev->text().toUtf8(); const bool printable = !text.isEmpty() && @@ -745,40 +753,21 @@ void GhosttySurface::mouseReleaseEvent(QMouseEvent *ev) { } // The keybind bound to `action` in the live config, as a QKeySequence -// for a context-menu hint. Empty if unbound or not displayable. +// for a context-menu hint. Empty if unbound or not displayable +// (CATCH_ALL, an unmapped physical key, etc.). QKeySequence GhosttySurface::shortcutFor(const char *action) const { if (!m_owner || !m_owner->config()) return {}; const ghostty_input_trigger_s t = ghostty_config_trigger(m_owner->config(), action, qstrlen(action)); - QString key; - switch (t.tag) { - case GHOSTTY_TRIGGER_UNICODE: - if (t.key.unicode) key = QString(QChar(t.key.unicode)).toUpper(); - break; - case GHOSTTY_TRIGGER_PHYSICAL: { - const ghostty_input_key_e k = t.key.physical; - if (k >= GHOSTTY_KEY_A && k <= GHOSTTY_KEY_Z) - key = QChar('A' + (k - GHOSTTY_KEY_A)); - else if (k >= GHOSTTY_KEY_DIGIT_0 && k <= GHOSTTY_KEY_DIGIT_9) - key = QChar('0' + (k - GHOSTTY_KEY_DIGIT_0)); - else if (k == GHOSTTY_KEY_ENTER) - key = QStringLiteral("Return"); - else if (k == GHOSTTY_KEY_SPACE) - key = QStringLiteral("Space"); - else if (k == GHOSTTY_KEY_TAB) - key = QStringLiteral("Tab"); - break; - } - default: - break; // CATCH_ALL etc. — nothing displayable - } + const QString key = triggerKeyName(t); if (key.isEmpty()) return {}; QString seq; if (t.mods & GHOSTTY_MODS_CTRL) seq += QStringLiteral("Ctrl+"); if (t.mods & GHOSTTY_MODS_ALT) seq += QStringLiteral("Alt+"); if (t.mods & GHOSTTY_MODS_SHIFT) seq += QStringLiteral("Shift+"); + // QKeySequence parses Meta+ as the Super/Logo key on Linux. if (t.mods & GHOSTTY_MODS_SUPER) seq += QStringLiteral("Meta+"); return QKeySequence(seq + key); } diff --git a/qt/src/GhosttySurface.h b/qt/src/GhosttySurface.h index 9b3d0c879..6e1cf9506 100644 --- a/qt/src/GhosttySurface.h +++ b/qt/src/GhosttySurface.h @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -187,7 +188,10 @@ private: int m_lastCols = 0; // last grid size, to detect changes int m_lastRows = 0; SearchBar *m_searchBar = nullptr; // in-terminal search; lazily made - InspectorWindow *m_inspectorWindow = nullptr; // terminal inspector; 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 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 diff --git a/qt/src/GlobalShortcuts.cpp b/qt/src/GlobalShortcuts.cpp index f9b575f65..9c7a00eb3 100644 --- a/qt/src/GlobalShortcuts.cpp +++ b/qt/src/GlobalShortcuts.cpp @@ -95,7 +95,7 @@ void GlobalShortcuts::portalCall(const QString &method, QVariantList args, [method](QDBusPendingCallWatcher *w) { QDBusPendingReply reply = *w; if (reply.isError()) - std::fprintf(stderr, "[ghostty] portal %s failed: %s\n", + std::fprintf(stderr, "[ghastty] portal %s failed: %s\n", method.toUtf8().constData(), reply.error().message().toUtf8().constData()); w->deleteLater(); @@ -111,7 +111,7 @@ void GlobalShortcuts::onResponse(const QDBusMessage &message) { const QVariantMap results = args.size() > 1 ? qdbus_cast(args.at(1)) : QVariantMap(); if (code != 0) - std::fprintf(stderr, "[ghostty] portal %s response code=%u\n", + std::fprintf(stderr, "[ghastty] portal %s response code=%u\n", method.toUtf8().constData(), code); if (method == QLatin1String("CreateSession")) handleCreateSession(code, results); diff --git a/qt/src/InspectorWindow.cpp b/qt/src/InspectorWindow.cpp index 54c16cda8..d1f33f7a1 100644 --- a/qt/src/InspectorWindow.cpp +++ b/qt/src/InspectorWindow.cpp @@ -12,16 +12,9 @@ #include #include -namespace { +#include "Util.h" -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(r); -} +namespace { // The editing/navigation keys an ImGui text field needs; other keys // arrive as text via ghostty_inspector_text. @@ -133,6 +126,14 @@ void InspectorWindow::paintEvent(QPaintEvent *) { 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. + hide(); + e->ignore(); +} + void InspectorWindow::sendMouseButton(QMouseEvent *ev, ghostty_input_mouse_state_e state) { if (!m_inspector) return; diff --git a/qt/src/InspectorWindow.h b/qt/src/InspectorWindow.h index dfd90e2ff..175357cc2 100644 --- a/qt/src/InspectorWindow.h +++ b/qt/src/InspectorWindow.h @@ -15,6 +15,11 @@ class QTimer; // 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 @@ -24,6 +29,10 @@ public: ~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; void paintEvent(QPaintEvent *) override; void resizeEvent(QResizeEvent *) override; void mouseMoveEvent(QMouseEvent *) override; diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index f61a88180..6f20aa358 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -41,6 +42,7 @@ #include "CommandPalette.h" #include "GhosttySurface.h" #include "TabWidget.h" +#include "Util.h" #include "WindowBlur.h" // Prefix marking a tab with an unacknowledged bell (bell-features title). @@ -54,6 +56,7 @@ QList MainWindow::s_windows; QTimer *MainWindow::s_quitTimer = nullptr; int MainWindow::s_quitDelayMs = 0; MainWindow *MainWindow::s_quickTerminal = nullptr; +QTimer *MainWindow::s_frameTimer = nullptr; std::atomic MainWindow::s_tickPending{false}; MainWindow::MainWindow() { @@ -101,6 +104,14 @@ MainWindow::~MainWindow() { // The shared app and config outlive every window but the last. if (s_windows.isEmpty()) { + if (s_frameTimer) { + // The timer is parented to qApp; stop it so a final tick can't + // fire after s_app is freed below. delete leaves qApp's child + // list to clean up at process exit. + s_frameTimer->stop(); + delete s_frameTimer; + s_frameTimer = nullptr; + } if (s_quitTimer) { delete s_quitTimer; s_quitTimer = nullptr; @@ -220,7 +231,7 @@ bool MainWindow::initialize() { s_app = ghostty_app_new(&rt, s_config); if (!s_app) { - std::fprintf(stderr, "[ghostty] ghostty_app_new failed\n"); + std::fprintf(stderr, "[ghastty] ghostty_app_new failed\n"); return false; } @@ -229,9 +240,7 @@ bool MainWindow::initialize() { // through the libghostty quit_timer action (see handleQuitTimer). const bool quitAfter = configBool("quit-after-last-window-closed", true); unsigned long long delayNs = 0; - ghostty_config_get(s_config, &delayNs, - "quit-after-last-window-closed-delay", - qstrlen("quit-after-last-window-closed-delay")); + configGet(s_config, &delayNs, "quit-after-last-window-closed-delay"); s_quitDelayMs = quitAfter ? static_cast(delayNs / 1000000ULL) : 0; QApplication::setQuitOnLastWindowClosed(quitAfter && s_quitDelayMs == 0); } @@ -255,11 +264,17 @@ bool MainWindow::initialize() { // Tab-bar policy and colour scheme. applyWindowConfig(); - // 60fps frame timer: a backstop tick plus rendering. onWakeup drives - // extra ticks between frames for input responsiveness. - auto *timer = new QTimer(this); - connect(timer, &QTimer::timeout, this, &MainWindow::frame); - timer->start(16); + // Process-wide 60fps frame timer (created on the first window): a + // backstop tick plus rendering. onWakeup drives extra ticks between + // frames for input responsiveness. One timer covers every window — + // N windows would otherwise produce N ticks per 16ms for the same + // shared ghostty_app_t. + if (!s_frameTimer) { + s_frameTimer = new QTimer(qApp); + QObject::connect(s_frameTimer, &QTimer::timeout, qApp, + &MainWindow::frame); + s_frameTimer->start(16); + } // The first tab is created in showEvent, not here: see below. return true; @@ -442,6 +457,7 @@ void MainWindow::adoptTab(MainWindow *src, QWidget *page) { } const QString text = src->m_tabs->tabText(srcIndex); + // QVariant carrying the typed TabData; copies cleanly across windows. const QVariant data = src->m_tabs->tabBar()->tabData(srcIndex); src->m_tabs->removeTab(srcIndex); // page is now parentless const int index = m_tabs->addTab(page, text); // reparents page here @@ -477,10 +493,9 @@ void MainWindow::setSurfaceTitle(GhosttySurface *surface, if (index < 0) return; // Store the terminal title as the tab's base; updateTabText decides // whether it or a manual override is shown. - QStringList data = m_tabs->tabBar()->tabData(index).toStringList(); - while (data.size() < 2) data.append(QString()); - data[0] = title; - m_tabs->tabBar()->setTabData(index, data); + TabData data = m_tabs->tabBar()->tabData(index).value(); + data.base = title; + m_tabs->tabBar()->setTabData(index, QVariant::fromValue(data)); updateTabText(index); } @@ -488,19 +503,18 @@ void MainWindow::setTabTitleOverride(GhosttySurface *surface, const QString &title) { const int index = tabIndexForSurface(surface); if (index < 0) return; - QStringList data = m_tabs->tabBar()->tabData(index).toStringList(); - while (data.size() < 2) data.append(QString()); - data[1] = title; // empty clears the override - m_tabs->tabBar()->setTabData(index, data); + TabData data = m_tabs->tabBar()->tabData(index).value(); + data.override_ = title; // empty clears the override + m_tabs->tabBar()->setTabData(index, QVariant::fromValue(data)); updateTabText(index); } void MainWindow::copyTitleToClipboard() { const int tab = m_tabs->currentIndex(); if (tab < 0) return; - const QStringList data = m_tabs->tabBar()->tabData(tab).toStringList(); + const TabData data = m_tabs->tabBar()->tabData(tab).value(); const QString title = - !data.value(1).isEmpty() ? data.value(1) : data.value(0); + !data.override_.isEmpty() ? data.override_ : data.base; if (!title.isEmpty()) QGuiApplication::clipboard()->setText(title); } @@ -509,7 +523,10 @@ void MainWindow::frame() { ghostty_app_tick(s_app); // Rendering happens only here, so a flood of RENDER actions cannot // saturate the GUI thread — each surface renders at most once a frame. - for (GhosttySurface *s : m_surfaces) s->renderIfDirty(); + // One pass across every window: the shared ghostty_app_t was already + // ticked once above. + for (MainWindow *w : s_windows) + for (GhosttySurface *s : w->m_surfaces) s->renderIfDirty(); } void MainWindow::onTabCloseRequested(int index) { @@ -627,8 +644,7 @@ void MainWindow::setupLayerShell() { // quick-terminal-size: primary is the edge-perpendicular extent. ghostty_config_quick_terminal_size_s qsz = {}; - ghostty_config_get(s_config, &qsz, "quick-terminal-size", - qstrlen("quick-terminal-size")); + configGet(s_config, &qsz, "quick-terminal-size"); const auto toPx = [](const ghostty_quick_terminal_size_s &s, int dim, int fallback) -> int { switch (s.tag) { @@ -666,7 +682,9 @@ void MainWindow::setupLayerShell() { size = {scr.width(), toPx(qsz.primary, scr.height(), 400)}; } ls->setAnchors(anchors); - ls->setDesiredSize(size); + // The layer-shell protocol takes the size from the underlying + // wl_surface (i.e. the QWindow's size); LayerShellQt has no + // setDesiredSize on this Qt branch. resize(size); } @@ -845,18 +863,17 @@ void MainWindow::moveTab(int amount) { } void MainWindow::ringBell(GhosttySurface *surface) { - // bell-features is a packed struct, returned by ghostty_config_get as - // a bitfield: bit 0 system, 1 audio, 2 attention, 3 title, 4 border. - unsigned int features = 1u << 2; // fall back to `attention` - ghostty_config_get(s_config, &features, "bell-features", - qstrlen("bell-features")); - if (features & (1u << 2)) QApplication::alert(this); // attention - if (features & (1u << 0)) QApplication::beep(); // system - if (features & (1u << 1)) playBellAudio(); // audio + // bell-features is a packed struct returned by ghostty_config_get as + // a bitfield (see BellFeature in Util.h). + unsigned int features = BellAttention; // fallback if config-get fails + configGet(s_config, &features, "bell-features"); + if (features & BellAttention) QApplication::alert(this); + if (features & BellSystem) QApplication::beep(); + if (features & BellAudio) playBellAudio(); if (!surface) return; - if (features & (1u << 4)) surface->flashBorder(); // border - if (features & (1u << 3)) { // title + if (features & BellBorder) surface->flashBorder(); + if (features & BellTitle) { const int tab = tabIndexForSurface(surface); // Marking the current tab is pointless — you are looking at it. if (tab >= 0 && tab != m_tabs->currentIndex()) { @@ -874,12 +891,10 @@ bool MainWindow::tabBellMarked(int tab) const { void MainWindow::updateTabText(int tab) { if (tab < 0 || tab >= m_tabs->count()) return; - const QStringList data = m_tabs->tabBar()->tabData(tab).toStringList(); - const QString base = data.value(0); - const QString override = data.value(1); - QString text = !override.isEmpty() ? override - : !base.isEmpty() ? base - : QStringLiteral("Ghastty"); + const TabData data = m_tabs->tabBar()->tabData(tab).value(); + QString text = !data.override_.isEmpty() ? data.override_ + : !data.base.isEmpty() ? data.base + : QStringLiteral("Ghastty"); m_tabs->setTabText(tab, tabBellMarked(tab) ? kBellMark + text : text); if (tab == m_tabs->currentIndex()) setWindowTitle(text + QStringLiteral(" — Ghastty")); @@ -984,7 +999,7 @@ void MainWindow::applyWindowConfig() { scheme = Qt::ColorScheme::Light; } else if (theme == QLatin1String("ghostty")) { ghostty_config_color_s bg{}; - if (ghostty_config_get(s_config, &bg, "background", qstrlen("background"))) { + if (configGet(s_config, &bg, "background")) { const double luma = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b; scheme = luma < 128.0 ? Qt::ColorScheme::Dark : Qt::ColorScheme::Light; } @@ -1003,9 +1018,7 @@ void MainWindow::applyBlur() { // macOS-only negatives) means off, a positive radius means on. KWin // uses its own configured radius, so only on/off matters here. short blur = 0; - if (s_config) - ghostty_config_get(s_config, &blur, "background-blur", - qstrlen("background-blur")); + configGet(s_config, &blur, "background-blur"); applyWindowBlur(this, blur > 0); } @@ -1057,7 +1070,9 @@ void MainWindow::onWakeup(void *) { // Coalesce: queue a shared-app tick only when one is not already // pending, so a chatty surface cannot flood the event loop. May be // called off-thread, so it marshals onto qApp (always alive) rather - // than any particular window. + // than any particular window. The s_app check inside the lambda + // guards against the last window being destroyed (which frees s_app) + // between this wakeup and the queued tick draining. if (s_tickPending.exchange(true)) return; QMetaObject::invokeMethod( qApp, @@ -1068,6 +1083,13 @@ void MainWindow::onWakeup(void *) { Qt::QueuedConnection); } +bool MainWindow::surfaceAlive(GhosttySurface *s) { + if (!s) return false; + for (MainWindow *w : s_windows) + if (w->m_surfaces.contains(s)) return true; + return false; +} + // Map a libghostty mouse shape to the nearest Qt cursor. static Qt::CursorShape mouseShapeToCursor(ghostty_action_mouse_shape_e s) { switch (s) { @@ -1105,32 +1127,14 @@ static Qt::CursorShape mouseShapeToCursor(ghostty_action_mouse_shape_e s) { } } -// Format a keybind trigger as a human-readable chord, e.g. "Ctrl+B". -static 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+"); - switch (t.tag) { - case GHOSTTY_TRIGGER_UNICODE: - s += QString(QChar(t.key.unicode)).toUpper(); - break; - case GHOSTTY_TRIGGER_PHYSICAL: { - const ghostty_input_key_e k = t.key.physical; - if (k >= GHOSTTY_KEY_DIGIT_0 && k <= GHOSTTY_KEY_DIGIT_9) - s += QChar('0' + (k - GHOSTTY_KEY_DIGIT_0)); - else if (k >= GHOSTTY_KEY_A && k <= GHOSTTY_KEY_Z) - s += QChar('A' + (k - GHOSTTY_KEY_A)); - else - s += QStringLiteral("•"); // an unmapped physical key - break; - } - default: - s += QStringLiteral("…"); // catch-all - break; - } - return s; +// Queue `f` on `target`'s thread, but only if `target` is still alive +// when the slot runs (Qt cancels queued slots whose receiver was +// deleted). Cross-captured pointers must be wrapped in QPointer +// separately — `target` only protects itself. +template +static void post(Target *target, F &&f) { + if (!target) return; + QMetaObject::invokeMethod(target, std::forward(f), Qt::QueuedConnection); } bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target, @@ -1143,9 +1147,14 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target, // The window the action applies to: the target surface's window, or // (for app-level actions) any live window. Surface/window work is - // marshalled onto `win` so it is cancelled if that window goes away. + // marshalled onto `win` so it is cancelled if that window goes away; + // *cross*-captured pointers (e.g. `src` when posting to `win`) are + // wrapped in QPointer so they're checked at lambda-execution time — + // a multi-window + tear-off + close race could otherwise UAF. MainWindow *win = src ? src->owner() : (s_windows.isEmpty() ? nullptr : s_windows.first()); + QPointer winp(win); + QPointer srcp(src); // Actions may be dispatched from non-GUI threads, so window-touching // work is marshalled onto the GUI thread. @@ -1158,47 +1167,46 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target, case GHOSTTY_ACTION_NEW_TAB: { if (!win) return false; - ghostty_surface_t parent = src ? src->surface() : nullptr; - QMetaObject::invokeMethod( - win, [win, parent]() { win->newTab(parent); }, - Qt::QueuedConnection); + // `parent` is a libghostty handle whose lifetime tracks `src`'s. + // If `src` is gone by the time the lambda runs, drop the parent + // and create an unparented tab. + post(win, [winp, srcp]() { + if (!winp) return; + winp->newTab(srcp ? srcp->surface() : nullptr); + }); return true; } - case GHOSTTY_ACTION_NEW_WINDOW: { - ghostty_surface_t parent = src ? src->surface() : nullptr; - QMetaObject::invokeMethod( - qApp, [parent]() { MainWindow::newWindow(parent); }, - Qt::QueuedConnection); + case GHOSTTY_ACTION_NEW_WINDOW: + post(qApp, [srcp]() { + MainWindow::newWindow(srcp ? srcp->surface() : nullptr); + }); return true; - } case GHOSTTY_ACTION_NEW_SPLIT: { if (!src) return false; const ghostty_action_split_direction_e dir = action.action.new_split; - QMetaObject::invokeMethod( - win, [win, src, dir]() { win->splitSurface(src, dir); }, - Qt::QueuedConnection); + post(win, [winp, srcp, dir]() { + if (winp && srcp) winp->splitSurface(srcp, dir); + }); return true; } case GHOSTTY_ACTION_CLOSE_TAB: if (src) - QMetaObject::invokeMethod( - win, - [win, src]() { - if (win->confirmCloseSurfaces({src})) win->removeSurface(src); - }, - Qt::QueuedConnection); + post(win, [winp, srcp]() { + if (!winp || !srcp) return; + if (winp->confirmCloseSurfaces({srcp})) winp->removeSurface(srcp); + }); return true; case GHOSTTY_ACTION_SET_TITLE: { const char *title = action.action.set_title.title; if (!title || !src) return true; const QString t = QString::fromUtf8(title); - QMetaObject::invokeMethod( - win, [win, src, t]() { win->setSurfaceTitle(src, t); }, - Qt::QueuedConnection); + post(win, [winp, srcp, t]() { + if (winp && srcp) winp->setSurfaceTitle(srcp, t); + }); return true; } @@ -1207,9 +1215,9 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target, if (!src) return true; const char *title = action.action.set_tab_title.title; const QString t = QString::fromUtf8(title ? title : ""); - QMetaObject::invokeMethod( - win, [win, src, t]() { win->setTabTitleOverride(src, t); }, - Qt::QueuedConnection); + post(win, [winp, srcp, t]() { + if (winp && srcp) winp->setTabTitleOverride(srcp, t); + }); return true; } @@ -1217,117 +1225,108 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target, if (!src) return true; const bool tabScope = action.action.prompt_title == GHOSTTY_PROMPT_TITLE_TAB; - QMetaObject::invokeMethod( - src, [src, tabScope]() { src->promptTitle(tabScope); }, - Qt::QueuedConnection); + post(src, [srcp, tabScope]() { + if (srcp) srcp->promptTitle(tabScope); + }); return true; } case GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD: - if (win) - QMetaObject::invokeMethod( - win, [win]() { win->copyTitleToClipboard(); }, - Qt::QueuedConnection); + post(win, [winp]() { + if (winp) winp->copyTitleToClipboard(); + }); return true; case GHOSTTY_ACTION_RESET_WINDOW_SIZE: - if (win) - QMetaObject::invokeMethod( - win, - [win]() { - win->resize(win->m_defaultWindowSize.isValid() - ? win->m_defaultWindowSize - : QSize(800, 600)); - }, - Qt::QueuedConnection); + post(win, [winp]() { + if (!winp) return; + winp->resize(winp->m_defaultWindowSize.isValid() + ? winp->m_defaultWindowSize + : QSize(800, 600)); + }); return true; case GHOSTTY_ACTION_KEY_SEQUENCE: { if (!src) return true; const ghostty_action_key_sequence_s ks = action.action.key_sequence; if (!ks.active) { - QMetaObject::invokeMethod(src, [src]() { src->endKeySequence(); }, - Qt::QueuedConnection); + post(src, [srcp]() { + if (srcp) srcp->endKeySequence(); + }); return true; } const QString chord = formatTrigger(ks.trigger); - QMetaObject::invokeMethod( - src, [src, chord]() { src->pushKeySequence(chord); }, - Qt::QueuedConnection); + post(src, [srcp, chord]() { + if (srcp) srcp->pushKeySequence(chord); + }); return true; } case GHOSTTY_ACTION_GOTO_TAB: { if (!win) return false; const ghostty_action_goto_tab_e tab = action.action.goto_tab; - QMetaObject::invokeMethod( - win, [win, tab]() { win->gotoTab(tab); }, Qt::QueuedConnection); + post(win, [winp, tab]() { + if (winp) winp->gotoTab(tab); + }); return true; } case GHOSTTY_ACTION_GOTO_SPLIT: { if (!src) return false; const ghostty_action_goto_split_e dir = action.action.goto_split; - QMetaObject::invokeMethod( - win, [win, src, dir]() { win->gotoSplit(src, dir); }, - Qt::QueuedConnection); + post(win, [winp, srcp, dir]() { + if (winp && srcp) winp->gotoSplit(srcp, dir); + }); return true; } case GHOSTTY_ACTION_RESIZE_SPLIT: { if (!src) return false; const ghostty_action_resize_split_s rs = action.action.resize_split; - QMetaObject::invokeMethod( - win, [win, src, rs]() { win->resizeSplit(src, rs); }, - Qt::QueuedConnection); + post(win, [winp, srcp, rs]() { + if (winp && srcp) winp->resizeSplit(srcp, rs); + }); return true; } case GHOSTTY_ACTION_EQUALIZE_SPLITS: if (src) - QMetaObject::invokeMethod( - win, [win, src]() { win->equalizeSplits(src); }, - Qt::QueuedConnection); + post(win, [winp, srcp]() { + if (winp && srcp) winp->equalizeSplits(srcp); + }); return true; case GHOSTTY_ACTION_TOGGLE_FULLSCREEN: if (!win) return false; - QMetaObject::invokeMethod( - win, - [win]() { - if (win->isFullScreen()) - win->showNormal(); - else - win->showFullScreen(); - }, - Qt::QueuedConnection); + post(win, [winp]() { + if (!winp) return; + if (winp->isFullScreen()) + winp->showNormal(); + else + winp->showFullScreen(); + }); return true; case GHOSTTY_ACTION_TOGGLE_MAXIMIZE: if (!win) return false; - QMetaObject::invokeMethod( - win, - [win]() { - if (win->isMaximized()) - win->showNormal(); - else - win->showMaximized(); - }, - Qt::QueuedConnection); + post(win, [winp]() { + if (!winp) return; + if (winp->isMaximized()) + winp->showNormal(); + else + winp->showMaximized(); + }); return true; case GHOSTTY_ACTION_QUIT: case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: - QMetaObject::invokeMethod(qApp, []() { MainWindow::closeAllWindows(); }, - Qt::QueuedConnection); + post(qApp, []() { MainWindow::closeAllWindows(); }); return true; case GHOSTTY_ACTION_QUIT_TIMER: { const bool start = action.action.quit_timer == GHOSTTY_QUIT_TIMER_START; - QMetaObject::invokeMethod( - qApp, [start]() { MainWindow::handleQuitTimer(start); }, - Qt::QueuedConnection); + post(qApp, [start]() { MainWindow::handleQuitTimer(start); }); return true; } @@ -1335,17 +1334,17 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target, if (!src) return false; const int code = static_cast(action.action.child_exited.exit_code); - QMetaObject::invokeMethod( - src, [src, code]() { src->showChildExited(code); }, - Qt::QueuedConnection); + post(src, [srcp, code]() { + if (srcp) srcp->showChildExited(code); + }); return true; } case GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM: if (src) - QMetaObject::invokeMethod( - win, [win, src]() { win->toggleSplitZoom(src); }, - Qt::QueuedConnection); + post(win, [winp, srcp]() { + if (winp && srcp) winp->toggleSplitZoom(srcp); + }); return true; case GHOSTTY_ACTION_OPEN_CONFIG: { @@ -1355,67 +1354,60 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target, if (path.ptr && path.len) { const QString p = QString::fromUtf8(path.ptr, static_cast(path.len)); - QMetaObject::invokeMethod( - qApp, - [p]() { - QDesktopServices::openUrl(QUrl::fromLocalFile(p)); - }, - Qt::QueuedConnection); + post(qApp, + [p]() { QDesktopServices::openUrl(QUrl::fromLocalFile(p)); }); } ghostty_string_free(path); return true; } case GHOSTTY_ACTION_RELOAD_CONFIG: - if (win) - QMetaObject::invokeMethod( - win, [win]() { win->reloadConfig(); }, Qt::QueuedConnection); + post(win, [winp]() { + if (winp) winp->reloadConfig(); + }); return true; case GHOSTTY_ACTION_CONFIG_CHANGE: // A notification: libghostty already holds the new config (this // often fires as the echo of our own ghostty_app_update_config). // Re-pushing it would loop, so just refresh window chrome. - QMetaObject::invokeMethod(qApp, []() { MainWindow::refreshChrome(); }, - Qt::QueuedConnection); + post(qApp, []() { MainWindow::refreshChrome(); }); return true; case GHOSTTY_ACTION_INITIAL_SIZE: { if (!win) return false; const ghostty_action_initial_size_s sz = action.action.initial_size; - QMetaObject::invokeMethod( - win, - [win, sz]() { - // The action carries device pixels; resize() takes logical. - const double dpr = win->devicePixelRatioF(); - const QSize logical(static_cast(sz.width / dpr), - static_cast(sz.height / dpr)); - win->m_defaultWindowSize = logical; // for RESET_WINDOW_SIZE - win->resize(logical); - }, - Qt::QueuedConnection); + post(win, [winp, sz]() { + if (!winp) return; + // The action carries device pixels; resize() takes logical. + const double dpr = winp->devicePixelRatioF(); + const QSize logical(static_cast(sz.width / dpr), + static_cast(sz.height / dpr)); + winp->m_defaultWindowSize = logical; // for RESET_WINDOW_SIZE + winp->resize(logical); + }); return true; } case GHOSTTY_ACTION_CLOSE_WINDOW: - if (win) - QMetaObject::invokeMethod(win, [win]() { win->close(); }, - Qt::QueuedConnection); + post(win, [winp]() { + if (winp) winp->close(); + }); return true; case GHOSTTY_ACTION_RING_BELL: - if (win) - QMetaObject::invokeMethod(win, [win, src]() { win->ringBell(src); }, - Qt::QueuedConnection); + post(win, [winp, srcp]() { + if (winp) winp->ringBell(srcp); + }); return true; case GHOSTTY_ACTION_MOUSE_SHAPE: { if (!src) return false; const Qt::CursorShape shape = mouseShapeToCursor(action.action.mouse_shape); - QMetaObject::invokeMethod( - src, [src, shape]() { src->setCursor(shape); }, - Qt::QueuedConnection); + post(src, [srcp, shape]() { + if (srcp) srcp->setCursor(shape); + }); return true; } @@ -1424,8 +1416,9 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target, const ghostty_action_mouse_over_link_s l = action.action.mouse_over_link; const QString url = l.url && l.len ? QString::fromUtf8(l.url, l.len) : QString(); - QMetaObject::invokeMethod( - src, [src, url]() { src->setToolTip(url); }, Qt::QueuedConnection); + post(src, [srcp, url]() { + if (srcp) srcp->setToolTip(url); + }); return true; } @@ -1433,13 +1426,10 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target, const ghostty_action_open_url_s u = action.action.open_url; if (!u.url || !u.len) return true; const QString s = QString::fromUtf8(u.url, static_cast(u.len)); - QMetaObject::invokeMethod( - qApp, - [s]() { - QDesktopServices::openUrl( - QUrl::fromUserInput(s, QString(), QUrl::AssumeLocalFile)); - }, - Qt::QueuedConnection); + post(qApp, [s]() { + QDesktopServices::openUrl( + QUrl::fromUserInput(s, QString(), QUrl::AssumeLocalFile)); + }); return true; } @@ -1448,34 +1438,28 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target, action.action.desktop_notification; const QString title = QString::fromUtf8(n.title ? n.title : ""); const QString body = QString::fromUtf8(n.body ? n.body : ""); - QMetaObject::invokeMethod( - qApp, [title, body]() { postNotification(title, body); }, - Qt::QueuedConnection); + post(qApp, [title, body]() { postNotification(title, body); }); return true; } case GHOSTTY_ACTION_COMMAND_FINISHED: { if (!src) return true; const int code = action.action.command_finished.exit_code; - QMetaObject::invokeMethod( - src, - [src, code]() { - if (!src->consumeCommandNotify()) return; - postNotification( - QStringLiteral("Command finished"), - code >= 0 ? QStringLiteral("Exited with code %1").arg(code) - : QStringLiteral("The command completed.")); - }, - Qt::QueuedConnection); + post(src, [srcp, code]() { + if (!srcp || !srcp->consumeCommandNotify()) return; + postNotification( + QStringLiteral("Command finished"), + code >= 0 ? QStringLiteral("Exited with code %1").arg(code) + : QStringLiteral("The command completed.")); + }); return true; } case GHOSTTY_ACTION_MOVE_TAB: { const int amount = static_cast(action.action.move_tab.amount); - if (win) - QMetaObject::invokeMethod( - win, [win, amount]() { win->moveTab(amount); }, - Qt::QueuedConnection); + post(win, [winp, amount]() { + if (winp) winp->moveTab(amount); + }); return true; } @@ -1483,27 +1467,23 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target, if (!src) return false; const bool hidden = action.action.mouse_visibility == GHOSTTY_MOUSE_HIDDEN; - QMetaObject::invokeMethod( - src, - [src, hidden]() { - src->setCursor(hidden ? Qt::BlankCursor : Qt::ArrowCursor); - }, - Qt::QueuedConnection); + post(src, [srcp, hidden]() { + if (srcp) srcp->setCursor(hidden ? Qt::BlankCursor : Qt::ArrowCursor); + }); return true; } case GHOSTTY_ACTION_RENDERER_HEALTH: if (action.action.renderer_health == GHOSTTY_RENDERER_HEALTH_UNHEALTHY) - std::fprintf(stderr, "[ghostty] renderer reported unhealthy\n"); + std::fprintf(stderr, "[ghastty] renderer reported unhealthy\n"); return true; case GHOSTTY_ACTION_SCROLLBAR: { if (!src) return false; const ghostty_action_scrollbar_s s = action.action.scrollbar; - QMetaObject::invokeMethod( - src, - [src, s]() { src->updateScrollbar(s.total, s.offset, s.len); }, - Qt::QueuedConnection); + post(src, [srcp, s]() { + if (srcp) srcp->updateScrollbar(s.total, s.offset, s.len); + }); return true; } @@ -1511,52 +1491,47 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target, const ghostty_action_progress_report_s p = action.action.progress_report; const bool visible = p.state != GHOSTTY_PROGRESS_STATE_REMOVE; const double fraction = p.progress >= 0 ? p.progress / 100.0 : 0.0; - QMetaObject::invokeMethod( - qApp, [visible, fraction]() { postProgress(visible, fraction); }, - Qt::QueuedConnection); + post(qApp, [visible, fraction]() { postProgress(visible, fraction); }); return true; } case GHOSTTY_ACTION_TOGGLE_VISIBILITY: - QMetaObject::invokeMethod(qApp, - []() { MainWindow::toggleVisibility(); }, - Qt::QueuedConnection); + post(qApp, []() { MainWindow::toggleVisibility(); }); return true; case GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL: - QMetaObject::invokeMethod(qApp, - []() { MainWindow::toggleQuickTerminal(); }, - Qt::QueuedConnection); + post(qApp, []() { MainWindow::toggleQuickTerminal(); }); return true; case GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE: - if (win) - QMetaObject::invokeMethod( - win, [win, src]() { win->toggleCommandPalette(src); }, - Qt::QueuedConnection); + post(win, [winp, srcp]() { + if (winp) winp->toggleCommandPalette(srcp); + }); return true; case GHOSTTY_ACTION_START_SEARCH: { if (!src) return true; const char *needle = action.action.start_search.needle; const QString n = QString::fromUtf8(needle ? needle : ""); - QMetaObject::invokeMethod(src, [src, n]() { src->openSearch(n); }, - Qt::QueuedConnection); + post(src, [srcp, n]() { + if (srcp) srcp->openSearch(n); + }); return true; } case GHOSTTY_ACTION_END_SEARCH: if (src) - QMetaObject::invokeMethod(src, [src]() { src->closeSearch(); }, - Qt::QueuedConnection); + post(src, [srcp]() { + if (srcp) srcp->closeSearch(); + }); return true; case GHOSTTY_ACTION_SEARCH_TOTAL: { if (!src) return true; const int total = static_cast(action.action.search_total.total); - QMetaObject::invokeMethod( - src, [src, total]() { src->setSearchTotal(total); }, - Qt::QueuedConnection); + post(src, [srcp, total]() { + if (srcp) srcp->setSearchTotal(total); + }); return true; } @@ -1564,18 +1539,18 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target, if (!src) return true; const int sel = static_cast(action.action.search_selected.selected); - QMetaObject::invokeMethod( - src, [src, sel]() { src->setSearchSelected(sel); }, - Qt::QueuedConnection); + post(src, [srcp, sel]() { + if (srcp) srcp->setSearchSelected(sel); + }); return true; } case GHOSTTY_ACTION_INSPECTOR: { if (!src) return true; const ghostty_action_inspector_e mode = action.action.inspector; - QMetaObject::invokeMethod( - src, [src, mode]() { src->toggleInspector(mode); }, - Qt::QueuedConnection); + post(src, [srcp, mode]() { + if (srcp) srcp->toggleInspector(mode); + }); return true; } @@ -1587,9 +1562,11 @@ bool MainWindow::onAction(ghostty_app_t, ghostty_target_s target, bool MainWindow::onReadClipboard(void *ud, ghostty_clipboard_e loc, void *state) { // surface userdata. Called synchronously when libghostty needs - // clipboard contents (paste). + // clipboard contents (paste). May arrive on a worker thread, so + // surfaceAlive validates the pointer first — the GhosttySurface + // could be mid-destruction. auto *surface = static_cast(ud); - if (!surface || !surface->surface()) return false; + if (!surfaceAlive(surface) || !surface->surface()) return false; const QClipboard::Mode mode = loc == GHOSTTY_CLIPBOARD_SELECTION ? QClipboard::Selection @@ -1608,24 +1585,30 @@ void MainWindow::onConfirmReadClipboard(void *ud, const char *str, void *state, // through the render tick — a crash/freeze. `state` is a completion // token valid until used; `str` is not, so copy it. auto *surface = static_cast(ud); - if (!surface || !surface->surface()) return; + if (!surfaceAlive(surface) || !surface->surface()) return; + QPointer sp(surface); const QByteArray content(str); QMetaObject::invokeMethod( surface->owner(), - [surface, content, state]() { - if (!surface->surface()) return; + [sp, content, state]() { + if (!sp || !sp->surface()) return; QString preview = QString::fromUtf8(content); - if (preview.size() > 200) - preview = preview.left(200) + QStringLiteral("…"); + // Truncate by code unit but back off to a non-surrogate boundary + // so we don't slice a surrogate pair half. + if (preview.size() > 200) { + int cut = 200; + while (cut > 0 && preview.at(cut - 1).isHighSurrogate()) --cut; + preview = preview.left(cut) + QStringLiteral("…"); + } const auto reply = QMessageBox::warning( - surface->owner(), QStringLiteral("Confirm Paste"), + sp->owner(), QStringLiteral("Confirm Paste"), QStringLiteral("The text being pasted may be unsafe:\n\n%1\n\n" "Paste anyway?") .arg(preview), QMessageBox::Yes | QMessageBox::No, QMessageBox::No); ghostty_surface_complete_clipboard_request( - surface->surface(), content.constData(), state, + sp->surface(), content.constData(), state, reply == QMessageBox::Yes); }, Qt::QueuedConnection); @@ -1636,14 +1619,16 @@ void MainWindow::onWriteClipboard(void *ud, ghostty_clipboard_e loc, size_t n, bool) { if (n == 0 || !content[0].data) return; auto *surface = static_cast(ud); - if (!surface) return; + if (!surfaceAlive(surface)) return; const QClipboard::Mode mode = loc == GHOSTTY_CLIPBOARD_SELECTION ? QClipboard::Selection : QClipboard::Clipboard; const QString text = QString::fromUtf8(content[0].data); + // The clipboard is process-global; route via qApp so a window dying + // mid-flight does not strand the write. QMetaObject::invokeMethod( - surface->owner(), + qApp, [text, mode]() { QGuiApplication::clipboard()->setText(text, mode); }, Qt::QueuedConnection); } @@ -1652,13 +1637,15 @@ void MainWindow::onCloseSurface(void *ud, bool) { // surface userdata. Deferred out of this callback so the confirm // dialog cannot spin a nested event loop back into libghostty. auto *surface = static_cast(ud); - if (!surface) return; + if (!surfaceAlive(surface)) return; MainWindow *self = surface->owner(); + QPointer selfp(self); + QPointer sp(surface); QMetaObject::invokeMethod( self, - [self, surface]() { - if (self->confirmCloseSurfaces({surface})) - self->removeSurface(surface); + [selfp, sp]() { + if (!selfp || !sp) return; + if (selfp->confirmCloseSurfaces({sp})) selfp->removeSurface(sp); }, Qt::QueuedConnection); } diff --git a/qt/src/MainWindow.h b/qt/src/MainWindow.h index 33baed960..d609df8b4 100644 --- a/qt/src/MainWindow.h +++ b/qt/src/MainWindow.h @@ -91,8 +91,11 @@ private: // Create the first tab once the device pixel ratio has settled. void createFirstTab(); - // 60fps frame timer: ticks libghostty and renders any dirty surface. - void frame(); + // 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). @@ -184,6 +187,12 @@ private: 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 m_surfaces; // every live surface in this window bool m_firstTabPending = true; // first tab is created on show() @@ -202,6 +211,10 @@ private: 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. diff --git a/qt/src/OverlayScrollbar.cpp b/qt/src/OverlayScrollbar.cpp index d33dd09a9..293c624c4 100644 --- a/qt/src/OverlayScrollbar.cpp +++ b/qt/src/OverlayScrollbar.cpp @@ -55,7 +55,10 @@ void OverlayScrollbar::setMetrics(quint64 total, quint64 offset, m_total = total; m_offset = offset; m_len = len; - if (isVisible()) update(); + // 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) { diff --git a/qt/src/TabWidget.cpp b/qt/src/TabWidget.cpp index 5d32ae9a4..6bc27ee54 100644 --- a/qt/src/TabWidget.cpp +++ b/qt/src/TabWidget.cpp @@ -1,18 +1,40 @@ #include "TabWidget.h" +#include + +#include +#include #include #include #include #include #include #include +#include #include namespace { -// Set by a TabBar::dropEvent during an in-flight tear-off. It is the -// reliable "released on a tab bar" signal: QDrag::exec()'s return value -// cannot be trusted across surfaces on Wayland. -bool g_tabDropHandled = false; +// MIME role carrying a pointer-to-originating-TabBar so a receiving +// bar's dropEvent can mark the originator's m_dropHandled. We can't +// rely on QDrag::exec()'s return value on Wayland, and a process-wide +// "drop handled" flag races with simultaneous tear-offs in different +// windows. +constexpr char kTearOffOriginRole[] = "application/x-ghastty-tab-origin"; + +QByteArray encodeOrigin(TabBar *bar) { + // Pack the raw pointer; the source process owns it for the lifetime + // of the drag. We never dereference unless we're in the same process, + // and a tear-off across processes is meaningless. + QByteArray bytes(reinterpret_cast(&bar), sizeof(bar)); + return bytes; +} + +TabBar *decodeOrigin(const QByteArray &bytes) { + if (bytes.size() != sizeof(TabBar *)) return nullptr; + TabBar *bar; + std::memcpy(&bar, bytes.constData(), sizeof(bar)); + return bar; +} } // namespace void TabBar::mousePressEvent(QMouseEvent *e) { @@ -64,6 +86,10 @@ void TabBar::startTearOff(QMouseEvent *e) { 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()); @@ -77,15 +103,15 @@ void TabBar::startTearOff(QMouseEvent *e) { // Released on a tab bar cancels the tear-off; released anywhere else // (the terminal, another window, the desktop) tears it into a new - // window. g_tabDropHandled — set by TabBar::dropEvent — is the - // signal, since QDrag::exec()'s result is unreliable across surfaces - // on Wayland. - g_tabDropHandled = false; + // 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. + m_dropHandled = false; drag->exec(Qt::MoveAction); m_tearing = false; m_pressIndex = -1; - if (!g_tabDropHandled) emit tabTornOff(index); + if (!m_dropHandled) emit tabTornOff(index); } void TabBar::dragEnterEvent(QDragEnterEvent *e) { @@ -94,9 +120,15 @@ void TabBar::dragEnterEvent(QDragEnterEvent *e) { } void TabBar::dropEvent(QDropEvent *e) { - // Dropping a tear-off back on a tab bar cancels it. + // 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))) { - g_tabDropHandled = true; + if (TabBar *origin = decodeOrigin( + e->mimeData()->data(QString::fromLatin1(kTearOffOriginRole)))) + origin->m_dropHandled = true; + else + m_dropHandled = true; // fallback: mark ourselves e->acceptProposedAction(); } } diff --git a/qt/src/WindowBlur.cpp b/qt/src/WindowBlur.cpp index bf1f68314..5dea0d3ba 100644 --- a/qt/src/WindowBlur.cpp +++ b/qt/src/WindowBlur.cpp @@ -60,6 +60,14 @@ org_kde_kwin_blur_manager *blurManager(wl_display *display) { 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 &waylandBlurs() { + static QHash blurs; + return blurs; +} + void applyWayland(QWindow *window, bool enabled) { QPlatformNativeInterface *native = QGuiApplication::platformNativeInterface(); if (!native) return; @@ -72,9 +80,7 @@ void applyWayland(QWindow *window, bool enabled) { org_kde_kwin_blur_manager *manager = blurManager(display); if (!manager) return; // compositor advertises no blur support - // The live blur object per window — kept so it can be released when - // blur is turned off or re-applied on a config change. - static QHash blurs; + auto &blurs = waylandBlurs(); if (org_kde_kwin_blur *old = blurs.take(window)) org_kde_kwin_blur_release(old); @@ -84,6 +90,15 @@ void applyWayland(QWindow *window, bool enabled) { 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. Without this, + // a closed window leaves its org_kde_kwin_blur leaked and the + // QWindow* key in the hash dangles. + 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); } diff --git a/qt/src/main.cpp b/qt/src/main.cpp index 4271eb8ed..462645b9a 100644 --- a/qt/src/main.cpp +++ b/qt/src/main.cpp @@ -49,14 +49,14 @@ int main(int argc, char **argv) { // 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(argc), argv) != GHOSTTY_SUCCESS) { - std::fprintf(stderr, "[ghostty] ghostty_init failed\n"); + 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, "[ghostty] window initialization failed\n"); + std::fprintf(stderr, "[ghastty] window initialization failed\n"); return 1; } From edc00a3eaa13a569f3f3ee1e5a581cf703a47145 Mon Sep 17 00:00:00 2001 From: ntomsic Date: Wed, 20 May 2026 14:23:53 -0500 Subject: [PATCH 62/75] qt: stop mutating zig-out at configure time; Debian GuiPrivate fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CMake fixes shaken out by a containerised build: - The previous file(CREATE_LINK ... ${GHOSTTY_LIB_DIR}/libghostty.so) was a configure-time write into the source tree (zig-out is the Zig build's output, not ours to touch). Replaced with an add_custom_command that creates the libghostty.so symlink in ${CMAKE_CURRENT_BINARY_DIR}/lib/, plus a ghostty_link target the ghastty target depends on. BUILD_RPATH covers both that build-tree symlink and zig-out for transitive deps. - Qt 6 GuiPrivate is the official path for qpa/qplatformnativeinterface.h, but Debian's qt6-base-private-dev ships only the headers — no Qt6GuiPrivateConfig.cmake. find_package(Qt6 OPTIONAL_COMPONENTS GuiPrivate) still tries first; on miss, fall back to find_path-ing the header and adding its parent dir as a private include. Build now succeeds on Debian trixie + Qt 6.8. - Util.cpp registered in the executable target. Co-Authored-By: claude-flow --- qt/CMakeLists.txt | 66 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/qt/CMakeLists.txt b/qt/CMakeLists.txt index 426c514ff..451182052 100644 --- a/qt/CMakeLists.txt +++ b/qt/CMakeLists.txt @@ -29,8 +29,15 @@ set(CMAKE_AUTOMOC ON) include(GNUInstallDirs) -find_package(Qt6 REQUIRED COMPONENTS Gui GuiPrivate Widgets OpenGL DBus +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) @@ -71,9 +78,17 @@ if(NOT EXISTS "${GHOSTTY_SO}") endif() # The .so's SONAME is libghostty.so but it is installed as -# ghostty-internal.so; create the expected name so the loader finds it. -file(CREATE_LINK "ghostty-internal.so" "${GHOSTTY_LIB_DIR}/libghostty.so" - SYMBOLIC) +# 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 @@ -85,6 +100,7 @@ add_executable(ghastty src/OverlayScrollbar.cpp src/SearchBar.cpp src/TabWidget.cpp + src/Util.cpp src/WindowBlur.cpp "${BLUR_CODE}" "${BLUR_HEADER}" @@ -101,9 +117,10 @@ target_include_directories(ghastty PRIVATE "${CMAKE_CURRENT_BINARY_DIR}" # generated blur-client-protocol.h ) +add_dependencies(ghastty ghostty_link) + target_link_libraries(ghastty PRIVATE Qt6::Gui - Qt6::GuiPrivate Qt6::Widgets Qt6::OpenGL Qt6::DBus @@ -113,13 +130,44 @@ target_link_libraries(ghastty PRIVATE PkgConfig::XCB PkgConfig::XKBCOMMON LayerShellQt::Interface - "${GHOSTTY_LIB_DIR}/libghostty.so" + "${GHOSTTY_LINK_SO}" ) -# RPATH: the build-tree binary finds libghostty.so in zig-out/lib; the -# installed binary finds it next to itself ($ORIGIN/../lib). +# 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 + # /QtGui//QtGui/qpa/qplatformnativeinterface.h, so we + # add the dir containing the `qpa` subfolder). + find_path(QT_QPA_INCLUDE_DIR + NAMES qpa/qplatformnativeinterface.h + HINTS + "${Qt6Gui_PRIVATE_INCLUDE_DIRS}" + "${Qt6_DIR}/../../../include/aarch64-linux-gnu/qt6/QtGui/${Qt6_VERSION}/QtGui" + "${Qt6_DIR}/../../../include/x86_64-linux-gnu/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_LIB_DIR}" + BUILD_RPATH "${GHOSTTY_LINK_DIR};${GHOSTTY_LIB_DIR}" INSTALL_RPATH "$ORIGIN/../${CMAKE_INSTALL_LIBDIR}" ) From 34ac8dae1111b730a2a2cdfc5c6f4a69c2028591 Mon Sep 17 00:00:00 2001 From: ntomsic Date: Wed, 20 May 2026 14:24:08 -0500 Subject: [PATCH 63/75] build: Dockerfile for a reproducible libghostty + Qt frontend build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-stage: base - debian trixie + Qt 6.8 + LayerShellQt + xkbcommon / wayland / xcb dev packages zig - Zig 0.15.2 (the project's minimum_zig_version) pinned; separate stage so apt churn doesn't bust the toolchain layer libghostty - zig build test (Debug) then -Doptimize=ReleaseFast, with mounted caches for /root/.cache/zig and /src/.zig-cache qt - cmake -G Ninja then cmake --install /out out - debian-slim carrying just /out (bin, lib, share/...) Tests run inside the libghostty stage so a regression there fails the build cleanly rather than wasting time on the Qt stage. The image is build-only — runtime needs Qt + Wayland sockets the container doesn't have. Extract the artifacts with: mkdir -p out docker run --rm -v "$PWD/out:/host-out" ghastty \ sh -c 'cp -a /out/. /host-out/' .dockerignore drops zig-cache, build dirs, agent state and the usual git/IDE noise so the COPY context stays small. Co-Authored-By: claude-flow --- .dockerignore | 58 +++++++++++++++++++++ Dockerfile | 137 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..18d0be9ba --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..b090a5030 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,137 @@ +# 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 --mount=type=cache,target=/root/.cache/cmake \ + 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"] From e18f874dd03dbf9a7d2509c380c5d617911c2f3c Mon Sep 17 00:00:00 2001 From: ntomsic Date: Wed, 20 May 2026 15:15:43 -0500 Subject: [PATCH 64/75] =?UTF-8?q?core:=20fix=20two=20C=20ABI=20typos=20(ti?= =?UTF-8?q?metime=5Fms=20=E2=86=92=20runtime=5Fms,=20chostty=5Fipc=5Ftarge?= =?UTF-8?q?t=5Fs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two long-standing typos in the public C header, surfaced by a senior- engineer audit pass on this PR: - `ghostty_surface_message_childexited_s.timetime_ms` was a typo for `runtime_ms` (the field name on the Zig side at src/apprt/surface.zig:119). The binary layout was always correct (offset 4, u64), so this is a name-only rename — no ABI break, no consumer was reading the wrong bytes. Three Swift call sites in macos/Sources/Ghostty updated to match. - `chostty_ipc_target_s` was a typedef-name typo (the contained `ghostty_ipc_target_tag_e` and `ghostty_ipc_target_u` were already correct). The struct has no consumers yet, so this is purely cosmetic; no client breakage possible. Co-Authored-By: claude-flow --- include/ghostty.h | 8 ++++++-- macos/Sources/Ghostty/Ghostty.App.swift | 4 ++-- macos/Sources/Ghostty/Ghostty.ChildExitedMessage.swift | 6 +++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index b562134aa..c328cd115 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -867,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 @@ -1075,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 { @@ -1235,6 +1235,10 @@ GHOSTTY_API bool ghostty_inspector_metal_shutdown(ghostty_inspector_t); // 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); diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 018122760..cbb906323 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -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 diff --git a/macos/Sources/Ghostty/Ghostty.ChildExitedMessage.swift b/macos/Sources/Ghostty/Ghostty.ChildExitedMessage.swift index e21a79dbe..08ac5c0a2 100644 --- a/macos/Sources/Ghostty/Ghostty.ChildExitedMessage.swift +++ b/macos/Sources/Ghostty/Ghostty.ChildExitedMessage.swift @@ -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 From f2d29f769556246b6a18b9ea327e5f57cb3005b6 Mon Sep 17 00:00:00 2001 From: ntomsic Date: Wed, 20 May 2026 15:15:56 -0500 Subject: [PATCH 65/75] core+renderer: align loader cast, log every render-pass error, sync OpenGL host docs Audit findings (passes 1, 3, 4) on libghostty side: - src/renderer/OpenGL.zig: present() now log.warns when called on a thread without an embedded GL host bound (the threadlocal `gl_host` is null). Should never happen given must_draw_from_app_thread, but silent failure makes the misconfiguration impossible to diagnose. - src/renderer/opengl/RenderPass.zig: every `catch return` arm in step() now logs the failure. Pass 1 only converted the bind paths; Pass 3 caught that viewport, UBO/texture/sampler/VBO/SSBO bindings, blend setup, and the actual drawArrays were still silent. A failing draw used to leave no log evidence at all. - src/apprt/embedded.zig: - Inspector.deinit now documents that the OpenGL backend's ImGui shutdown requires the GL context current; hosts must arrange that before calling ghostty_inspector_free. Mirrors the C-side comment. - Platform.OpenGL docstring corrected: the prior text said callbacks run on Ghostty's renderer thread, but with must_draw_from_app_thread they run on the app/GUI thread. The C header was updated earlier; this brings the Zig-side doc in sync. Co-Authored-By: claude-flow --- src/apprt/embedded.zig | 14 ++++++-- src/renderer/OpenGL.zig | 13 ++++++- src/renderer/opengl/RenderPass.zig | 55 ++++++++++++++++++++++++------ 3 files changed, 67 insertions(+), 15 deletions(-) diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 3daf0a18c..a28f3545c 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -368,9 +368,12 @@ pub const Platform = union(PlatformTag) { /// Configuration for a host that provides its own OpenGL context /// (e.g. a Qt, X11, or Wayland application embedding libghostty). /// - /// Ghostty's renderer thread drives the context via these callbacks, - /// so they must be safe to call from a thread other than the one - /// that created the context. + /// 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, @@ -1096,6 +1099,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); } diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 4be5b45d5..09f6d8188 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -377,7 +377,18 @@ pub fn present(self: *OpenGL, target: Target) !void { // 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); + 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", + .{}, + ); + } } } diff --git a/src/renderer/opengl/RenderPass.zig b/src/renderer/opengl/RenderPass.zig index 4876de9ae..2f3a05704 100644 --- a/src/renderer/opengl/RenderPass.zig +++ b/src/renderer/opengl/RenderPass.zig @@ -108,7 +108,10 @@ pub fn step(self: *Self, s: Step) void { .target => |t| .{ t.width, t.height }, .texture => |t| .{ t.width, t.height }, }; - gl.viewport(0, 0, @intCast(vp_w), @intCast(vp_h)) catch return; + 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 @@ -120,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, @@ -142,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( @@ -161,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. From 78cdd854664aaf93326e3b20a477cc2802e5e305 Mon Sep 17 00:00:00 2001 From: ntomsic Date: Wed, 20 May 2026 15:16:26 -0500 Subject: [PATCH 66/75] qt: audit-driven correctness, lifetime, and consistency fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five-pass senior-engineer audit on top of the earlier audit pass. Cumulative findings, all fixes, all verified by Docker build: Lifetime / safety: - MainWindow::onAction no longer dispatches lambdas that can outlive s_app/s_config: the dtor drains qApp-targeted MetaCalls via QCoreApplication::sendPostedEvents(qApp, QEvent::MetaCall) before freeing s_app/s_config. Per-window/per-surface targeted lambdas are auto-cancelled by Qt when their receivers were already deleted. - removeSurface, closeTab, and adoptTab now invalidate m_zoomed/m_zoomRoot/m_zoomSplitter when the page or splitter being destroyed contained the zoom stash — fixes a UAF in toggleSplitZoom reachable by closing a non-zoomed sibling pane while zoomed. - TabBar tear-off: drag->exec spins a nested event loop that could delete `this` mid-drag; wrapped in `QPointer self` and early-return on destruction. Origin payload now carries source PID + a process-local liveTabBars() set membership check, so a stale same-process pointer or cross-process Wayland delivery cannot dereference garbage. - WindowBlur connect-once: repeated applyBlur(window, true) calls used to stack N stale QWindow::destroyed lambdas; now gated on a firstTime check (entry not in the cache). - InspectorWindow: closeEvent stops the 30Hz redraw timer (was burning CPU while hidden); showEvent restarts it. Dtor reordered so ghostty_inspector_free runs WITH the GL context current, satisfying the OpenGL ImGui backend's teardown contract; if makeCurrent fails while m_glReady, log to stderr and accept the GL leak (process exit imminent). - GhosttySurface dtor handles partial construction gracefully — GL teardown no longer gated on a successful makeCurrent. - GlobalShortcuts: m_requests entries are dropped on async-call failure; previously a portal rejection leaked the entry forever. - CommandPalette: m_owner is now QPointer with a null check before mapToGlobal; the filter input is debounced 80ms (mirrors SearchBar's 200ms in spirit) so a long command-palette-entry list doesn't thrash QSortFilterProxyModel on every keystroke. - SearchBar: m_surface is null-checked in close paths before setFocus, against teardown-ordering edge cases. Bug fixes: - LayerShellQt::Window has no setDesiredSize() on the Qt 6 / trixie branch; that call was removed (the next-line resize() carries the intent already, and that's how the protocol takes the size). - Bell audio: m_bellPlayer->stop() before setSource/play so rapid back-to-back bells restart the clip from zero instead of being silently swallowed. - InspectorWindow keyPressEvent: control-character filter scans every byte of ev->text(), not just text.at(0); previously a multi-char text starting with a printable then containing newline/control bytes could leak through. - OverlayScrollbar::setMetrics now repaints during fade-out (m_opacity > 0) too, not only when fully visible — a fading scrollbar tracks live scrollback updates instead of freezing. DRY / consistency: - New qt/src/Util.{h,cpp}: a single home for translateMods (was duplicated 3×), formatTrigger/triggerKeyName, the BellFeature enum (replaces hand-rolled (1u << N) magic numbers), and a configGet template that infers the key length from a string literal so call sites stop repeating qstrlen(literal). Util.cpp also static_asserts the libghostty enum contiguity that triggerKeyName relies on (DIGIT_0..9, A..Z), so a future libghostty insertion fails the build instead of producing wrong shortcut hints silently. - Typed TabData struct (in TabWidget.h) replaces the QStringList-of-length-2 stored in QTabBar::tabData — schema is no longer comment-only. - s_quitTimer parented to qApp for consistency with s_frameTimer. - Log prefix unified to [ghastty] across qt/src/. Co-Authored-By: claude-flow --- qt/src/CommandPalette.cpp | 25 +++++++++++++---- qt/src/CommandPalette.h | 7 ++++- qt/src/GhosttySurface.cpp | 39 ++++++++++++++++---------- qt/src/GlobalShortcuts.cpp | 13 ++++++--- qt/src/InspectorWindow.cpp | 44 +++++++++++++++++++++++++---- qt/src/InspectorWindow.h | 3 ++ qt/src/MainWindow.cpp | 54 +++++++++++++++++++++++++++++++++--- qt/src/SearchBar.cpp | 6 ++-- qt/src/TabWidget.cpp | 57 +++++++++++++++++++++++++++++--------- qt/src/TabWidget.h | 3 +- qt/src/Util.cpp | 9 ++++++ qt/src/Util.h | 4 +++ qt/src/WindowBlur.cpp | 23 +++++++++------ 13 files changed, 227 insertions(+), 60 deletions(-) diff --git a/qt/src/CommandPalette.cpp b/qt/src/CommandPalette.cpp index c8d014df8..386e24b6c 100644 --- a/qt/src/CommandPalette.cpp +++ b/qt/src/CommandPalette.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include "GhosttySurface.h" @@ -48,10 +49,18 @@ CommandPalette::CommandPalette(QWidget *owner) layout->addWidget(m_search); layout->addWidget(m_list); - connect(m_search, &QLineEdit::textChanged, this, [this](const QString &t) { - m_filter->setFilterFixedString(t); + // 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(); @@ -62,6 +71,12 @@ void CommandPalette::toggleFor(GhosttySurface *surface) { 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(); @@ -70,10 +85,8 @@ void CommandPalette::toggleFor(GhosttySurface *surface) { // 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. - if (m_owner) { - const QPoint p((m_owner->width() - width()) / 2, m_owner->height() / 6); - move(m_owner->mapToGlobal(p)); - } + const QPoint p((m_owner->width() - width()) / 2, m_owner->height() / 6); + move(m_owner->mapToGlobal(p)); show(); m_search->setFocus(); selectFirstRow(); diff --git a/qt/src/CommandPalette.h b/qt/src/CommandPalette.h index 7bc36e86a..4f89d7208 100644 --- a/qt/src/CommandPalette.h +++ b/qt/src/CommandPalette.h @@ -8,6 +8,7 @@ class QLineEdit; class QListView; class QSortFilterProxyModel; class QStandardItemModel; +class QTimer; // A searchable command palette (the TOGGLE_COMMAND_PALETTE action). // @@ -35,10 +36,14 @@ private: void moveSelection(int delta); void selectFirstRow(); - QWidget *m_owner; // the window the palette centres over + // 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 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 m_surface; // active surface; may go away }; diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index 6648ac136..909810813 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -8,6 +8,7 @@ #include "Util.h" #include +#include #include #include @@ -120,14 +121,18 @@ GhosttySurface::~GhosttySurface() { // QPointer auto-nulls on a destroyed QObject, so .data() is safe. delete m_inspectorWindow.data(); - // Release GL-owning objects with the context current. - if (makeCurrent()) { - if (m_surface) ghostty_surface_free(m_surface); - delete m_fbo; - delete m_premultProg; - delete m_premultVao; - m_context->doneCurrent(); - } + // GL teardown must happen with the context current. If makeCurrent + // fails (e.g. the ctor failed before m_context could be created), we + // still free m_surface — it carries no GL state of its own — and we + // still delete the FBO and premult helpers. Deleting QOpenGL* objects + // without a current context leaks the GL-side resource but is safe + // CPU-side; that's the best we can do when the context is gone. + const bool current = makeCurrent(); + if (m_surface) ghostty_surface_free(m_surface); + delete m_fbo; + delete m_premultProg; + delete m_premultVao; + if (current) m_context->doneCurrent(); } bool GhosttySurface::makeCurrent() { @@ -149,9 +154,11 @@ void GhosttySurface::syncSurfaceSize() { // PassThrough rounding policy. const double dpr = devicePixelRatioF(); // The terminal fills the full width; the scrollbar is a thin overlay - // floating on top, so it does not subtract from the grid. - const int w = std::max(1, static_cast(width() * dpr)); - const int h = std::max(1, static_cast(height() * dpr)); + // floating on top, so it does not subtract from the grid. Round-to- + // nearest rather than truncate so a fractional DPR (e.g. 1.5) doesn't + // shave a pixel off the framebuffer relative to the QImage blit. + const int w = std::max(1, static_cast(std::lround(width() * dpr))); + const int h = std::max(1, static_cast(std::lround(height() * dpr))); if (w == m_fbw && h == m_fbh && dpr == m_fbDpr) return; m_fbw = w; m_fbh = h; @@ -985,10 +992,12 @@ QVariant GhosttySurface::inputMethodQuery(Qt::InputMethodQuery query) const { if (!m_surface) return QRect(); const ghostty_surface_cursor_position_s c = ghostty_surface_cursor_position(m_surface); - const double dpr = m_fbDpr > 0 ? m_fbDpr : 1.0; - return QRect(static_cast(c.x / dpr), static_cast(c.y / dpr), - std::max(1, static_cast(c.width / dpr)), - std::max(1, static_cast(c.height / dpr))); + // m_fbDpr defaults to 1.0 and only ever takes positive values + // from syncSurfaceSize, so dividing is always safe. + return QRect(static_cast(c.x / m_fbDpr), + static_cast(c.y / m_fbDpr), + std::max(1, static_cast(c.width / m_fbDpr)), + std::max(1, static_cast(c.height / m_fbDpr))); } default: return QWidget::inputMethodQuery(query); diff --git a/qt/src/GlobalShortcuts.cpp b/qt/src/GlobalShortcuts.cpp index 9c7a00eb3..75e6a3caa 100644 --- a/qt/src/GlobalShortcuts.cpp +++ b/qt/src/GlobalShortcuts.cpp @@ -80,7 +80,8 @@ void GlobalShortcuts::portalCall(const QString &method, QVariantList args, const QString token = nextToken(); options[QStringLiteral("handle_token")] = token; args.append(QVariant(options)); // the trailing a{sv} every method takes - m_requests.insert(requestPath(token), method); + const QString path = requestPath(token); + m_requests.insert(path, method); QDBusMessage msg = QDBusMessage::createMethodCall( QString::fromLatin1(kService), QString::fromLatin1(kPath), @@ -88,16 +89,20 @@ void GlobalShortcuts::portalCall(const QString &method, QVariantList args, msg.setArguments(args); // The real result arrives via the Response signal; watch the call - // itself only so a failed invocation is not silently swallowed. + // 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, - [method](QDBusPendingCallWatcher *w) { + [this, method, path](QDBusPendingCallWatcher *w) { QDBusPendingReply reply = *w; - if (reply.isError()) + 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(); }); } diff --git a/qt/src/InspectorWindow.cpp b/qt/src/InspectorWindow.cpp index d1f33f7a1..25bc3bd3f 100644 --- a/qt/src/InspectorWindow.cpp +++ b/qt/src/InspectorWindow.cpp @@ -1,8 +1,11 @@ #include "InspectorWindow.h" +#include + #include #include #include +#include #include #include #include @@ -55,12 +58,27 @@ InspectorWindow::InspectorWindow(ghostty_surface_t surface) } InspectorWindow::~InspectorWindow() { - if (m_inspector && makeCurrent()) { + // 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; - m_context->doneCurrent(); + } 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; } @@ -129,11 +147,18 @@ 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. + // 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; @@ -175,10 +200,17 @@ void InspectorWindow::keyPressEvent(QKeyEvent *ev) { if (key != GHOSTTY_KEY_UNIDENTIFIED) ghostty_inspector_key(m_inspector, GHOSTTY_ACTION_PRESS, key, translateMods(ev->modifiers())); - // Printable text drives ImGui's text input. + // 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() && static_cast(text.at(0)) >= 0x20) - ghostty_inspector_text(m_inspector, text.constData()); + if (text.isEmpty()) return; + for (char c : text) { + const auto u = static_cast(c); + if (u < 0x20 || u == 0x7f) return; + } + ghostty_inspector_text(m_inspector, text.constData()); } void InspectorWindow::keyReleaseEvent(QKeyEvent *ev) { diff --git a/qt/src/InspectorWindow.h b/qt/src/InspectorWindow.h index 175357cc2..7a53d3a54 100644 --- a/qt/src/InspectorWindow.h +++ b/qt/src/InspectorWindow.h @@ -33,6 +33,9 @@ protected: // 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; diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp index 6f20aa358..8697ef167 100644 --- a/qt/src/MainWindow.cpp +++ b/qt/src/MainWindow.cpp @@ -10,9 +10,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -106,8 +108,8 @@ MainWindow::~MainWindow() { if (s_windows.isEmpty()) { if (s_frameTimer) { // The timer is parented to qApp; stop it so a final tick can't - // fire after s_app is freed below. delete leaves qApp's child - // list to clean up at process exit. + // fire after s_app is freed below. The QObject destructor + // unparents it from qApp. s_frameTimer->stop(); delete s_frameTimer; s_frameTimer = nullptr; @@ -116,6 +118,18 @@ MainWindow::~MainWindow() { delete s_quitTimer; s_quitTimer = nullptr; } + // Drain qApp-targeted MetaCalls posted by worker-thread libghostty + // callbacks (closeAllWindows, refreshChrome, OPEN_URL, postProgress, + // handleQuitTimer, NEW_WINDOW, CONFIG_CHANGE, ...) — these are the + // ones that can still touch s_app/s_config after their original + // window has gone. Lambdas posted to per-window/per-surface + // receivers are auto-cancelled by Qt when those receivers were + // deleted above (qDeleteAll above and the broader Qt object tree + // teardown), so they don't need draining. + // + // sendPostedEvents only drains the named receiver, not its + // children — which is exactly what we want here. + QCoreApplication::sendPostedEvents(qApp, QEvent::MetaCall); if (s_app) { ghostty_app_free(s_app); s_app = nullptr; @@ -414,6 +428,16 @@ void MainWindow::removeSurface(GhosttySurface *surface) { } else if (splitterParent && splitterParent->layout()) { delete splitterParent->layout()->replaceWidget(splitter, sibling); } + // Drop split-zoom stash if any of its widgets is about to die. + // m_zoomSplitter (the splitter the zoomed surface came from) and + // m_zoomRoot (the page's tree root) can both be reached by + // collapsing siblings, leaving the stash dangling for the next + // toggleSplitZoom. + if (m_zoomed && (splitter == m_zoomSplitter || splitter == m_zoomRoot)) { + m_zoomed = nullptr; + m_zoomRoot = nullptr; + m_zoomSplitter = nullptr; + } // Deleting the orphaned splitter also deletes `surface`. splitter->deleteLater(); return; @@ -436,6 +460,13 @@ void MainWindow::closeTab(int index) { if (!page) return; const auto inTab = page->findChildren(); for (GhosttySurface *s : inTab) m_surfaces.removeOne(s); + // If the zoomed surface was in this tab, clear the stash so a later + // toggleSplitZoom doesn't dereference a deleted page tree. + if (m_zoomed && inTab.contains(m_zoomed)) { + m_zoomed = nullptr; + m_zoomRoot = nullptr; + m_zoomSplitter = nullptr; + } m_tabs->removeTab(index); page->deleteLater(); // destroys every surface in the tab if (m_tabs->count() == 0) { @@ -450,7 +481,16 @@ void MainWindow::adoptTab(MainWindow *src, QWidget *page) { // Re-home every surface in the tab — the libghostty surfaces are // unaffected (the app is shared), only the owning window changes. - for (GhosttySurface *s : page->findChildren()) { + const auto adopted = page->findChildren(); + // If the source's zoomed surface lived in this tab, clear src's + // zoom stash before transferring — the stashed root/splitter + // pointers belong to widgets we're about to reparent away. + if (src->m_zoomed && adopted.contains(src->m_zoomed)) { + src->m_zoomed = nullptr; + src->m_zoomRoot = nullptr; + src->m_zoomSplitter = nullptr; + } + for (GhosttySurface *s : adopted) { src->m_surfaces.removeOne(s); if (!m_surfaces.contains(s)) m_surfaces.append(s); s->setOwner(this); @@ -703,7 +743,9 @@ void MainWindow::handleQuitTimer(bool start) { if (s_quitDelayMs <= 0) return; if (start) { if (!s_quitTimer) { - s_quitTimer = new QTimer; + // Parent to qApp for consistency with s_frameTimer; the dtor + // still deletes it explicitly when the last window closes. + s_quitTimer = new QTimer(qApp); s_quitTimer->setSingleShot(true); QObject::connect(s_quitTimer, &QTimer::timeout, qApp, &QApplication::quit); @@ -916,6 +958,10 @@ void MainWindow::playBellAudio() { m_bellPlayer->setAudioOutput(m_bellAudio); } m_bellAudio->setVolume(ok ? volume : 0.5); + // Stop first so a back-to-back bell restarts the clip from the + // beginning. Without this, calling play() on an already-playing + // QMediaPlayer is a no-op and rapid bells get silently swallowed. + m_bellPlayer->stop(); m_bellPlayer->setSource(QUrl::fromLocalFile(path)); m_bellPlayer->play(); } diff --git a/qt/src/SearchBar.cpp b/qt/src/SearchBar.cpp index 07c765921..31998af07 100644 --- a/qt/src/SearchBar.cpp +++ b/qt/src/SearchBar.cpp @@ -80,7 +80,9 @@ SearchBar::SearchBar(GhosttySurface *surface) connect(close, &QToolButton::clicked, this, [this]() { runAction("end_search"); hide(); - m_surface->setFocus(); + // 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(); } @@ -159,7 +161,7 @@ bool SearchBar::eventFilter(QObject *obj, QEvent *event) { case Qt::Key_Escape: runAction("end_search"); hide(); - m_surface->setFocus(); + if (m_surface) m_surface->setFocus(); return true; case Qt::Key_Return: case Qt::Key_Enter: diff --git a/qt/src/TabWidget.cpp b/qt/src/TabWidget.cpp index 6bc27ee54..245d99d9e 100644 --- a/qt/src/TabWidget.cpp +++ b/qt/src/TabWidget.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -12,31 +13,53 @@ #include #include #include +#include namespace { -// MIME role carrying a pointer-to-originating-TabBar so a receiving -// bar's dropEvent can mark the originator's m_dropHandled. We can't -// rely on QDrag::exec()'s return value on Wayland, and a process-wide -// "drop handled" flag races with simultaneous tear-offs in different -// windows. +// 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 &liveTabBars() { + static QSet 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) { - // Pack the raw pointer; the source process owns it for the lifetime - // of the drag. We never dereference unless we're in the same process, - // and a tear-off across processes is meaningless. - QByteArray bytes(reinterpret_cast(&bar), sizeof(bar)); + OriginPayload p{QCoreApplication::applicationPid(), bar}; + QByteArray bytes(reinterpret_cast(&p), sizeof(p)); return bytes; } TabBar *decodeOrigin(const QByteArray &bytes) { - if (bytes.size() != sizeof(TabBar *)) return nullptr; - TabBar *bar; - std::memcpy(&bar, bytes.constData(), sizeof(bar)); - return bar; + 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); +} + +TabBar::~TabBar() { liveTabBars().remove(this); } + void TabBar::mousePressEvent(QMouseEvent *e) { if (e->button() == Qt::LeftButton) { m_pressIndex = tabAt(e->position().toPoint()); @@ -106,8 +129,16 @@ void TabBar::startTearOff(QMouseEvent *e) { // 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 self(this); drag->exec(Qt::MoveAction); + if (!self) return; m_tearing = false; m_pressIndex = -1; diff --git a/qt/src/TabWidget.h b/qt/src/TabWidget.h index a450e987c..f6ddf6c88 100644 --- a/qt/src/TabWidget.h +++ b/qt/src/TabWidget.h @@ -33,7 +33,8 @@ class TabBar : public QTabBar { Q_OBJECT public: - explicit TabBar(QWidget *parent = nullptr) : QTabBar(parent) {} + explicit TabBar(QWidget *parent = nullptr); + ~TabBar() override; signals: // The tab was dragged off and released clear of its window. diff --git a/qt/src/Util.cpp b/qt/src/Util.cpp index 01876d817..2989ed49d 100644 --- a/qt/src/Util.cpp +++ b/qt/src/Util.cpp @@ -3,6 +3,15 @@ #include #include +// 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: diff --git a/qt/src/Util.h b/qt/src/Util.h index 08caee115..baae6b1ea 100644 --- a/qt/src/Util.h +++ b/qt/src/Util.h @@ -41,6 +41,10 @@ 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 inline bool configGet(ghostty_config_t cfg, T *out, const char (&key)[N]) { return cfg && ghostty_config_get(cfg, out, key, N - 1); diff --git a/qt/src/WindowBlur.cpp b/qt/src/WindowBlur.cpp index 5dea0d3ba..63a2a7bf5 100644 --- a/qt/src/WindowBlur.cpp +++ b/qt/src/WindowBlur.cpp @@ -81,6 +81,11 @@ void applyWayland(QWindow *window, bool enabled) { 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); @@ -91,14 +96,16 @@ void applyWayland(QWindow *window, bool enabled) { org_kde_kwin_blur_commit(blur); blurs.insert(window, blur); - // Release the blur object when the window goes away. Without this, - // a closed window leaves its org_kde_kwin_blur leaked and the - // QWindow* key in the hash dangles. - 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); - }); + // 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); } From c66605bd855e66c4e520ab8ca1cb13a8dc3b32bd Mon Sep 17 00:00:00 2001 From: ntomsic Date: Wed, 20 May 2026 15:16:39 -0500 Subject: [PATCH 67/75] =?UTF-8?q?build:=20audit=20fixes=20=E2=80=94=20mult?= =?UTF-8?q?i-arch=20HINT,=20atomic=20g=5Fneeds=5Fdraw,=20CC=20override,=20?= =?UTF-8?q?no-op=20cache=20mount?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - qt/CMakeLists.txt: the multi-arch QPA-header HINT used a hardcoded list of aarch64-linux-gnu / x86_64-linux-gnu paths. Replace with ${CMAKE_LIBRARY_ARCHITECTURE} so armhf / riscv64 / any future Debian multiarch resolves correctly. On non-Debian distros the variable is empty and the HINT degrades to a noisy-but-harmless dead path (the OPTIONAL_COMPONENTS GuiPrivate find_package wins there anyway). - embed-test/main.c: `g_needs_draw` is now `atomic_int`. libghostty action callbacks may run on a worker thread, so the prior plain `int` write-from-worker / read-from-main was a race with no memory barrier. on_action and on_framebuffer_size atomic_store(1); the main loop atomic_exchanges to read-and-clear in one step (a wakeup setting it again between the read and the draw is preserved). - embed-test/build.sh: hardcoded `cc` is now `${CC:-cc}` so a toolchain override is honored. - Dockerfile: dropped the `--mount=type=cache,target=/root/.cache/cmake` on the qt stage. CMake doesn't read that path; the cache mount was doing nothing. Co-Authored-By: claude-flow --- Dockerfile | 3 +-- embed-test/build.sh | 2 +- embed-test/main.c | 16 ++++++++++------ qt/CMakeLists.txt | 7 +++++-- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index b090a5030..c5d0106c1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -119,8 +119,7 @@ WORKDIR /src/qt # 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 --mount=type=cache,target=/root/.cache/cmake \ - set -eux; \ +RUN set -eux; \ cmake -S /src/qt -B /src/qt/build -G Ninja \ -DCMAKE_BUILD_TYPE=Release; \ cmake --build /src/qt/build --parallel "$(nproc)"; \ diff --git a/embed-test/build.sh b/embed-test/build.sh index 34059d63a..ec3782806 100755 --- a/embed-test/build.sh +++ b/embed-test/build.sh @@ -16,7 +16,7 @@ if [[ ! -f "$lib" ]]; then exit 1 fi -cc -std=c11 -Wall -Wextra -g \ +"${CC:-cc}" -std=c11 -Wall -Wextra -g \ -o "$here/harness" \ "$here/main.c" \ -I "$root/include" \ diff --git a/embed-test/main.c b/embed-test/main.c index 84477822e..3cb8a2339 100644 --- a/embed-test/main.c +++ b/embed-test/main.c @@ -27,7 +27,10 @@ typedef struct { static ghostty_surface_t g_surface = NULL; // Set when libghostty asks for a redraw; the main loop then draws. -static int g_needs_draw = 1; +// 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. @@ -74,7 +77,7 @@ static bool on_action(ghostty_app_t app, ghostty_target_s 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) { - g_needs_draw = 1; + atomic_store(&g_needs_draw, 1); return true; } return false; @@ -143,7 +146,7 @@ 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); - g_needs_draw = 1; + atomic_store(&g_needs_draw, 1); } } @@ -252,9 +255,10 @@ int main(int argc, char **argv) { } // libghostty requested a draw (via the render action); service it - // on this thread. - if (g_needs_draw) { - g_needs_draw = 0; + // 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); } diff --git a/qt/CMakeLists.txt b/qt/CMakeLists.txt index 451182052..9363cfb73 100644 --- a/qt/CMakeLists.txt +++ b/qt/CMakeLists.txt @@ -141,12 +141,15 @@ else() # parent dir as a private include (the header lives at # /QtGui//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/aarch64-linux-gnu/qt6/QtGui/${Qt6_VERSION}/QtGui" - "${Qt6_DIR}/../../../include/x86_64-linux-gnu/qt6/QtGui/${Qt6_VERSION}/QtGui" + "${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" From b7f769418e7d4aa096ea61ed1ceec89f1991c9fc Mon Sep 17 00:00:00 2001 From: ntomsic Date: Wed, 20 May 2026 16:08:23 -0500 Subject: [PATCH 68/75] =?UTF-8?q?qt:=20redesign=20app=20icon=20=E2=80=94?= =?UTF-8?q?=20Ghast=20on=20a=20CRT,=20in=20Ghastty=20colors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five rounds of redesign in response to small-size legibility and visual-identity feedback: - Drops the original glitch/corrupted-ghost design. At 16-32 px the scanlines, dead pixels, and RGB-split fringes aliased into mottled noise rather than reading as a glitch effect; the silhouette itself was a generic ghost shape with no Ghastty-specific cues. - The new mark is a Minecraft-inspired Ghast: cubic head with nine 3x3 dangling tentacles (center longest), warm bone-paper body shading, sized to fit inside a CRT screen rather than dominating the canvas. The cubic silhouette differentiates from a generic ghost; the 3x3-uneven tentacle rhythm avoids reading as a cartoon-skull's lower jaw, which the prior 6-equal-tentacle variant had as a strong unintended read. - The face follows upstream Ghostty's mascot vocabulary: a `>_` prompt — wide-set chevron and block cursor as the two eyes — plus a small red mouth below. The `>_` in red gives Ghastty its single bright accent (matching the dark-canvas + one-bright-color pattern of every legible terminal icon: Alacritty, Hyper, WezTerm, Konsole) and inherits the upstream lineage so this reads as a fork of Ghostty rather than an unrelated app. - The composition is layered like upstream Ghostty's app icon, in Ghastty's palette: dark purple chrome bezel with a top sheen, deep purple-navy CRT screen, soft violet bloom near the top, sparse violet scanlines (28-px spacing on a 1024 source so they survive the 256-px and 128-px buckets), top gloss sweep, then the Ghast inside. - Renders cleanly at every freedesktop bucket (16, 22, 24, 32, 48, 64, 128, 256, 512). At small sizes the bezel + screen + Ghast silhouette + red prompt anchor the read; at large sizes the full layered scene resolves. - Authored for QtSvg's supported subset (no clipPath / mask / filter / blend modes). The icon ships embedded as a Qt resource and installed into hicolor/scalable/apps. Co-Authored-By: claude-flow --- qt/dist/ghastty.svg | 441 +++++++++++++++++++++++++++++--------------- 1 file changed, 291 insertions(+), 150 deletions(-) diff --git a/qt/dist/ghastty.svg b/qt/dist/ghastty.svg index 40678d8cc..2090f0621 100644 --- a/qt/dist/ghastty.svg +++ b/qt/dist/ghastty.svg @@ -1,177 +1,318 @@ - + + - - - - - - - - - - - - - - - + + + - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - + + + + + - - + + + + + - - - - - - + + + + + + + + + + + + + + + + + + - - - + + - - - + + - - - - + + + - - - + + - - - - - - + - - - - - - - - - - - - - + 28 px spacing on a 1024 source. At 256 px each line is ~7 px + apart with 1 px line height — visible. At 64 px they alias + into a faint horizontal texture. At 32 px they vanish into a + slight tonal shift, which is fine. Opacity is intentionally + low so they read as atmosphere, not as a stripe pattern. + ============================================================ --> + + + + + + + + + + + + + + + + + + + + + - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - + - - - - - + Sized to fit inside the screen with breathing room: 580 wide, + 560 tall, centered horizontally. The whole figure (head + + tentacles) lives between y=260 and y=908, which leaves about + 170 px of screen below the bezel ring — enough for the CRT + to feel like a screen, not a frame. - - - + Tentacles are 9-in-3x3 with center longest; same structural + choice as before so the Ghast reads correctly. + ============================================================ --> + - - + + - - - - + + + + + + + + + + + + + + + + + + + From 15b2b060e7c3156050b8ec884ca2fc281d5f999c Mon Sep 17 00:00:00 2001 From: ntomsic Date: Wed, 20 May 2026 16:25:34 -0500 Subject: [PATCH 69/75] qt: keep the resize overlay visible across continuous resizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resize overlay's hide-timer was only restarted on grid-size *steps* — i.e. when the column or row count changed by at least one cell. During a slow window drag the grid only steps every few hundred milliseconds (each time a pixel resize crosses a cell boundary), so the overlay would appear once, the 750ms timer would fire while the user was still dragging, and the overlay would vanish — flashing on every grid step rather than staying visible until the resize ended. Restart the timer on every resizeEvent and only refresh the overlay's text/visibility when the grid actually changes. The position is also re-anchored on every event so the centered placement tracks the new widget size during the drag. Co-Authored-By: claude-flow --- qt/src/GhosttySurface.cpp | 80 +++++++++++++++++++++++++-------------- qt/src/GhosttySurface.h | 1 + 2 files changed, 53 insertions(+), 28 deletions(-) diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index 909810813..a1025de0a 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -418,38 +418,16 @@ void GhosttySurface::layoutSearchBar() { void GhosttySurface::showResizeOverlay() { if (!m_surface || !m_owner) return; const ghostty_surface_size_s sz = ghostty_surface_size(m_surface); - // Only a grid-size change is a "resize" worth announcing. - if (sz.columns == m_lastCols && sz.rows == m_lastRows) return; - m_lastCols = sz.columns; - m_lastRows = sz.rows; - ghostty_config_t cfg = m_owner->config(); const QString mode = cfgString(cfg, "resize-overlay"); - const bool first = !m_firstGridSeen; - m_firstGridSeen = true; if (mode == QLatin1String("never")) return; - if (mode == QLatin1String("after-first") && first) return; - - if (!m_resizeOverlay) m_resizeOverlay = makeOverlayLabel(this); - m_resizeOverlay->setText( - QStringLiteral("%1 × %2").arg(sz.columns).arg(sz.rows)); - m_resizeOverlay->adjustSize(); - - // resize-overlay-position: center / {top,bottom}-{left,center,right}. - const QString pos = cfgString(cfg, "resize-overlay-position"); - const int m = 8; - int x = (width() - m_resizeOverlay->width()) / 2; - int y = (height() - m_resizeOverlay->height()) / 2; - if (pos.contains(QLatin1String("left"))) x = m; - else if (pos.contains(QLatin1String("right"))) - x = width() - m_resizeOverlay->width() - m; - if (pos.contains(QLatin1String("top"))) y = m; - else if (pos.contains(QLatin1String("bottom"))) - y = height() - m_resizeOverlay->height() - m; - m_resizeOverlay->move(x, y); - m_resizeOverlay->show(); - m_resizeOverlay->raise(); + // Reset the hide timer on EVERY resize event, not just on grid-size + // boundaries. Without this, slow window drags only triggered the + // overlay when the grid happened to step (e.g. crossing a cell + // height), so the overlay flashed for ~750ms then disappeared even + // though the user was still dragging. Reading the duration here + // also picks up any config reload during a resize. unsigned long long durNs = 0; configGet(cfg, &durNs, "resize-overlay-duration"); const int durMs = durNs ? static_cast(durNs / 1000000ULL) : 750; @@ -461,6 +439,52 @@ void GhosttySurface::showResizeOverlay() { }); } m_resizeHideTimer->start(durMs); + + // The overlay TEXT only changes when the grid steps. Suppress the + // "after-first" mode's first-show, then track each grid step. + const bool gridChanged = + sz.columns != m_lastCols || sz.rows != m_lastRows; + if (gridChanged) { + const bool first = !m_firstGridSeen; + m_lastCols = sz.columns; + m_lastRows = sz.rows; + m_firstGridSeen = true; + if (mode == QLatin1String("after-first") && first) return; + } else if (m_resizeOverlay && m_resizeOverlay->isVisible()) { + // Overlay already visible with the right text; just reposition + // for the new widget size and we're done. + if (!m_resizeOverlay) return; + repositionResizeOverlay(); + return; + } else if (!m_firstGridSeen) { + // Haven't seen a grid step yet AND no overlay is currently up. + // Don't show stale 0×0 text. + return; + } + + if (!m_resizeOverlay) m_resizeOverlay = makeOverlayLabel(this); + m_resizeOverlay->setText( + QStringLiteral("%1 × %2").arg(sz.columns).arg(sz.rows)); + m_resizeOverlay->adjustSize(); + repositionResizeOverlay(); + m_resizeOverlay->show(); + m_resizeOverlay->raise(); +} + +void GhosttySurface::repositionResizeOverlay() { + if (!m_resizeOverlay || !m_owner) return; + ghostty_config_t cfg = m_owner->config(); + const QString pos = cfgString(cfg, "resize-overlay-position"); + const int m = 8; + int x = (width() - m_resizeOverlay->width()) / 2; + int y = (height() - m_resizeOverlay->height()) / 2; + if (pos.contains(QLatin1String("left"))) x = m; + else if (pos.contains(QLatin1String("right"))) + x = width() - m_resizeOverlay->width() - m; + if (pos.contains(QLatin1String("top"))) y = m; + else if (pos.contains(QLatin1String("bottom"))) + y = height() - m_resizeOverlay->height() - m; + m_resizeOverlay->move(x, y); } void GhosttySurface::showChildExited(int exitCode) { diff --git a/qt/src/GhosttySurface.h b/qt/src/GhosttySurface.h index 6e1cf9506..256d388f6 100644 --- a/qt/src/GhosttySurface.h +++ b/qt/src/GhosttySurface.h @@ -139,6 +139,7 @@ private: void flashScrollbar(); // reveal the overlay scrollbar, arm hide void buildExitOverlay(int exitCode); void showResizeOverlay(); // transient grid-size overlay on resize + void repositionResizeOverlay(); // re-place overlay for current widget size void layoutSearchBar(); // position the search bar at the top edge void sendKey(QKeyEvent *, ghostty_input_action_e action); void commitText(const QString &text); From af567feb83d77b53ccb6e49bd38f148df21123ea Mon Sep 17 00:00:00 2001 From: ntomsic Date: Wed, 20 May 2026 16:45:53 -0500 Subject: [PATCH 70/75] qt: paint the resize overlay directly, not as a child QLabel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier "extend the timer on every resizeEvent" patch did not actually fix the flash, because the flash wasn't a timer issue — it was a paint-ordering race between the parent surface and the child QLabel. GhosttySurface::paintEvent draws the terminal FBO with QPainter::CompositionMode_Source, which fully replaces destination pixels in the painted rect. With a child QLabel sitting on top, Qt composites the child's paint into the same backing store, but only after the child's own paintEvent runs — and during a continuous resize the parent's frequent update() calls outpaced the child's paint, so the backing store was flushed once with the parent's blit already done but the child not yet repainted. That single-frame gap is the visible flash. Fix the root cause: drop the child QLabel entirely and paint the "cols × rows" plate inside GhosttySurface::paintEvent itself, in the same QPainter pass as the terminal blit. Now the overlay is atomic with the surface beneath it — no child-widget timing race possible. State is reduced to (m_resizeOverlayText, m_resizeOverlayUntilMs): showResizeOverlay updates both, calls update(); paintEvent calls paintResizeOverlay() which draws the plate when current time is before the deadline. A QTimer schedules a single update() at the deadline so the overlay disappears even if no further resize events arrive. cfgString is forward-declared because paintResizeOverlay uses it before its definition further down in the file. Co-Authored-By: claude-flow --- qt/src/GhosttySurface.cpp | 129 +++++++++++++++++++++++--------------- qt/src/GhosttySurface.h | 14 ++++- 2 files changed, 89 insertions(+), 54 deletions(-) diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index a1025de0a..818e9324f 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -14,9 +14,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -308,6 +310,52 @@ void GhosttySurface::paintEvent(QPaintEvent *) { painter.setBrush(Qt::NoBrush); painter.drawRect(QRectF(rect()).adjusted(1.5, 1.5, -1.5, -1.5)); } + + // Resize overlay (rendered here, not as a child widget, so it + // can't race the Source-mode blit above mid-resize). + paintResizeOverlay(painter); +} + +// Forward decl: cfgString is defined further down (alongside other +// per-config-key helpers). We need it here for the resize-overlay +// paint path. +static QString cfgString(ghostty_config_t cfg, const char *key); + +void GhosttySurface::paintResizeOverlay(QPainter &painter) { + if (m_resizeOverlayText.isEmpty()) return; + if (QDateTime::currentMSecsSinceEpoch() >= m_resizeOverlayUntilMs) return; + if (!m_owner) return; + + ghostty_config_t cfg = m_owner->config(); + const QString posCfg = cfgString(cfg, "resize-overlay-position"); + + // Layout the text in a rounded-rect plate, sized from the text's + // bounding rect plus padding. + painter.setCompositionMode(QPainter::CompositionMode_SourceOver); + QFont f = painter.font(); + f.setPixelSize(13); + painter.setFont(f); + const QFontMetrics fm(f); + const QRect textRect = fm.boundingRect(m_resizeOverlayText); + const int padX = 10, padY = 4; + const int w = textRect.width() + padX * 2; + const int h = textRect.height() + padY * 2; + + const int m = 8; + int x = (width() - w) / 2; + int y = (height() - h) / 2; + if (posCfg.contains(QLatin1String("left"))) x = m; + else if (posCfg.contains(QLatin1String("right"))) x = width() - w - m; + if (posCfg.contains(QLatin1String("top"))) y = m; + else if (posCfg.contains(QLatin1String("bottom"))) y = height() - h - m; + + const QRectF plate(x, y, w, h); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(0, 0, 0, 191)); // 0.75 alpha + painter.drawRoundedRect(plate, 4, 4); + painter.setPen(QColor(0xf0, 0xf0, 0xf0)); + painter.drawText(plate, Qt::AlignCenter, m_resizeOverlayText); } void GhosttySurface::flashBorder() { @@ -422,26 +470,8 @@ void GhosttySurface::showResizeOverlay() { const QString mode = cfgString(cfg, "resize-overlay"); if (mode == QLatin1String("never")) return; - // Reset the hide timer on EVERY resize event, not just on grid-size - // boundaries. Without this, slow window drags only triggered the - // overlay when the grid happened to step (e.g. crossing a cell - // height), so the overlay flashed for ~750ms then disappeared even - // though the user was still dragging. Reading the duration here - // also picks up any config reload during a resize. - unsigned long long durNs = 0; - configGet(cfg, &durNs, "resize-overlay-duration"); - const int durMs = durNs ? static_cast(durNs / 1000000ULL) : 750; - if (!m_resizeHideTimer) { - m_resizeHideTimer = new QTimer(this); - m_resizeHideTimer->setSingleShot(true); - connect(m_resizeHideTimer, &QTimer::timeout, this, [this]() { - if (m_resizeOverlay) m_resizeOverlay->hide(); - }); - } - m_resizeHideTimer->start(durMs); - - // The overlay TEXT only changes when the grid steps. Suppress the - // "after-first" mode's first-show, then track each grid step. + // The "after-first" mode hides the overlay until the grid has + // stepped at least once after the surface was created. const bool gridChanged = sz.columns != m_lastCols || sz.rows != m_lastRows; if (gridChanged) { @@ -449,42 +479,39 @@ void GhosttySurface::showResizeOverlay() { m_lastCols = sz.columns; m_lastRows = sz.rows; m_firstGridSeen = true; + m_resizeOverlayText = + QStringLiteral("%1 × %2").arg(sz.columns).arg(sz.rows); if (mode == QLatin1String("after-first") && first) return; - } else if (m_resizeOverlay && m_resizeOverlay->isVisible()) { - // Overlay already visible with the right text; just reposition - // for the new widget size and we're done. - if (!m_resizeOverlay) return; - repositionResizeOverlay(); - return; - } else if (!m_firstGridSeen) { - // Haven't seen a grid step yet AND no overlay is currently up. - // Don't show stale 0×0 text. + } else if (m_resizeOverlayText.isEmpty()) { + // No grid step has happened yet AND no overlay text is cached — + // nothing to display. return; } - if (!m_resizeOverlay) m_resizeOverlay = makeOverlayLabel(this); - m_resizeOverlay->setText( - QStringLiteral("%1 × %2").arg(sz.columns).arg(sz.rows)); - m_resizeOverlay->adjustSize(); - repositionResizeOverlay(); - m_resizeOverlay->show(); - m_resizeOverlay->raise(); -} + // Push the hide deadline forward on every resizeEvent so the + // overlay stays visible until the user actually stops resizing. + // Without this, slow drags between cell-boundary crossings would + // let the timer fire mid-drag and flash the overlay off-on-off. + unsigned long long durNs = 0; + configGet(cfg, &durNs, "resize-overlay-duration"); + const int durMs = durNs ? static_cast(durNs / 1000000ULL) : 750; + m_resizeOverlayUntilMs = + QDateTime::currentMSecsSinceEpoch() + durMs; -void GhosttySurface::repositionResizeOverlay() { - if (!m_resizeOverlay || !m_owner) return; - ghostty_config_t cfg = m_owner->config(); - const QString pos = cfgString(cfg, "resize-overlay-position"); - const int m = 8; - int x = (width() - m_resizeOverlay->width()) / 2; - int y = (height() - m_resizeOverlay->height()) / 2; - if (pos.contains(QLatin1String("left"))) x = m; - else if (pos.contains(QLatin1String("right"))) - x = width() - m_resizeOverlay->width() - m; - if (pos.contains(QLatin1String("top"))) y = m; - else if (pos.contains(QLatin1String("bottom"))) - y = height() - m_resizeOverlay->height() - m; - m_resizeOverlay->move(x, y); + // Schedule a paint at the deadline so the overlay disappears even + // when no further resize events are arriving. + if (!m_resizeHideTimer) { + m_resizeHideTimer = new QTimer(this); + m_resizeHideTimer->setSingleShot(true); + connect(m_resizeHideTimer, &QTimer::timeout, this, + [this]() { update(); }); + } + m_resizeHideTimer->start(durMs); + + // Repaint now so the overlay appears (or its text updates) on the + // next frame. paintEvent reads m_resizeOverlayText and the + // deadline; nothing else changes about the surface contents. + update(); } void GhosttySurface::showChildExited(int exitCode) { diff --git a/qt/src/GhosttySurface.h b/qt/src/GhosttySurface.h index 256d388f6..740d87b0d 100644 --- a/qt/src/GhosttySurface.h +++ b/qt/src/GhosttySurface.h @@ -25,6 +25,7 @@ class QOpenGLContext; class QOpenGLFramebufferObject; class QOpenGLShaderProgram; class QOpenGLVertexArrayObject; +class QPainter; class OverlayScrollbar; // One Ghostty terminal pane. @@ -139,7 +140,10 @@ private: void flashScrollbar(); // reveal the overlay scrollbar, arm hide void buildExitOverlay(int exitCode); void showResizeOverlay(); // transient grid-size overlay on resize - void repositionResizeOverlay(); // re-place overlay for current widget size + // Paint the resize overlay (if visible) directly via the parent's + // QPainter — done inside paintEvent so the overlay is atomic with + // the terminal blit beneath it. + void paintResizeOverlay(QPainter &painter); void layoutSearchBar(); // position the search bar at the top edge void sendKey(QKeyEvent *, ghostty_input_action_e action); void commitText(const QString &text); @@ -183,8 +187,12 @@ private: 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 + // Resize overlay is painted directly inside paintEvent (not a child + // QLabel) so it can't race the parent's CompositionMode_Source blit + // mid-resize. Deadline-based: visible while now < m_resizeOverlayUntil. + QString m_resizeOverlayText; + qint64 m_resizeOverlayUntilMs = 0; // monotonic ms since epoch + QTimer *m_resizeHideTimer = nullptr; // schedules a paint at hide-time bool m_firstGridSeen = false; // for `resize-overlay = after-first` int m_lastCols = 0; // last grid size, to detect changes int m_lastRows = 0; From 56918dc3db6700428c036e3484e33ff2fbc67dd5 Mon Sep 17 00:00:00 2001 From: ntomsic Date: Wed, 20 May 2026 17:00:01 -0500 Subject: [PATCH 71/75] qt: fix resize-overlay first-grid logic so it actually shows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous rewrite suppressed the first grid step under 'after-first' mode, but conflated 'first' with 'first grid step' instead of 'initial size at surface creation.' Net effect: short window resizes that crossed exactly one grid boundary never showed the overlay because the only grid step they produced was treated as the suppressed first step. Restructure: m_firstGridSeen now tracks the very first showResizeOverlay call (the implicit layout pass before the user has touched anything), records that initial size, and bails under 'after-first'. Every subsequent call — whether the grid steps or not — caches the current "cols × rows" text, pushes the deadline, and triggers update(). The user-visible behaviour: - resize-overlay = always: overlay shows on every resize event, starting at surface layout. - resize-overlay = after-first (default): overlay is suppressed on the surface's first layout but shows on every later resize. - resize-overlay = never: never shows. Also drop the m_image.isNull() early-return at the top of paintEvent so the overlay can still draw before the first FBO render lands. Co-Authored-By: claude-flow --- qt/src/GhosttySurface.cpp | 48 ++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index 818e9324f..f9b956d44 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -275,16 +275,15 @@ void GhosttySurface::renderTerminal() { } void GhosttySurface::paintEvent(QPaintEvent *) { - if (m_image.isNull()) return; QPainter painter(this); - // Blit the framebuffer 1:1. m_image carries the device pixel ratio, so - // the QPointF overload draws it at its true logical size: when in sync - // that exactly fills the widget, and mid-resize the content keeps its - // real size instead of stretching to the (already-resized) widget. - // CompositionMode_Source replaces the transparent widget pixels with - // the terminal image, alpha included, so its translucency is kept. - painter.setCompositionMode(QPainter::CompositionMode_Source); - painter.drawImage(QPointF(0, 0), m_image); + // Blit the framebuffer 1:1 if we have one. m_image carries the + // device pixel ratio, so the QPointF overload draws it at its true + // logical size. CompositionMode_Source replaces the transparent + // widget pixels with the terminal image (alpha included). + if (!m_image.isNull()) { + painter.setCompositionMode(QPainter::CompositionMode_Source); + painter.drawImage(QPointF(0, 0), m_image); + } // Unfocused-split dimming: a translucent fill over an inactive pane. // Only split panes (a QSplitter parent) are dimmed, matching GTK. @@ -470,24 +469,27 @@ void GhosttySurface::showResizeOverlay() { const QString mode = cfgString(cfg, "resize-overlay"); if (mode == QLatin1String("never")) return; - // The "after-first" mode hides the overlay until the grid has - // stepped at least once after the surface was created. - const bool gridChanged = - sz.columns != m_lastCols || sz.rows != m_lastRows; - if (gridChanged) { - const bool first = !m_firstGridSeen; + // First-call short-circuit. resizeEvent fires once when the + // surface is first laid out, before the user has done any + // resizing. Per `resize-overlay = after-first` (the default), + // we suppress that initial show — record the grid size and + // bail. Subsequent resizes always flow through. + if (!m_firstGridSeen) { + m_firstGridSeen = true; m_lastCols = sz.columns; m_lastRows = sz.rows; - m_firstGridSeen = true; - m_resizeOverlayText = - QStringLiteral("%1 × %2").arg(sz.columns).arg(sz.rows); - if (mode == QLatin1String("after-first") && first) return; - } else if (m_resizeOverlayText.isEmpty()) { - // No grid step has happened yet AND no overlay text is cached — - // nothing to display. - return; + if (mode == QLatin1String("after-first")) return; + // mode == "always": fall through and show the initial size too. } + // Update the cached text whenever the grid actually steps. + if (sz.columns != m_lastCols || sz.rows != m_lastRows) { + m_lastCols = sz.columns; + m_lastRows = sz.rows; + } + m_resizeOverlayText = + QStringLiteral("%1 × %2").arg(sz.columns).arg(sz.rows); + // Push the hide deadline forward on every resizeEvent so the // overlay stays visible until the user actually stops resizing. // Without this, slow drags between cell-boundary crossings would From 7cffdcb0a9da376b517858d99d88c2e04cc42bdb Mon Sep 17 00:00:00 2001 From: ntomsic Date: Wed, 20 May 2026 17:08:19 -0500 Subject: [PATCH 72/75] qt: temporary stderr diagnostics for the resize-overlay path Two fprintf calls so we can see what's happening on the user's machine when they resize: - showResizeOverlay logs the mode, current grid size, cached last- cols/rows, and whether we've registered the first-grid-seen flag. - paintResizeOverlay logs the cached text, the current and deadline timestamps, and the m_owner pointer. Both write to stderr so they appear when ghastty is launched from a terminal. The lines are prefixed [ghastty/dbg] for easy grepping; the diagnostic will be reverted once the rendering path is fixed. Co-Authored-By: claude-flow --- qt/src/GhosttySurface.cpp | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index f9b956d44..6a5ccba77 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -321,8 +321,16 @@ void GhosttySurface::paintEvent(QPaintEvent *) { static QString cfgString(ghostty_config_t cfg, const char *key); void GhosttySurface::paintResizeOverlay(QPainter &painter) { + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + std::fprintf(stderr, + "[ghastty/dbg] paintResizeOverlay: text='%s' nowMs=%lld " + "untilMs=%lld owner=%p\n", + m_resizeOverlayText.toUtf8().constData(), + static_cast(now), + static_cast(m_resizeOverlayUntilMs), + static_cast(m_owner)); if (m_resizeOverlayText.isEmpty()) return; - if (QDateTime::currentMSecsSinceEpoch() >= m_resizeOverlayUntilMs) return; + if (now >= m_resizeOverlayUntilMs) return; if (!m_owner) return; ghostty_config_t cfg = m_owner->config(); @@ -467,6 +475,11 @@ void GhosttySurface::showResizeOverlay() { const ghostty_surface_size_s sz = ghostty_surface_size(m_surface); ghostty_config_t cfg = m_owner->config(); const QString mode = cfgString(cfg, "resize-overlay"); + std::fprintf(stderr, + "[ghastty/dbg] showResizeOverlay: mode=%s grid=%ux%u " + "lastCols=%d lastRows=%d firstSeen=%d\n", + mode.toUtf8().constData(), sz.columns, sz.rows, + m_lastCols, m_lastRows, m_firstGridSeen ? 1 : 0); if (mode == QLatin1String("never")) return; // First-call short-circuit. resizeEvent fires once when the From 58142f3ffbda4d56d781e14143b9f0c43379e300 Mon Sep 17 00:00:00 2001 From: ntomsic Date: Wed, 20 May 2026 17:20:09 -0500 Subject: [PATCH 73/75] Revert "qt: keep the resize overlay visible across continuous resizes" Revert "qt: paint the resize overlay directly, not as a child QLabel" Revert "qt: fix resize-overlay first-grid logic so it actually shows" Revert "qt: temporary stderr diagnostics for the resize-overlay path" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roll the resize-overlay rendering back to the implementation at b7f769418 (the icon-redesign commit). The four follow-up attempts to fix the reported "quickly flashes" bug each made it worse: - 15b2b060e: timer-only fix, didn't help. - af567feb8: switched from a child QLabel to direct paintEvent rendering. Overlay never drew (paintResizeOverlay was reached but nothing showed visibly — paint state / clip / composition issue that wasn't worth diagnosing further on a path the user said had been working before). - 56918dc3d: tweaked first-grid logic; still not visible. - 7cffdcb0a: stderr diagnostics, no behavior change. Net result: resize overlay is back to the pre-flash-report behavior. The original flash bug remains; we'll revisit with a different strategy when ready. This reverts commits 7cffdcb0a, 56918dc3d, af567feb8, and 15b2b060e in one squashed roll-back commit (the four attempts touched the same two files heavily — separate revert commits would create more noise than the consolidated rollback). Co-Authored-By: claude-flow --- qt/src/GhosttySurface.cpp | 142 ++++++++++---------------------------- qt/src/GhosttySurface.h | 13 +--- 2 files changed, 40 insertions(+), 115 deletions(-) diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index 6a5ccba77..909810813 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -14,11 +14,9 @@ #include #include #include -#include #include #include #include -#include #include #include #include @@ -275,15 +273,16 @@ void GhosttySurface::renderTerminal() { } void GhosttySurface::paintEvent(QPaintEvent *) { + if (m_image.isNull()) return; QPainter painter(this); - // Blit the framebuffer 1:1 if we have one. m_image carries the - // device pixel ratio, so the QPointF overload draws it at its true - // logical size. CompositionMode_Source replaces the transparent - // widget pixels with the terminal image (alpha included). - if (!m_image.isNull()) { - painter.setCompositionMode(QPainter::CompositionMode_Source); - painter.drawImage(QPointF(0, 0), m_image); - } + // Blit the framebuffer 1:1. m_image carries the device pixel ratio, so + // the QPointF overload draws it at its true logical size: when in sync + // that exactly fills the widget, and mid-resize the content keeps its + // real size instead of stretching to the (already-resized) widget. + // CompositionMode_Source replaces the transparent widget pixels with + // the terminal image, alpha included, so its translucency is kept. + painter.setCompositionMode(QPainter::CompositionMode_Source); + painter.drawImage(QPointF(0, 0), m_image); // Unfocused-split dimming: a translucent fill over an inactive pane. // Only split panes (a QSplitter parent) are dimmed, matching GTK. @@ -309,60 +308,6 @@ void GhosttySurface::paintEvent(QPaintEvent *) { painter.setBrush(Qt::NoBrush); painter.drawRect(QRectF(rect()).adjusted(1.5, 1.5, -1.5, -1.5)); } - - // Resize overlay (rendered here, not as a child widget, so it - // can't race the Source-mode blit above mid-resize). - paintResizeOverlay(painter); -} - -// Forward decl: cfgString is defined further down (alongside other -// per-config-key helpers). We need it here for the resize-overlay -// paint path. -static QString cfgString(ghostty_config_t cfg, const char *key); - -void GhosttySurface::paintResizeOverlay(QPainter &painter) { - const qint64 now = QDateTime::currentMSecsSinceEpoch(); - std::fprintf(stderr, - "[ghastty/dbg] paintResizeOverlay: text='%s' nowMs=%lld " - "untilMs=%lld owner=%p\n", - m_resizeOverlayText.toUtf8().constData(), - static_cast(now), - static_cast(m_resizeOverlayUntilMs), - static_cast(m_owner)); - if (m_resizeOverlayText.isEmpty()) return; - if (now >= m_resizeOverlayUntilMs) return; - if (!m_owner) return; - - ghostty_config_t cfg = m_owner->config(); - const QString posCfg = cfgString(cfg, "resize-overlay-position"); - - // Layout the text in a rounded-rect plate, sized from the text's - // bounding rect plus padding. - painter.setCompositionMode(QPainter::CompositionMode_SourceOver); - QFont f = painter.font(); - f.setPixelSize(13); - painter.setFont(f); - const QFontMetrics fm(f); - const QRect textRect = fm.boundingRect(m_resizeOverlayText); - const int padX = 10, padY = 4; - const int w = textRect.width() + padX * 2; - const int h = textRect.height() + padY * 2; - - const int m = 8; - int x = (width() - w) / 2; - int y = (height() - h) / 2; - if (posCfg.contains(QLatin1String("left"))) x = m; - else if (posCfg.contains(QLatin1String("right"))) x = width() - w - m; - if (posCfg.contains(QLatin1String("top"))) y = m; - else if (posCfg.contains(QLatin1String("bottom"))) y = height() - h - m; - - const QRectF plate(x, y, w, h); - painter.setRenderHint(QPainter::Antialiasing, true); - painter.setPen(Qt::NoPen); - painter.setBrush(QColor(0, 0, 0, 191)); // 0.75 alpha - painter.drawRoundedRect(plate, 4, 4); - painter.setPen(QColor(0xf0, 0xf0, 0xf0)); - painter.drawText(plate, Qt::AlignCenter, m_resizeOverlayText); } void GhosttySurface::flashBorder() { @@ -473,60 +418,49 @@ void GhosttySurface::layoutSearchBar() { void GhosttySurface::showResizeOverlay() { if (!m_surface || !m_owner) return; const ghostty_surface_size_s sz = ghostty_surface_size(m_surface); + // Only a grid-size change is a "resize" worth announcing. + if (sz.columns == m_lastCols && sz.rows == m_lastRows) return; + m_lastCols = sz.columns; + m_lastRows = sz.rows; + ghostty_config_t cfg = m_owner->config(); const QString mode = cfgString(cfg, "resize-overlay"); - std::fprintf(stderr, - "[ghastty/dbg] showResizeOverlay: mode=%s grid=%ux%u " - "lastCols=%d lastRows=%d firstSeen=%d\n", - mode.toUtf8().constData(), sz.columns, sz.rows, - m_lastCols, m_lastRows, m_firstGridSeen ? 1 : 0); + const bool first = !m_firstGridSeen; + m_firstGridSeen = true; if (mode == QLatin1String("never")) return; + if (mode == QLatin1String("after-first") && first) return; - // First-call short-circuit. resizeEvent fires once when the - // surface is first laid out, before the user has done any - // resizing. Per `resize-overlay = after-first` (the default), - // we suppress that initial show — record the grid size and - // bail. Subsequent resizes always flow through. - if (!m_firstGridSeen) { - m_firstGridSeen = true; - m_lastCols = sz.columns; - m_lastRows = sz.rows; - if (mode == QLatin1String("after-first")) return; - // mode == "always": fall through and show the initial size too. - } + if (!m_resizeOverlay) m_resizeOverlay = makeOverlayLabel(this); + m_resizeOverlay->setText( + QStringLiteral("%1 × %2").arg(sz.columns).arg(sz.rows)); + m_resizeOverlay->adjustSize(); - // Update the cached text whenever the grid actually steps. - if (sz.columns != m_lastCols || sz.rows != m_lastRows) { - m_lastCols = sz.columns; - m_lastRows = sz.rows; - } - m_resizeOverlayText = - QStringLiteral("%1 × %2").arg(sz.columns).arg(sz.rows); + // resize-overlay-position: center / {top,bottom}-{left,center,right}. + const QString pos = cfgString(cfg, "resize-overlay-position"); + const int m = 8; + int x = (width() - m_resizeOverlay->width()) / 2; + int y = (height() - m_resizeOverlay->height()) / 2; + if (pos.contains(QLatin1String("left"))) x = m; + else if (pos.contains(QLatin1String("right"))) + x = width() - m_resizeOverlay->width() - m; + if (pos.contains(QLatin1String("top"))) y = m; + else if (pos.contains(QLatin1String("bottom"))) + y = height() - m_resizeOverlay->height() - m; + m_resizeOverlay->move(x, y); + m_resizeOverlay->show(); + m_resizeOverlay->raise(); - // Push the hide deadline forward on every resizeEvent so the - // overlay stays visible until the user actually stops resizing. - // Without this, slow drags between cell-boundary crossings would - // let the timer fire mid-drag and flash the overlay off-on-off. unsigned long long durNs = 0; configGet(cfg, &durNs, "resize-overlay-duration"); const int durMs = durNs ? static_cast(durNs / 1000000ULL) : 750; - m_resizeOverlayUntilMs = - QDateTime::currentMSecsSinceEpoch() + durMs; - - // Schedule a paint at the deadline so the overlay disappears even - // when no further resize events are arriving. if (!m_resizeHideTimer) { m_resizeHideTimer = new QTimer(this); m_resizeHideTimer->setSingleShot(true); - connect(m_resizeHideTimer, &QTimer::timeout, this, - [this]() { update(); }); + connect(m_resizeHideTimer, &QTimer::timeout, this, [this]() { + if (m_resizeOverlay) m_resizeOverlay->hide(); + }); } m_resizeHideTimer->start(durMs); - - // Repaint now so the overlay appears (or its text updates) on the - // next frame. paintEvent reads m_resizeOverlayText and the - // deadline; nothing else changes about the surface contents. - update(); } void GhosttySurface::showChildExited(int exitCode) { diff --git a/qt/src/GhosttySurface.h b/qt/src/GhosttySurface.h index 740d87b0d..6e1cf9506 100644 --- a/qt/src/GhosttySurface.h +++ b/qt/src/GhosttySurface.h @@ -25,7 +25,6 @@ class QOpenGLContext; class QOpenGLFramebufferObject; class QOpenGLShaderProgram; class QOpenGLVertexArrayObject; -class QPainter; class OverlayScrollbar; // One Ghostty terminal pane. @@ -140,10 +139,6 @@ private: void flashScrollbar(); // reveal the overlay scrollbar, arm hide void buildExitOverlay(int exitCode); void showResizeOverlay(); // transient grid-size overlay on resize - // Paint the resize overlay (if visible) directly via the parent's - // QPainter — done inside paintEvent so the overlay is atomic with - // the terminal blit beneath it. - void paintResizeOverlay(QPainter &painter); void layoutSearchBar(); // position the search bar at the top edge void sendKey(QKeyEvent *, ghostty_input_action_e action); void commitText(const QString &text); @@ -187,12 +182,8 @@ private: QLabel *m_exitOverlay = nullptr; // "process exited" banner; lazily made QLabel *m_keySeqOverlay = nullptr; // pending keybind chord; lazily made QStringList m_keySeq; // accumulated pending chords - // Resize overlay is painted directly inside paintEvent (not a child - // QLabel) so it can't race the parent's CompositionMode_Source blit - // mid-resize. Deadline-based: visible while now < m_resizeOverlayUntil. - QString m_resizeOverlayText; - qint64 m_resizeOverlayUntilMs = 0; // monotonic ms since epoch - QTimer *m_resizeHideTimer = nullptr; // schedules a paint at hide-time + 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; From f8f516382d3e6da2029c191d6800ee25e3e20762 Mon Sep 17 00:00:00 2001 From: ntomsic Date: Wed, 20 May 2026 19:21:22 -0500 Subject: [PATCH 74/75] qt: simplify icon to a dome ghost with >_ prompt eyes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cubic-Ghast revision read as too blocky at small sizes — the nine-tentacle 3x3 fringe collapsed to noise below ~48 px and the square head looked more "skull" than "ghost." Replace it with a simpler design: rounded-dome ghost with four wavy feet (the upstream Ghostty silhouette), inside the same purple CRT bezel + screen composition. Layout: - Bezel + screen + bloom + gloss layers unchanged from the prior revision: deep purple chrome, top sheen, soft violet bloom, gloss. - Ghost: rounded-top dome (radius 190, x=322..702) with four wavy feet at the bottom. Warm bone-paper body shading. - Eyes: wide-set >_ prompt glyph, exactly centered on the ghost (chevron 66 wide + 68 gap + cursor 98 wide, total 232, pair midpoint at the ghost-center x=512). Red, with a soft red bloom behind matching the rest of the icon's accent palette. - Mouth: small red square just below the eyes, above the foot scallops so it doesn't overlap the wavy region. Drops the scanlines too — they survived at 256+ px but were always the first detail to alias away below that, and the icon reads more clearly without them. Co-Authored-By: claude-flow --- qt/dist/ghastty.svg | 308 ++++++-------------------------------------- 1 file changed, 40 insertions(+), 268 deletions(-) diff --git a/qt/dist/ghastty.svg b/qt/dist/ghastty.svg index 2090f0621..63c7089a5 100644 --- a/qt/dist/ghastty.svg +++ b/qt/dist/ghastty.svg @@ -1,318 +1,90 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - + 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)"/> - - - - - - - - + + - - - - - - - - - - + + + From a74f01089a70edb7ce918b62c019fce2b614aca4 Mon Sep 17 00:00:00 2001 From: ntomsic Date: Wed, 20 May 2026 19:41:46 -0500 Subject: [PATCH 75/75] qt: cap tab width and elide long titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qt's QTabBar defaults to "size each tab to fit the full title text," so a long terminal title (e.g. a deep cwd or "filename.cpp - vim") made one tab consume the entire bar and pushed every other tab off- screen. Match the upstream platforms: - macOS uses Cocoa tabs with lineBreakMode = .byTruncatingTail. - GTK uses Adw.TabBar, which clamps width and ellipsizes the label. In TabBar: - setElideMode(Qt::ElideRight): truncate with `…` when the title doesn't fit the tab. - setExpanding(false): tabs size to content (capped via tabSizeHint), rather than stretching to fill the bar. - setUsesScrollButtons(true): when many tabs together still exceed the bar, Qt shows left/right arrows instead of squeezing every tab to an unreadable sliver. - Override tabSizeHint to cap each tab at ~28em wide (plus the close-button width when closable). Below the cap, falls back to Qt's content-fit hint so short titles still get short tabs. Co-Authored-By: claude-flow --- qt/src/TabWidget.cpp | 27 +++++++++++++++++++++++++++ qt/src/TabWidget.h | 8 ++++++++ 2 files changed, 35 insertions(+) diff --git a/qt/src/TabWidget.cpp b/qt/src/TabWidget.cpp index 245d99d9e..2dcd2d6a2 100644 --- a/qt/src/TabWidget.cpp +++ b/qt/src/TabWidget.cpp @@ -56,10 +56,37 @@ TabBar *decodeOrigin(const QByteArray &bytes) { 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()); diff --git a/qt/src/TabWidget.h b/qt/src/TabWidget.h index f6ddf6c88..6b18ebcb2 100644 --- a/qt/src/TabWidget.h +++ b/qt/src/TabWidget.h @@ -48,6 +48,14 @@ protected: 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);