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..c5d0106c1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,136 @@ +# syntax=docker/dockerfile:1.7 +# +# Reproducible build for libghostty + the Qt frontend (ghastty). +# +# Usage: +# docker build -t ghastty . +# docker run --rm -v "$PWD/out:/host-out" ghastty \ +# sh -c 'cp -a /out/. /host-out/' +# +# The runtime container does not ship a usable terminal — the Qt +# frontend wants a Wayland/X11 socket from the host. This image is for +# building (and CI testing) only. +# +# Stage layout: +# - base : Debian trixie + the Qt/Wayland deps both stages need +# - zig : pinned Zig toolchain (kept separate so a deps-only +# rebuild doesn't re-fetch Zig) +# - libghostty : zig build of libghostty (-Dapp-runtime=none) + tests +# - qt : cmake build of qt/ against the libghostty artifact +# - out : minimal final stage holding only the built binaries + +ARG DEBIAN_VERSION=trixie + +# Pinned to the project's minimum_zig_version (build.zig.zon). +ARG ZIG_VERSION=0.15.2 + +# --------------------------------------------------------------------- +# base — system packages shared across the build stages. +# --------------------------------------------------------------------- +FROM debian:${DEBIAN_VERSION}-slim AS base + +ENV DEBIAN_FRONTEND=noninteractive + +# Single apt layer so the package cache is dropped before the next +# stage. The list mixes: +# - build tooling (cmake, ninja, pkg-config, gcc, libstdc++-dev) +# - libghostty build deps via Zig (most are vendored; libxml2-dev is +# pulled in by the Sentry/breakpad path on Linux) +# - Qt 6 modules the frontend uses (Gui, Widgets, OpenGL, DBus, +# Multimedia, Svg) plus LayerShellQt +# - native-protocol deps the frontend hits directly (xkbcommon, +# wayland-client, wayland-scanner, xcb) +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + xz-utils \ + git \ + pkg-config \ + cmake \ + ninja-build \ + build-essential \ + libstdc++-14-dev \ + qt6-base-dev \ + qt6-base-private-dev \ + qt6-multimedia-dev \ + qt6-svg-dev \ + liblayershellqtinterface-dev \ + libxkbcommon-dev \ + libwayland-dev \ + wayland-protocols \ + libxcb1-dev \ + libxml2-dev \ + && rm -rf /var/lib/apt/lists/* + +# --------------------------------------------------------------------- +# zig — fetch and unpack the pinned Zig toolchain. +# +# Kept separate from `base` so changing apt deps does not invalidate +# the (large) Zig download layer. +# --------------------------------------------------------------------- +FROM base AS zig +ARG ZIG_VERSION + +RUN set -eux; \ + arch="$(dpkg --print-architecture)"; \ + case "$arch" in \ + amd64) zig_arch=x86_64 ;; \ + arm64) zig_arch=aarch64 ;; \ + *) echo "unsupported arch: $arch" >&2; exit 1 ;; \ + esac; \ + tarball="zig-${zig_arch}-linux-${ZIG_VERSION}.tar.xz"; \ + curl -fsSL -o "/tmp/${tarball}" \ + "https://ziglang.org/download/${ZIG_VERSION}/${tarball}"; \ + mkdir -p /opt/zig; \ + tar -xJf "/tmp/${tarball}" -C /opt/zig --strip-components=1; \ + rm "/tmp/${tarball}"; \ + ln -s /opt/zig/zig /usr/local/bin/zig; \ + zig version + +# --------------------------------------------------------------------- +# libghostty — Zig build of the libghostty shared library + tests. +# +# We mount the source tree rather than COPY so a `docker build` run +# does not bake the entire repo into this layer. Caches: +# - /root/.cache/zig : Zig's per-user cache (compiled deps) +# - /src/.zig-cache : project-local cache (incremental rebuilds) +# --------------------------------------------------------------------- +FROM zig AS libghostty +WORKDIR /src +COPY . /src + +# `-Dapp-runtime=none` makes the Zig build emit libghostty (the .so +# our Qt frontend links against) and skips the GTK frontend. Tests +# run first so a regression in libghostty fails the build cleanly, +# rather than later in the slower Qt stage. +RUN --mount=type=cache,target=/root/.cache/zig \ + --mount=type=cache,target=/src/.zig-cache \ + set -eux; \ + zig build test -Dapp-runtime=none -Doptimize=Debug; \ + zig build -Dapp-runtime=none -Doptimize=ReleaseFast + +# --------------------------------------------------------------------- +# qt — CMake build of the Qt frontend against the libghostty artifact. +# --------------------------------------------------------------------- +FROM libghostty AS qt +WORKDIR /src/qt + +# The CMake project links against zig-out/lib/ghostty-internal.so and +# materialises libghostty.so as a build-tree symlink (see qt/CMakeLists.txt). +# `--install` lays the binary, .so, .desktop entry and icon into /out +# under the standard FHS layout (bin/, lib/, share/...). +RUN set -eux; \ + cmake -S /src/qt -B /src/qt/build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release; \ + cmake --build /src/qt/build --parallel "$(nproc)"; \ + cmake --install /src/qt/build --prefix /out + +# --------------------------------------------------------------------- +# out — only the final artifacts. Run this image to extract them. +# --------------------------------------------------------------------- +FROM debian:${DEBIAN_VERSION}-slim AS out +COPY --from=qt /out /out + +# Default command lists the artifacts so `docker run --rm ghastty` +# is informative without --entrypoint heroics. +CMD ["sh", "-c", "find /out -type f -o -type l | sort"] 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..ec3782806 --- /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:-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..3cb8a2339 --- /dev/null +++ b/embed-test/main.c @@ -0,0 +1,282 @@ +// libghostty OpenGL embedding harness (Workstream A4). +// +// A minimal GLFW host that drives libghostty through the +// GHOSTTY_PLATFORM_OPENGL embedded API. Its only purpose is to verify +// that the OpenGL embedded render path (A1 + A2) actually produces a +// rendered terminal. This is throwaway verification scaffolding, not a +// real terminal frontend. +// +// Build: see build.sh in this directory. + +#include +#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; + +// Set when libghostty asks for a redraw; the main loop then draws. +// libghostty action callbacks may run on a worker thread, so this +// must be atomic to pair the write in on_action with the read in +// the main loop. +static atomic_int g_needs_draw = 1; + +// Count of presented frames. A nonzero value confirms the OpenGL +// embedded render path is producing frames. +static atomic_int g_frames = 0; + +// --- ghostty_platform_opengl_s callbacks ----------------------------- +// +// libghostty draws on the app thread (must_draw_from_app_thread), so +// these run on the main thread. + +static void *gl_get_proc_address(void *userdata, const char *name) { + (void)userdata; + return (void *)glfwGetProcAddress(name); +} + +static void gl_make_current(void *userdata) { + host_t *h = userdata; + glfwMakeContextCurrent(h->window); +} + +static void gl_release_current(void *userdata) { + (void)userdata; + glfwMakeContextCurrent(NULL); +} + +static void gl_present(void *userdata) { + host_t *h = userdata; + glfwSwapBuffers(h->window); + atomic_fetch_add(&g_frames, 1); +} + +// --- ghostty_runtime_config_s callbacks ------------------------------ + +static void on_wakeup(void *userdata) { + (void)userdata; + // Called from another thread; nudge the main loop so it ticks. + glfwPostEmptyEvent(); +} + +static bool on_action(ghostty_app_t app, ghostty_target_s target, + ghostty_action_s action) { + (void)app; + (void)target; + // libghostty requests a redraw via the render action; the main loop + // services it. Other actions are ignored by this harness. + if (action.tag == GHOSTTY_ACTION_RENDER) { + atomic_store(&g_needs_draw, 1); + return true; + } + return false; +} + +static bool on_read_clipboard(void *userdata, ghostty_clipboard_e loc, + void *state) { + (void)userdata; + (void)loc; + (void)state; + return false; +} + +static void on_confirm_read_clipboard(void *userdata, const char *str, + void *state, + ghostty_clipboard_request_e req) { + (void)userdata; + (void)str; + (void)state; + (void)req; +} + +static void on_write_clipboard(void *userdata, ghostty_clipboard_e loc, + const ghostty_clipboard_content_s *content, + size_t n, bool confirm) { + (void)userdata; + (void)loc; + (void)content; + (void)n; + (void)confirm; +} + +static void on_close_surface(void *userdata, bool process_active) { + (void)userdata; + (void)process_active; +} + +// --- GLFW input -> libghostty ---------------------------------------- + +static void on_char(GLFWwindow *win, unsigned int cp) { + (void)win; + if (!g_surface) return; + + // Encode the UTF-32 codepoint to UTF-8 and feed it as text. + char buf[4]; + int len = 0; + if (cp < 0x80) { + buf[len++] = (char)cp; + } else if (cp < 0x800) { + buf[len++] = (char)(0xC0 | (cp >> 6)); + buf[len++] = (char)(0x80 | (cp & 0x3F)); + } else if (cp < 0x10000) { + buf[len++] = (char)(0xE0 | (cp >> 12)); + buf[len++] = (char)(0x80 | ((cp >> 6) & 0x3F)); + buf[len++] = (char)(0x80 | (cp & 0x3F)); + } else { + buf[len++] = (char)(0xF0 | (cp >> 18)); + buf[len++] = (char)(0x80 | ((cp >> 12) & 0x3F)); + buf[len++] = (char)(0x80 | ((cp >> 6) & 0x3F)); + buf[len++] = (char)(0x80 | (cp & 0x3F)); + } + ghostty_surface_text(g_surface, buf, (uintptr_t)len); +} + +static void on_framebuffer_size(GLFWwindow *win, int w, int h) { + (void)win; + if (g_surface && w > 0 && h > 0) { + ghostty_surface_set_size(g_surface, (uint32_t)w, (uint32_t)h); + atomic_store(&g_needs_draw, 1); + } +} + +int main(int argc, char **argv) { + if (ghostty_init((uintptr_t)argc, argv) != GHOSTTY_SUCCESS) { + fprintf(stderr, "ghostty_init failed\n"); + return 1; + } + + if (!glfwInit()) { + fprintf(stderr, "glfwInit failed (no display?)\n"); + return 1; + } + + // Ghostty's OpenGL renderer requires at least OpenGL 4.3 core. + glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4); + glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); + glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); + glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE); + + GLFWwindow *window = + glfwCreateWindow(800, 600, "libghostty OpenGL harness", NULL, NULL); + if (!window) { + fprintf(stderr, "glfwCreateWindow failed\n"); + glfwTerminate(); + return 1; + } + + // libghostty draws on the app thread, so the GL context stays current + // on this (the main) thread, where glfwCreateWindow left it. + + host_t host = {.window = window}; + + // App-level runtime config. + ghostty_runtime_config_s rt = {0}; + rt.userdata = &host; + rt.supports_selection_clipboard = false; + rt.wakeup_cb = on_wakeup; + rt.action_cb = on_action; + rt.read_clipboard_cb = on_read_clipboard; + rt.confirm_read_clipboard_cb = on_confirm_read_clipboard; + rt.write_clipboard_cb = on_write_clipboard; + rt.close_surface_cb = on_close_surface; + + ghostty_config_t cfg = ghostty_config_new(); + ghostty_config_finalize(cfg); + + ghostty_app_t app = ghostty_app_new(&rt, cfg); + if (!app) { + fprintf(stderr, "ghostty_app_new failed\n"); + return 1; + } + + float xscale = 1.0f, yscale = 1.0f; + glfwGetWindowContentScale(window, &xscale, &yscale); + + // Force a widely-available TERM since the libghostty built with + // -Dapp-runtime=none does not install Ghostty's terminfo. + ghostty_env_var_s env[] = { + {"TERM", "xterm-256color"}, + }; + + // Surface config: hand libghostty our OpenGL context callbacks. + ghostty_surface_config_s sc = ghostty_surface_config_new(); + sc.platform_tag = GHOSTTY_PLATFORM_OPENGL; + sc.platform.opengl = (ghostty_platform_opengl_s){ + .userdata = &host, + .get_proc_address = gl_get_proc_address, + .make_current = gl_make_current, + .release_current = gl_release_current, + .present = gl_present, + }; + sc.userdata = &host; + sc.scale_factor = xscale; + sc.env_vars = env; + sc.env_var_count = sizeof(env) / sizeof(env[0]); + + ghostty_surface_t surface = ghostty_surface_new(app, &sc); + if (!surface) { + fprintf(stderr, "ghostty_surface_new failed\n"); + return 1; + } + g_surface = surface; + + int fbw, fbh; + glfwGetFramebufferSize(window, &fbw, &fbh); + ghostty_surface_set_content_scale(surface, xscale, yscale); + ghostty_surface_set_size(surface, (uint32_t)fbw, (uint32_t)fbh); + ghostty_surface_set_focus(surface, true); + + glfwSetCharCallback(window, on_char); + glfwSetFramebufferSizeCallback(window, on_framebuffer_size); + + printf("harness running — a terminal should render. " + "Close the window to exit.\n"); + fflush(stdout); + + // Main loop: pump GLFW events, tick libghostty, and draw when asked. + double next_report = glfwGetTime() + 1.0; + while (!glfwWindowShouldClose(window)) { + glfwWaitEventsTimeout(0.1); + ghostty_app_tick(app); + if (ghostty_surface_process_exited(surface)) { + glfwSetWindowShouldClose(window, GLFW_TRUE); + break; + } + + // libghostty requested a draw (via the render action); service it + // on this thread. atomic_exchange clears the flag and reads it in + // one step, so a wakeup setting it again between the read and + // the draw is preserved for the next iteration. + if (atomic_exchange(&g_needs_draw, 0)) { + ghostty_surface_draw(surface); + } + + // Report presented-frame count once per second. + double now = glfwGetTime(); + if (now >= next_report) { + printf("frames presented: %d\n", atomic_load(&g_frames)); + fflush(stdout); + next_report = now + 1.0; + } + } + + printf("exiting — total frames presented: %d\n", atomic_load(&g_frames)); + + ghostty_surface_free(surface); + ghostty_app_free(app); + ghostty_config_free(cfg); + glfwDestroyWindow(window); + glfwTerminate(); + return 0; +} diff --git a/include/ghostty.h b/include/ghostty.h index fbfe3ee2c..32523a9b1 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,37 @@ typedef struct { void* uiview; } ghostty_platform_ios_s; +// Platform configuration for a host that provides its own OpenGL +// context (e.g. a Qt, X11, or Wayland application embedding libghostty). +// +// The host owns the OpenGL context and windowing. libghostty draws on +// the app (GUI) thread for the OpenGL renderer (the embedded apprt +// sets must_draw_from_app_thread for OpenGL), so these callbacks all +// run on the same thread that calls ghostty_surface_new and +// ghostty_surface_draw. The context only needs to be usable from that +// thread; it does not need to be thread-portable. +typedef struct { + // Userdata passed as the first argument to every callback below. + void* userdata; + + // Return the address of the named OpenGL function, or NULL if it is + // not available. Used to load the GL function pointers. + void* (*get_proc_address)(void* userdata, const char* name); + + // Make the host's OpenGL context current on the calling thread. + void (*make_current)(void* userdata); + + // Release the host's OpenGL context from the calling thread. + void (*release_current)(void* userdata); + + // Present the most recently rendered frame (e.g. swap buffers). + void (*present)(void* userdata); +} ghostty_platform_opengl_s; + typedef union { ghostty_platform_macos_s macos; ghostty_platform_ios_s ios; + ghostty_platform_opengl_s opengl; } ghostty_platform_u; typedef enum { @@ -488,6 +517,13 @@ typedef struct { uint32_t cell_height_px; } ghostty_surface_size_s; +typedef struct { + uint32_t x; + uint32_t y; + uint32_t width; + uint32_t height; +} ghostty_surface_cursor_position_s; + // Config types // config.Path @@ -831,7 +867,7 @@ typedef enum { // apprt.surface.Message.ChildExited typedef struct { uint32_t exit_code; - uint64_t timetime_ms; + uint64_t runtime_ms; } ghostty_surface_message_childexited_s; // terminal.osc.Command.ProgressReport.State @@ -1039,7 +1075,7 @@ typedef union { typedef struct { ghostty_ipc_target_tag_e tag; ghostty_ipc_target_u target; -} chostty_ipc_target_s; +} ghostty_ipc_target_s; // apprt.ipc.Action.NewWindow typedef struct { @@ -1115,6 +1151,8 @@ GHOSTTY_API void ghostty_surface_set_focus(ghostty_surface_t, bool); GHOSTTY_API void ghostty_surface_set_occlusion(ghostty_surface_t, bool); GHOSTTY_API void ghostty_surface_set_size(ghostty_surface_t, uint32_t, uint32_t); GHOSTTY_API ghostty_surface_size_s ghostty_surface_size(ghostty_surface_t); +GHOSTTY_API ghostty_surface_cursor_position_s + ghostty_surface_cursor_position(ghostty_surface_t); GHOSTTY_API uint64_t ghostty_surface_foreground_pid(ghostty_surface_t); GHOSTTY_API ghostty_string_s ghostty_surface_tty_name(ghostty_surface_t); GHOSTTY_API void ghostty_surface_set_color_scheme(ghostty_surface_t, @@ -1194,6 +1232,18 @@ GHOSTTY_API void ghostty_inspector_metal_render(ghostty_inspector_t, void*, void GHOSTTY_API bool ghostty_inspector_metal_shutdown(ghostty_inspector_t); #endif +// Inspector rendering for the embedded OpenGL platform. init/shutdown +// manage the ImGui OpenGL backend; render draws the inspector into the +// currently bound GL framebuffer (the host makes its context current +// and binds the target framebuffer first). +// +// On Apple targets these symbols are present but no-op: Apple builds +// use the Metal inspector path above and the ImGui OpenGL3 backend is +// not compiled into libghostty there. +GHOSTTY_API bool ghostty_inspector_opengl_init(ghostty_inspector_t); +GHOSTTY_API void ghostty_inspector_opengl_render(ghostty_inspector_t); +GHOSTTY_API void ghostty_inspector_opengl_shutdown(ghostty_inspector_t); + // APIs I'd like to get rid of eventually but are still needed for now. // Don't use these unless you know what you're doing. GHOSTTY_API void ghostty_set_window_background_blur(ghostty_app_t, void*); 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 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..9363cfb73 --- /dev/null +++ b/qt/CMakeLists.txt @@ -0,0 +1,190 @@ +cmake_minimum_required(VERSION 3.16) +# C is needed for the wayland-scanner-generated protocol code. +project(ghastty LANGUAGES CXX C) + +# Ghastty: a Qt6 frontend for Ghostty that embeds libghostty through the +# GHOSTTY_PLATFORM_OPENGL C API. libghostty's OpenGL renderer draws each +# terminal into an offscreen framebuffer (a private QOpenGLContext); the +# frame is read back into a QImage and painted on an ordinary translucent +# QWidget. That keeps the terminal a plain widget — it embeds in the +# tab/split tree and its transparent background composites like any other +# widget. libghostty draws on the GUI thread (the embedded apprt sets +# must_draw_from_app_thread for the OpenGL renderer). +# +# Build libghostty first, from the repo root. Use ReleaseFast: a Debug +# build of libghostty leaves the whole terminal core unoptimized and +# makes rendering and surface creation noticeably sluggish. +# +# zig build -Dapp-runtime=none -Doptimize=ReleaseFast +# +# Then build, run, and (optionally) install this app: +# +# cmake -S qt -B qt/build && cmake --build qt/build +# ./qt/build/ghastty +# cmake --install qt/build --prefix ~/.local + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) + +include(GNUInstallDirs) + +find_package(Qt6 REQUIRED COMPONENTS Gui Widgets OpenGL DBus + Multimedia Svg) +# WindowBlur uses qpa/qplatformnativeinterface.h to reach the wl_display +# / wl_surface / xcb_connection_t for the native compositor blur calls. +# Qt's official path is `find_package(Qt6 COMPONENTS GuiPrivate)`, but +# Debian's qt6-base-private-dev ships the private headers without the +# matching Qt6GuiPrivateConfig.cmake. Detect the component when present; +# otherwise fall back to wiring the private include dir by hand. +find_package(Qt6 QUIET OPTIONAL_COMPONENTS GuiPrivate) + +# LayerShellQt: the quick terminal is a wlr-layer-shell dropdown window. +find_package(LayerShellQt REQUIRED) + +# KWin background-blur is applied through the native compositor: the +# org_kde_kwin_blur Wayland protocol and the _KDE_NET_WM_BLUR atom on +# X11. Qt6::GuiPrivate gives the QPA native-handle accessors. +find_package(PkgConfig REQUIRED) +pkg_check_modules(WAYLAND_CLIENT REQUIRED IMPORTED_TARGET wayland-client) +pkg_check_modules(XCB REQUIRED IMPORTED_TARGET xcb) +# libxkbcommon: derive the unshifted Unicode codepoint for a key event +# from its XKB keycode, so libghostty's kitty encoder finds an entry for +# punctuation keys (Qt's ev->key() reports the SHIFTED symbol, e.g. +# Qt::Key_Colon for Shift+;). +pkg_check_modules(XKBCOMMON REQUIRED IMPORTED_TARGET xkbcommon) +find_program(WAYLAND_SCANNER wayland-scanner REQUIRED) + +# Generate client glue for the org_kde_kwin_blur protocol. +set(BLUR_XML "${CMAKE_CURRENT_SOURCE_DIR}/protocols/blur.xml") +set(BLUR_HEADER "${CMAKE_CURRENT_BINARY_DIR}/blur-client-protocol.h") +set(BLUR_CODE "${CMAKE_CURRENT_BINARY_DIR}/blur-protocol.c") +add_custom_command(OUTPUT "${BLUR_HEADER}" + COMMAND "${WAYLAND_SCANNER}" client-header "${BLUR_XML}" "${BLUR_HEADER}" + DEPENDS "${BLUR_XML}" VERBATIM) +add_custom_command(OUTPUT "${BLUR_CODE}" + COMMAND "${WAYLAND_SCANNER}" private-code "${BLUR_XML}" "${BLUR_CODE}" + DEPENDS "${BLUR_XML}" VERBATIM) + +# libghostty is built out-of-tree by Zig. +get_filename_component(GHOSTTY_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/.." ABSOLUTE) +set(GHOSTTY_LIB_DIR "${GHOSTTY_ROOT}/zig-out/lib") +set(GHOSTTY_SO "${GHOSTTY_LIB_DIR}/ghostty-internal.so") + +if(NOT EXISTS "${GHOSTTY_SO}") + message(FATAL_ERROR + "libghostty not found at ${GHOSTTY_SO}\n" + "Build it first from the repo root: zig build -Dapp-runtime=none") +endif() + +# The .so's SONAME is libghostty.so but it is installed as +# ghostty-internal.so. Create a symlink in the build tree (NOT in the +# Zig output dir, which is the source tree from CMake's perspective) +# so the loader finds it under the SONAME the binary links against. +set(GHOSTTY_LINK_DIR "${CMAKE_CURRENT_BINARY_DIR}/lib") +set(GHOSTTY_LINK_SO "${GHOSTTY_LINK_DIR}/libghostty.so") +add_custom_command(OUTPUT "${GHOSTTY_LINK_SO}" + COMMAND "${CMAKE_COMMAND}" -E make_directory "${GHOSTTY_LINK_DIR}" + COMMAND "${CMAKE_COMMAND}" -E create_symlink + "${GHOSTTY_SO}" "${GHOSTTY_LINK_SO}" + DEPENDS "${GHOSTTY_SO}" VERBATIM) +add_custom_target(ghostty_link DEPENDS "${GHOSTTY_LINK_SO}") + +add_executable(ghastty + src/main.cpp + src/CommandPalette.cpp + src/GhosttySurface.cpp + src/GlobalShortcuts.cpp + src/InspectorWindow.cpp + src/MainWindow.cpp + src/OverlayScrollbar.cpp + src/SearchBar.cpp + src/TabWidget.cpp + src/Util.cpp + src/WindowBlur.cpp + "${BLUR_CODE}" + "${BLUR_HEADER}" +) + +# Embed the app icon so it is available even running from the build tree. +qt_add_resources(ghastty "appicon" + PREFIX "/" + BASE dist + FILES dist/ghastty.svg) + +target_include_directories(ghastty PRIVATE + "${GHOSTTY_ROOT}/include" + "${CMAKE_CURRENT_BINARY_DIR}" # generated blur-client-protocol.h +) + +add_dependencies(ghastty ghostty_link) + +target_link_libraries(ghastty PRIVATE + Qt6::Gui + Qt6::Widgets + Qt6::OpenGL + Qt6::DBus + Qt6::Multimedia + Qt6::Svg + PkgConfig::WAYLAND_CLIENT + PkgConfig::XCB + PkgConfig::XKBCOMMON + LayerShellQt::Interface + "${GHOSTTY_LINK_SO}" +) + +# Hook up the private QPA headers (see find_package above). +if(TARGET Qt6::GuiPrivate) + target_link_libraries(ghastty PRIVATE Qt6::GuiPrivate) +else() + # Debian fallback: locate qplatformnativeinterface.h and add its + # parent dir as a private include (the header lives at + # /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/${CMAKE_LIBRARY_ARCHITECTURE}/qt6/QtGui/${Qt6_VERSION}/QtGui" + "${Qt6_DIR}/../../../include/qt6/QtGui/${Qt6_VERSION}/QtGui" + PATH_SUFFIXES + "qt6/QtGui/${Qt6_VERSION}/QtGui" + ) + if(NOT QT_QPA_INCLUDE_DIR) + message(FATAL_ERROR + "Qt6 private QPA headers not found.\n" + "Install qt6-base-private-dev (Debian/Ubuntu) or the equivalent " + "Qt 6 private headers package.") + endif() + message(STATUS "Using Qt private include dir: ${QT_QPA_INCLUDE_DIR}") + target_include_directories(ghastty PRIVATE "${QT_QPA_INCLUDE_DIR}") +endif() + +# RPATH: +# - Build tree: libghostty.so lives in our build dir (a symlink to the +# actual zig-out artifact), and the .so's NEEDED entries also point +# into zig-out/lib for transitive deps. +# - Installed: libghostty.so lives next to the binary ($ORIGIN/../lib). +set_target_properties(ghastty PROPERTIES + BUILD_RPATH "${GHOSTTY_LINK_DIR};${GHOSTTY_LIB_DIR}" + INSTALL_RPATH "$ORIGIN/../${CMAKE_INSTALL_LIBDIR}" +) + +# --- install --------------------------------------------------------- +install(TARGETS ghastty RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") + +# libghostty.so the binary links against (SONAME is libghostty.so). +install(FILES "${GHOSTTY_SO}" + DESTINATION "${CMAKE_INSTALL_LIBDIR}" + RENAME libghostty.so) + +install(FILES dist/ghastty.desktop + DESTINATION "${CMAKE_INSTALL_DATADIR}/applications") + +# The custom scalable app icon. +install(FILES dist/ghastty.svg + DESTINATION "${CMAKE_INSTALL_DATADIR}/icons/hicolor/scalable/apps") diff --git a/qt/dist/ghastty.desktop b/qt/dist/ghastty.desktop new file mode 100644 index 000000000..3ea8ad459 --- /dev/null +++ b/qt/dist/ghastty.desktop @@ -0,0 +1,13 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=Ghastty +GenericName=Terminal +Comment=A terminal emulator +Exec=ghastty +Icon=ghastty +Categories=System;TerminalEmulator; +Keywords=terminal;tty;pty; +StartupNotify=true +StartupWMClass=ghastty +Terminal=false diff --git a/qt/dist/ghastty.svg b/qt/dist/ghastty.svg new file mode 100644 index 000000000..63c7089a5 --- /dev/null +++ b/qt/dist/ghastty.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/CommandPalette.cpp b/qt/src/CommandPalette.cpp new file mode 100644 index 000000000..386e24b6c --- /dev/null +++ b/qt/src/CommandPalette.cpp @@ -0,0 +1,159 @@ +#include "CommandPalette.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "GhosttySurface.h" +#include "MainWindow.h" +#include "Util.h" + +namespace { +// Item data roles: the keybind action to run, and the text the filter +// matches against (title plus the bare action name). +constexpr int kActionRole = Qt::UserRole; +constexpr int kFilterRole = Qt::UserRole + 1; +} // namespace + +CommandPalette::CommandPalette(QWidget *owner) + : QWidget(owner, Qt::Popup), m_owner(owner) { + resize(620, 420); + + m_search = new QLineEdit(this); + m_search->setPlaceholderText(QStringLiteral("Run a command…")); + m_search->setClearButtonEnabled(true); + m_search->installEventFilter(this); + + m_model = new QStandardItemModel(this); + m_filter = new QSortFilterProxyModel(this); + m_filter->setSourceModel(m_model); + m_filter->setFilterRole(kFilterRole); + m_filter->setFilterCaseSensitivity(Qt::CaseInsensitive); + + m_list = new QListView(this); + m_list->setModel(m_filter); + m_list->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_list->setSelectionMode(QAbstractItemView::SingleSelection); + m_list->setUniformItemSizes(true); + m_list->setFocusPolicy(Qt::NoFocus); // keep typing in the search box + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(8, 8, 8, 8); + layout->addWidget(m_search); + layout->addWidget(m_list); + + // Debounce filter updates so a fast typist on a long + // command-palette-entry list doesn't thrash QSortFilterProxyModel. + // 80ms feels live without flooring slow systems. + m_filterDebounce = new QTimer(this); + m_filterDebounce->setSingleShot(true); + m_filterDebounce->setInterval(80); + connect(m_filterDebounce, &QTimer::timeout, this, [this]() { + m_filter->setFilterFixedString(m_search->text()); + selectFirstRow(); + }); + connect(m_search, &QLineEdit::textChanged, this, + [this]() { m_filterDebounce->start(); }); + connect(m_list, &QListView::activated, this, + [this](const QModelIndex &) { runSelected(); }); + hide(); +} + +void CommandPalette::toggleFor(GhosttySurface *surface) { + if (isVisible()) { + hide(); + return; + } + // If the owner window is gone (closed while we were hidden), the + // QPointer auto-nulled — we can't place ourselves sensibly without + // it, so bail. The next initialize on a new window creates a fresh + // palette. + if (!m_owner) return; + + m_surface = surface; + populate(); + m_search->clear(); + m_filter->setFilterFixedString(QString()); + + // Centre over the owner, biased toward the top. As a Qt::Popup the + // position is interpreted relative to the parent window, so this + // places correctly on Wayland too. + const QPoint p((m_owner->width() - width()) / 2, m_owner->height() / 6); + move(m_owner->mapToGlobal(p)); + show(); + m_search->setFocus(); + selectFirstRow(); +} + +void CommandPalette::populate() { + m_model->clear(); + if (!m_surface || !m_surface->owner()) return; + ghostty_config_t cfg = m_surface->owner()->config(); + if (!cfg) return; + + // command-palette-entry defaults to a large built-in command set. + ghostty_config_command_list_s list = {}; + if (!configGet(cfg, &list, "command-palette-entry")) return; + for (size_t i = 0; i < list.len; ++i) { + const ghostty_command_s &c = list.commands[i]; + const QString title = QString::fromUtf8(c.title ? c.title : ""); + const QString action = QString::fromUtf8(c.action ? c.action : ""); + if (title.isEmpty() || action.isEmpty()) continue; + auto *item = new QStandardItem(title); + item->setData(action, kActionRole); + item->setData(title + QLatin1Char(' ') + + QString::fromUtf8(c.action_key ? c.action_key : ""), + kFilterRole); + if (c.description && *c.description) + item->setToolTip(QString::fromUtf8(c.description)); + m_model->appendRow(item); + } +} + +void CommandPalette::selectFirstRow() { + if (m_filter->rowCount() > 0) + m_list->setCurrentIndex(m_filter->index(0, 0)); +} + +void CommandPalette::moveSelection(int delta) { + const int n = m_filter->rowCount(); + if (n == 0) return; + int row = m_list->currentIndex().row(); + row = qBound(0, (row < 0 ? 0 : row) + delta, n - 1); + m_list->setCurrentIndex(m_filter->index(row, 0)); +} + +void CommandPalette::runSelected() { + const QModelIndex idx = m_list->currentIndex(); + if (!idx.isValid()) return; + const QString action = idx.data(kActionRole).toString(); + GhosttySurface *surface = m_surface; + hide(); // close before executing, matching the GTK palette + if (surface && surface->surface() && !action.isEmpty()) { + const QByteArray a = action.toUtf8(); + ghostty_surface_binding_action(surface->surface(), a.constData(), + a.size()); + } +} + +bool CommandPalette::eventFilter(QObject *obj, QEvent *event) { + if (obj == m_search && event->type() == QEvent::KeyPress) { + auto *ke = static_cast(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..4f89d7208 --- /dev/null +++ b/qt/src/CommandPalette.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include + +class GhosttySurface; +class QLineEdit; +class QListView; +class QSortFilterProxyModel; +class QStandardItemModel; +class QTimer; + +// A searchable command palette (the TOGGLE_COMMAND_PALETTE action). +// +// It lists the commands from the `command-palette-entry` config (a +// large built-in default set plus any user additions) and runs the +// chosen command's keybind action on the active surface. Shown as a +// Qt::Popup over its owner — Qt anchors it to the parent window (so it +// places correctly on Wayland) and dismisses it on an outside click. +class CommandPalette : public QWidget { + Q_OBJECT + +public: + explicit CommandPalette(QWidget *owner); + + // Show the palette for `surface` (populating from the live config), + // or hide it if already visible. + void toggleFor(GhosttySurface *surface); + +protected: + bool eventFilter(QObject *obj, QEvent *event) override; + +private: + void populate(); // (re)load the command list from config + void runSelected(); // execute the highlighted command + void moveSelection(int delta); + void selectFirstRow(); + + // Owner window the palette centres over. QPointer so a window + // closed while the palette is hidden doesn't leave us with a + // dangling raw pointer (toggleFor dereferences it for placement). + QPointer 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 new file mode 100644 index 000000000..909810813 --- /dev/null +++ b/qt/src/GhosttySurface.cpp @@ -0,0 +1,1024 @@ +#include "GhosttySurface.h" + +#include "InspectorWindow.h" +#include "MainWindow.h" +#include "OverlayScrollbar.h" +#include "SearchBar.h" +#include "TabWidget.h" +#include "Util.h" + +#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 +#include +#include +#include +#include +#include +#include +#include +#include +#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) { + setFocusPolicy(Qt::StrongFocus); + setMouseTracking(true); // deliver motion events for hover/link detection + setAttribute(Qt::WA_InputMethodEnabled, true); // IME composition + setAcceptDrops(true); // file / text drops + + // 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); + + // 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, "[ghastty] GL context creation failed\n"); + return; + } + m_offscreen = new QOffscreenSurface(nullptr, this); + m_offscreen->setFormat(m_context->format()); + m_offscreen->create(); + + if (!makeCurrent()) { + std::fprintf(stderr, "[ghastty] 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); + + ghostty_surface_config_s sc = + 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; + sc.platform.opengl.make_current = glMakeCurrent; + sc.platform.opengl.release_current = glReleaseCurrent; + sc.platform.opengl.present = glPresent; + sc.userdata = this; + sc.scale_factor = devicePixelRatioF(); + + m_surface = ghostty_surface_new(m_app, &sc); + if (!m_surface) { + std::fprintf(stderr, "[ghastty] ghostty_surface_new failed\n"); + return; + } + + if (m_owner->needsPremultiply()) initPremultiply(); +} + +GhosttySurface::~GhosttySurface() { + // The inspector window holds m_surface; destroy it before m_surface. + // QPointer auto-nulls on a destroyed QObject, so .data() is safe. + delete m_inspectorWindow.data(); + + // 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() { + return m_context && m_offscreen && m_offscreen->isValid() && + m_context->makeCurrent(m_offscreen); +} + +// --- rendering ------------------------------------------------------ + +// 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() + // is the true (possibly fractional) scale because main() selects the + // 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. 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; + m_fbDpr = dpr; + + 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::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); + layoutSearchBar(); + showResizeOverlay(); +} + +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::renderIfDirty() { + if (m_dirty.exchange(false)) renderTerminal(); +} + +void GhosttySurface::layoutScrollbar() { + 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; +// `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 (configGet(m_owner->config(), &value, "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) { + if (!m_scrollbar) return; + if (!scrollbarAllowed() || total <= len) { + m_scrollbar->setMetrics(0, 0, 0); + m_scrollbar->hide(); + return; + } + m_scrollbar->setMetrics(total, offset, len); + + // 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 && 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)); + } + layoutScrollbar(); + m_scrollbar->reveal(); +} + +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, 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(); +} + +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); + + // 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; + 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 (configGet(cfg, &c, "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); + 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(); + }); +} + +// 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::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); + 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, kept clear of the overlay scrollbar's strip. + m_searchBar->move( + width() - m_searchBar->width() - OverlayScrollbar::kWidth - 8, 8); +} + +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; + 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); +} + +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 +// 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(); + + 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 ---------------------------------------------------------- + +// 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). +// +// 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() { + 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. 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; + 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); + } + + ~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 + // 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; +}; + +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() && + static_cast(text.front()) >= 0x20 && + static_cast(text.front()) != 0x7f; + + // 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.keycode = keycode; + k.text = printable ? text.constData() : nullptr; + // 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); +} + +void GhosttySurface::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 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); +} + +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); +} + +// 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 +// (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)); + + 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); +} + +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); + add(&menu, "Find…", "edit-find", "start_search", 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); + 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(); + + // 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 via promptTitle (the set_*_title keybind). + if (data == QLatin1String("prompt_surface_title") || + data == QLatin1String("prompt_tab_title")) { + promptTitle(data == QLatin1String("prompt_tab_title")); + return; + } + + const QByteArray action = data.toUtf8(); + ghostty_surface_binding_action(m_surface, action.constData(), + action.size()); +} + +void GhosttySurface::dragEnterEvent(QDragEnterEvent *ev) { + // 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. + 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 + // 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())); + + // 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) { + 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); + flashScrollbar(); // mouse-wheel scrolling reveals the overlay scrollbar +} + +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); + 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. +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) { + 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 = preeditStr.toUtf8(); + ghostty_surface_preedit( + m_surface, preedit.isEmpty() ? nullptr : preedit.constData(), + static_cast(preedit.size())); + + // 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(); +} + +QVariant GhosttySurface::inputMethodQuery(Qt::InputMethodQuery query) const { + switch (query) { + case Qt::ImEnabled: + return true; + 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); + // 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); + } +} + +// --- libghostty GL platform callbacks -------------------------------- + +void *GhosttySurface::glGetProcAddress(void *, const char *name) { + QOpenGLContext *ctx = QOpenGLContext::currentContext(); + return ctx ? reinterpret_cast(ctx->getProcAddress(name)) : nullptr; +} + +void GhosttySurface::glMakeCurrent(void *ud) { + static_cast(ud)->makeCurrent(); +} + +void GhosttySurface::glReleaseCurrent(void *) { + // No-op: renderTerminal makes the context current around each frame. +} + +void GhosttySurface::glPresent(void *) { + // No-op: the frame is read back from the framebuffer, not swapped. +} diff --git a/qt/src/GhosttySurface.h b/qt/src/GhosttySurface.h new file mode 100644 index 000000000..6e1cf9506 --- /dev/null +++ b/qt/src/GhosttySurface.h @@ -0,0 +1,207 @@ +#pragma once + +#include + +#include +#include +#include +#include + +#include "ghostty.h" + +class MainWindow; +class QContextMenuEvent; +class QDragEnterEvent; +class QDropEvent; +class QEnterEvent; +class QTimer; +class InspectorWindow; +class SearchBar; +class QInputMethodEvent; +class QKeySequence; +class QLabel; +class QOffscreenSurface; +class QOpenGLContext; +class QOpenGLFramebufferObject; +class QOpenGLShaderProgram; +class QOpenGLVertexArrayObject; +class OverlayScrollbar; + +// One Ghostty terminal pane. +// +// libghostty's OpenGL renderer draws the terminal into an offscreen +// framebuffer owned by a private QOpenGLContext (there is no on-screen +// GL surface). Each frame is read back into a QImage and painted with +// QPainter. That keeps this an ordinary translucent QWidget, so it +// embeds in the QTabWidget / QSplitter tree and its transparent +// background composites to the desktop exactly like the rest of the +// widget chrome — avoiding QOpenGLWidget (composites opaque on Wayland) +// and an embedded QOpenGLWindow (does not present when embedded). +class GhosttySurface : public QWidget { + Q_OBJECT + +public: + // `parent_surface` (may be null) is the surface whose working + // directory etc. a new surface should inherit. + GhosttySurface(ghostty_app_t app, MainWindow *owner, + ghostty_surface_t parent_surface); + ~GhosttySurface() override; + + ghostty_surface_t surface() const { return m_surface; } + MainWindow *owner() const { return m_owner; } + // Reassign the owning window (used when a tab is torn off into one). + void setOwner(MainWindow *owner) { m_owner = owner; } + + // Show a dismissable "process exited" overlay over the terminal. The + // surface stays open until the user dismisses it (key or click). + void showChildExited(int exitCode); + + // Arm a one-shot desktop notification for the next command to finish + // (context-menu item); consumeCommandNotify reads-and-clears the flag. + void armCommandNotify() { m_notifyOnCommand = true; } + bool consumeCommandNotify() { + const bool armed = m_notifyOnCommand; + m_notifyOnCommand = false; + return armed; + } + +public: + // Render coalescing: markDirty() flags the surface (called from the + // RENDER action, possibly off-thread); renderIfDirty(), called once a + // frame by MainWindow, does the actual render. + void markDirty() { m_dirty.store(true); } + void renderIfDirty(); + + // Reflect a libghostty SCROLLBAR action: total scrollback rows, the + // viewport-top row, and the visible row count. + void updateScrollbar(uint64_t total, uint64_t offset, uint64_t len); + + // Open the title-change dialog (PROMPT_TITLE action / context menu); + // `tabScope` picks the tab vs surface title. + void promptTitle(bool tabScope); + + // Pending-keybind chord overlay, driven by the KEY_SEQUENCE action: + // pushKeySequence appends a chord, endKeySequence clears the overlay. + void pushKeySequence(const QString &chord); + void endKeySequence(); + + // Show/hide/toggle the terminal inspector window (INSPECTOR action). + void toggleInspector(ghostty_action_inspector_e mode); + + // In-terminal search (the *_SEARCH actions): openSearch shows the + // search bar (optionally pre-filled), closeSearch hides it, and the + // setSearch* calls mirror libghostty's reported match counters. + void openSearch(const QString &prefill); + void closeSearch(); + void setSearchTotal(int total); + void setSearchSelected(int selected); + + // Bell `border` feature: briefly flash a border over the terminal. + void flashBorder(); + // Bell `title` feature: mark/unmark an unacknowledged bell. MainWindow + // prefixes the tab title while any surface in the tab is marked. + void setBellTitle(bool marked) { m_bellTitle = marked; } + bool bellTitle() const { return m_bellTitle; } + +protected: + bool event(QEvent *) override; + void paintEvent(QPaintEvent *) override; + void resizeEvent(QResizeEvent *) override; + + // Disable Qt's Tab/Backtab focus traversal so those keys reach + // keyPressEvent and can be forwarded to the terminal. + bool focusNextPrevChild(bool) override { return false; } + + void keyPressEvent(QKeyEvent *) override; + void keyReleaseEvent(QKeyEvent *) override; + void mousePressEvent(QMouseEvent *) override; + void mouseReleaseEvent(QMouseEvent *) override; + void mouseMoveEvent(QMouseEvent *) override; + void contextMenuEvent(QContextMenuEvent *) override; + void dragEnterEvent(QDragEnterEvent *) override; + void dropEvent(QDropEvent *) override; + void wheelEvent(QWheelEvent *) override; + void enterEvent(QEnterEvent *) override; // focus-follows-mouse + void focusInEvent(QFocusEvent *) override; + void focusOutEvent(QFocusEvent *) override; + + // IME composition: preedit text is forwarded to libghostty for inline + // display; committed text is inserted as input. + void inputMethodEvent(QInputMethodEvent *) override; + QVariant inputMethodQuery(Qt::InputMethodQuery) const override; + +private: + bool makeCurrent(); + void syncSurfaceSize(); + void renderTerminal(); + void layoutScrollbar(); // position the scrollbar at the edge + bool scrollbarAllowed() const; // false when `scrollbar = never` + void flashScrollbar(); // reveal the overlay scrollbar, arm hide + void buildExitOverlay(int exitCode); + void showResizeOverlay(); // transient grid-size overlay on resize + void layoutSearchBar(); // position the search bar at the top edge + void sendKey(QKeyEvent *, ghostty_input_action_e action); + void commitText(const QString &text); + void sendMouseButton(QMouseEvent *, ghostty_input_mouse_state_e state); + bool rightClickOpensMenu(QMouseEvent *ev) const; + + // The keybind currently bound to `action` (for context-menu hints), + // or an empty sequence if none / not displayable. + QKeySequence shortcutFor(const char *action) const; + + // Premultiply the framebuffer's alpha; only used when a custom shader + // is configured (see GhosttySurface.cpp). + void initPremultiply(); + void premultiplyFramebuffer(); + + // libghostty GL platform callbacks (all run on the GUI thread). + static void *glGetProcAddress(void *ud, const char *name); + static void glMakeCurrent(void *ud); + static void glReleaseCurrent(void *ud); + static void glPresent(void *ud); + + ghostty_app_t m_app; // shared; owned by MainWindow + MainWindow *m_owner; // not owned + ghostty_surface_t m_parentSurface; // inherited-config source; may be null + ghostty_surface_t m_surface = nullptr; + + // Private offscreen GL context libghostty renders into. + QOpenGLContext *m_context = nullptr; + QOffscreenSurface *m_offscreen = nullptr; + QOpenGLFramebufferObject *m_fbo = nullptr; + QImage m_image; // last frame, read back from m_fbo + + // GL objects for the alpha-premultiply pass. + QOpenGLShaderProgram *m_premultProg = nullptr; + QOpenGLVertexArrayObject *m_premultVao = nullptr; + + int m_fbw = 0; // framebuffer size, device pixels + int m_fbh = 0; + double m_fbDpr = 1.0; // DPR the framebuffer was sized at + + QLabel *m_exitOverlay = nullptr; // "process exited" banner; lazily made + QLabel *m_keySeqOverlay = nullptr; // pending keybind chord; lazily made + QStringList m_keySeq; // accumulated pending chords + QLabel *m_resizeOverlay = nullptr; // transient "cols x rows"; lazily made + QTimer *m_resizeHideTimer = nullptr; // auto-hides m_resizeOverlay + bool m_firstGridSeen = false; // for `resize-overlay = after-first` + int m_lastCols = 0; // last grid size, to detect changes + int m_lastRows = 0; + SearchBar *m_searchBar = nullptr; // in-terminal search; lazily made + // Terminal inspector window; lazily made. QPointer so a WM-driven + // close (treated as hide) or a parent-destroyed cascade leaves the + // pointer null instead of dangling. + QPointer m_inspectorWindow; + OverlayScrollbar *m_scrollbar = nullptr; // floating scrollback scrollbar + bool m_scrollAtBottom = true; // viewport is following the buffer tail + bool m_notifyOnCommand = false; // one-shot: notify on next cmd finish + bool m_bellFlash = false; // bell `border` flash in progress + bool m_bellTitle = false; // unacknowledged bell `title` mark + // Tracks whether the prior inputMethodEvent reported active preedit. + // Used to distinguish a real post-composition commit (forward to the + // terminal) from the duplicate ASCII commit that Wayland's + // text-input-v3 fires alongside a keyPressEvent (drop it — the key + // event will deliver the same text). + bool m_hadPreedit = false; + std::atomic m_dirty{false}; // a frame render is pending +}; diff --git a/qt/src/GlobalShortcuts.cpp b/qt/src/GlobalShortcuts.cpp new file mode 100644 index 000000000..75e6a3caa --- /dev/null +++ b/qt/src/GlobalShortcuts.cpp @@ -0,0 +1,157 @@ +#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 + const QString path = requestPath(token); + m_requests.insert(path, method); + + QDBusMessage msg = QDBusMessage::createMethodCall( + QString::fromLatin1(kService), QString::fromLatin1(kPath), + QString::fromLatin1(kInterface), method); + msg.setArguments(args); + + // The real result arrives via the Response signal; watch the call + // itself so a failed invocation is not silently swallowed AND the + // m_requests entry is dropped (otherwise an errored portal call + // would leak a Request entry forever). + auto *watcher = new QDBusPendingCallWatcher( + QDBusConnection::sessionBus().asyncCall(msg), this); + connect(watcher, &QDBusPendingCallWatcher::finished, this, + [this, method, path](QDBusPendingCallWatcher *w) { + QDBusPendingReply reply = *w; + if (reply.isError()) { + std::fprintf(stderr, "[ghastty] portal %s failed: %s\n", + method.toUtf8().constData(), + reply.error().message().toUtf8().constData()); + m_requests.remove(path); + } + w->deleteLater(); + }); +} + +void GlobalShortcuts::onResponse(const QDBusMessage &message) { + const QString method = m_requests.take(message.path()); + if (method.isEmpty()) return; // not one of ours + const QVariantList args = message.arguments(); + if (args.isEmpty()) return; + const uint code = args.at(0).toUInt(); + const QVariantMap results = + args.size() > 1 ? qdbus_cast(args.at(1)) : QVariantMap(); + if (code != 0) + std::fprintf(stderr, "[ghastty] portal %s response code=%u\n", + method.toUtf8().constData(), code); + if (method == QLatin1String("CreateSession")) + handleCreateSession(code, results); +} + +void GlobalShortcuts::handleCreateSession(uint code, + const QVariantMap &results) { + if (code != 0) return; + m_sessionHandle = results.value(QStringLiteral("session_handle")).toString(); + if (m_sessionHandle.isEmpty()) return; + + // Declare the shortcuts; the desktop owns the actual key assignment + // (KDE System Settings -> Shortcuts). + // preferred_trigger uses MOD+keysym form (LOGO == Super); the desktop + // may honor it as the default key or let the user rebind it. + QList shortcuts; + shortcuts.append( + {QStringLiteral("toggle-quick-terminal"), + {{QStringLiteral("description"), + QStringLiteral("Toggle the Ghastty quick terminal")}, + {QStringLiteral("preferred_trigger"), QStringLiteral("LOGO+grave")}}}); + shortcuts.append( + {QStringLiteral("toggle-visibility"), + {{QStringLiteral("description"), + QStringLiteral("Toggle Ghastty window visibility")}}}); + + portalCall(QStringLiteral("BindShortcuts"), + {QVariant::fromValue(QDBusObjectPath(m_sessionHandle)), + QVariant::fromValue(shortcuts), QString()}, + {}); +} + +void GlobalShortcuts::onActivated(const QDBusMessage &message) { + // Activated(o session_handle, s shortcut_id, t timestamp, a{sv} options) + const QVariantList args = message.arguments(); + if (args.size() < 2) return; + emit activated(args.at(1).toString()); +} 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/InspectorWindow.cpp b/qt/src/InspectorWindow.cpp new file mode 100644 index 000000000..25bc3bd3f --- /dev/null +++ b/qt/src/InspectorWindow.cpp @@ -0,0 +1,230 @@ +#include "InspectorWindow.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Util.h" + +namespace { + +// The editing/navigation keys an ImGui text field needs; other keys +// arrive as text via ghostty_inspector_text. +ghostty_input_key_e translateKey(int key) { + switch (key) { + case Qt::Key_Backspace: return GHOSTTY_KEY_BACKSPACE; + case Qt::Key_Delete: return GHOSTTY_KEY_DELETE; + case Qt::Key_Return: + case Qt::Key_Enter: return GHOSTTY_KEY_ENTER; + case Qt::Key_Tab: return GHOSTTY_KEY_TAB; + case Qt::Key_Escape: return GHOSTTY_KEY_ESCAPE; + case Qt::Key_Home: return GHOSTTY_KEY_HOME; + case Qt::Key_End: return GHOSTTY_KEY_END; + case Qt::Key_Left: return GHOSTTY_KEY_ARROW_LEFT; + case Qt::Key_Right: return GHOSTTY_KEY_ARROW_RIGHT; + case Qt::Key_Up: return GHOSTTY_KEY_ARROW_UP; + case Qt::Key_Down: return GHOSTTY_KEY_ARROW_DOWN; + default: return GHOSTTY_KEY_UNIDENTIFIED; + } +} + +} // namespace + +InspectorWindow::InspectorWindow(ghostty_surface_t surface) + : m_surface(surface) { + setWindowTitle(QStringLiteral("Ghastty Inspector")); + setFocusPolicy(Qt::StrongFocus); + setMouseTracking(true); + resize(800, 600); + + m_inspector = ghostty_surface_inspector(m_surface); + + // ~30fps: ImGui is immediate-mode, so it must re-render to reflect + // hover and animation, not just on explicit RENDER_INSPECTOR actions. + m_timer = new QTimer(this); + connect(m_timer, &QTimer::timeout, this, &InspectorWindow::renderFrame); + m_timer->start(33); +} + +InspectorWindow::~InspectorWindow() { + // ghostty_inspector_free runs the ImGui OpenGL backend shutdown + // (ImGui_ImplOpenGL3_ShutdownWithLoaderCleanup), which needs the GL + // context current. Keep the context current across that call, then + // release it. + // + // If makeCurrent fails (display already gone on Wayland teardown, + // GPU reset, or the ctor failed before m_context was created) and + // the OpenGL backend had been initialized (m_glReady), the backend + // shutdown will run without a current context — accepting a + // GL-side resource leak. The process is exiting; the OS reclaims. + const bool current = m_inspector && makeCurrent(); + if (current) { + ghostty_inspector_opengl_shutdown(m_inspector); + delete m_fbo; + } else if (m_glReady) { + std::fprintf(stderr, + "[ghastty] inspector dtor: GL context unavailable; " + "leaking inspector GL resources at shutdown\n"); + } + if (m_surface) ghostty_inspector_free(m_surface); + if (current) m_context->doneCurrent(); + delete m_offscreen; +} + +bool InspectorWindow::makeCurrent() { + if (!m_context) { + m_context = new QOpenGLContext(this); + m_context->setFormat(QSurfaceFormat::defaultFormat()); + if (!m_context->create()) return false; + m_offscreen = new QOffscreenSurface; + m_offscreen->setFormat(m_context->format()); + m_offscreen->create(); + } + return m_context->makeCurrent(m_offscreen); +} + +void InspectorWindow::syncSize() { + if (!m_inspector) return; + const qreal dpr = devicePixelRatioF(); + // updateContentScale rebuilds the ImGui style, so only push it when + // the scale actually changes; set_size already ignores no-op resizes. + if (dpr != m_lastDpr) { + ghostty_inspector_set_content_scale(m_inspector, dpr, dpr); + m_lastDpr = dpr; + } + ghostty_inspector_set_size(m_inspector, + static_cast(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::closeEvent(QCloseEvent *e) { + // Hide rather than destroy: the owning GhosttySurface keeps a + // QPointer to this window across show/hide cycles. The window is + // deleted only when the surface is destroyed. Stop the redraw + // timer too — a hidden inspector has no work to do. + if (m_timer) m_timer->stop(); + hide(); + e->ignore(); +} + +void InspectorWindow::showEvent(QShowEvent *e) { + QWidget::showEvent(e); + if (m_timer && !m_timer->isActive()) m_timer->start(33); +} + +void InspectorWindow::sendMouseButton(QMouseEvent *ev, + ghostty_input_mouse_state_e state) { + if (!m_inspector) return; + ghostty_input_mouse_button_e button; + switch (ev->button()) { + case Qt::LeftButton: button = GHOSTTY_MOUSE_LEFT; break; + case Qt::RightButton: button = GHOSTTY_MOUSE_RIGHT; break; + case Qt::MiddleButton: button = GHOSTTY_MOUSE_MIDDLE; break; + default: button = GHOSTTY_MOUSE_UNKNOWN; break; + } + ghostty_inspector_mouse_button(m_inspector, state, button, + translateMods(ev->modifiers())); +} + +void InspectorWindow::mouseMoveEvent(QMouseEvent *ev) { + if (m_inspector) + ghostty_inspector_mouse_pos(m_inspector, ev->position().x(), + ev->position().y()); +} + +void InspectorWindow::mousePressEvent(QMouseEvent *ev) { + sendMouseButton(ev, GHOSTTY_MOUSE_PRESS); +} + +void InspectorWindow::mouseReleaseEvent(QMouseEvent *ev) { + sendMouseButton(ev, GHOSTTY_MOUSE_RELEASE); +} + +void InspectorWindow::wheelEvent(QWheelEvent *ev) { + if (!m_inspector) return; + const QPoint d = ev->angleDelta(); + ghostty_inspector_mouse_scroll(m_inspector, d.x() / 120.0, d.y() / 120.0, + 0); +} + +void InspectorWindow::keyPressEvent(QKeyEvent *ev) { + if (!m_inspector) return; + const ghostty_input_key_e key = translateKey(ev->key()); + if (key != GHOSTTY_KEY_UNIDENTIFIED) + ghostty_inspector_key(m_inspector, GHOSTTY_ACTION_PRESS, key, + translateMods(ev->modifiers())); + // Printable text drives ImGui's text input. Reject the whole string + // if any byte is a C0 control or DEL — checking only the first byte + // would let multi-char text starting with a printable then a newline + // sneak control codes through. + const QByteArray text = ev->text().toUtf8(); + if (text.isEmpty()) return; + for (char c : text) { + const auto u = static_cast(c); + if (u < 0x20 || u == 0x7f) return; + } + ghostty_inspector_text(m_inspector, text.constData()); +} + +void InspectorWindow::keyReleaseEvent(QKeyEvent *ev) { + if (!m_inspector) return; + const ghostty_input_key_e key = translateKey(ev->key()); + if (key != GHOSTTY_KEY_UNIDENTIFIED) + ghostty_inspector_key(m_inspector, GHOSTTY_ACTION_RELEASE, key, + translateMods(ev->modifiers())); +} + +void InspectorWindow::focusInEvent(QFocusEvent *) { + if (m_inspector) ghostty_inspector_set_focus(m_inspector, true); +} + +void InspectorWindow::focusOutEvent(QFocusEvent *) { + if (m_inspector) ghostty_inspector_set_focus(m_inspector, false); +} diff --git a/qt/src/InspectorWindow.h b/qt/src/InspectorWindow.h new file mode 100644 index 000000000..7a53d3a54 --- /dev/null +++ b/qt/src/InspectorWindow.h @@ -0,0 +1,67 @@ +#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. +// +// The inspector window is shown via a normal Qt::Widget close (WM +// close button), which is treated as "hide", not "destroy" — the +// owning surface keeps a QPointer to it and toggles visibility. The +// window only deletes when its owning GhosttySurface is destroyed. +class InspectorWindow : public QWidget { + Q_OBJECT + +public: + // `surface` is the terminal surface being inspected. + explicit InspectorWindow(ghostty_surface_t surface); + ~InspectorWindow() override; + +protected: + // Treat the WM close button as a hide rather than a destroy. The + // GhosttySurface owns the inspector's lifetime; closing here would + // dangle its QPointer and skip libghostty inspector teardown. + void closeEvent(QCloseEvent *) override; + // Restart the redraw timer when the inspector becomes visible + // again; closeEvent stops it to avoid CPU waste while hidden. + void showEvent(QShowEvent *) override; + void paintEvent(QPaintEvent *) override; + void resizeEvent(QResizeEvent *) override; + void mouseMoveEvent(QMouseEvent *) override; + void mousePressEvent(QMouseEvent *) override; + void mouseReleaseEvent(QMouseEvent *) override; + void wheelEvent(QWheelEvent *) override; + void keyPressEvent(QKeyEvent *) override; + void keyReleaseEvent(QKeyEvent *) override; + void focusInEvent(QFocusEvent *) override; + void focusOutEvent(QFocusEvent *) override; + +private: + bool makeCurrent(); + void renderFrame(); // render the inspector, read it back + void syncSize(); // push the size/scale to libghostty + void sendMouseButton(QMouseEvent *, ghostty_input_mouse_state_e state); + + ghostty_surface_t m_surface; + ghostty_inspector_t m_inspector = nullptr; + + // Private offscreen GL context the inspector renders into. + QOpenGLContext *m_context = nullptr; + QOffscreenSurface *m_offscreen = nullptr; + QOpenGLFramebufferObject *m_fbo = nullptr; + QImage m_image; // last frame, read back from m_fbo + QTimer *m_timer = nullptr; // drives ~30fps redraws while visible + bool m_glReady = false; // ghostty_inspector_opengl_init done + double m_lastDpr = 0; // last pushed content scale +}; diff --git a/qt/src/MainWindow.cpp b/qt/src/MainWindow.cpp new file mode 100644 index 000000000..8697ef167 --- /dev/null +++ b/qt/src/MainWindow.cpp @@ -0,0 +1,1697 @@ +#include "MainWindow.h" + +#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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#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). +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; +MainWindow *MainWindow::s_quickTerminal = nullptr; +QTimer *MainWindow::s_frameTimer = nullptr; +std::atomic MainWindow::s_tickPending{false}; + +MainWindow::MainWindow() { + setWindowTitle(QStringLiteral("Ghastty")); + // Let a translucent terminal background show through to the desktop. + setAttribute(Qt::WA_TranslucentBackground); + + m_tabs = new TabWidget(this); + 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); + // 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); + layout->addWidget(m_tabs); + + connect(m_tabs, &QTabWidget::tabCloseRequested, this, + &MainWindow::onTabCloseRequested); + connect(m_tabs, &QTabWidget::currentChanged, this, + &MainWindow::onCurrentChanged); + connect(m_tabs, &TabWidget::tabTornOff, this, &MainWindow::detachTab); +} + +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. + qDeleteAll(m_surfaces); + m_surfaces.clear(); + + // 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. The QObject destructor + // unparents it from qApp. + s_frameTimer->stop(); + delete s_frameTimer; + s_frameTimer = nullptr; + } + if (s_quitTimer) { + 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; + } + if (s_config) { + ghostty_config_free(s_config); + s_config = nullptr; + } + } +} + +// 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; +} + +// 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( + QStringLiteral("org.freedesktop.Notifications"), + QStringLiteral("/org/freedesktop/Notifications"), + QStringLiteral("org.freedesktop.Notifications"), + QStringLiteral("Notify")); + msg.setArguments({ + QStringLiteral("Ghastty"), // app_name + uint(0), // replaces_id + QStringLiteral("ghastty"), // app_icon + title, // summary + body, // body + QStringList(), // actions + QVariantMap(), // hints + -1, // expire_timeout (default) + }); + 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 ghastty.desktop. +static void postProgress(bool visible, double fraction) { + QDBusMessage msg = QDBusMessage::createSignal( + 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://ghastty.desktop"), QVariant(props)}); + QDBusConnection::sessionBus().send(msg); +} + +bool MainWindow::initialize() { + s_windows.append(this); + + // 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(); + + 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, "[ghastty] 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; + configGet(s_config, &delayNs, "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(). 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(); + + // 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; +} + +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); + + // 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(); }); + + // 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) { + // 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(m_firstTabParent); + m_firstTabParent = nullptr; +} + +GhosttySurface *MainWindow::newTab(ghostty_surface_t 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). + // It stays opaque chrome; the GhosttySurface paints over it. + auto *page = new QWidget(m_tabs); + auto *pageLayout = new QVBoxLayout(page); + pageLayout->setContentsMargins(0, 0, 0, 0); + pageLayout->addWidget(surface); + + // 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("Ghastty")); + else + index = m_tabs->addTab(page, QStringLiteral("Ghastty")); + m_tabs->setCurrentIndex(index); + surface->setFocus(); + return surface; +} + +GhosttySurface *MainWindow::splitSurface( + GhosttySurface *target, ghostty_action_split_direction_e dir) { + 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(s_app, this, target->surface()); + auto *splitter = + new QSplitter(horizontal ? Qt::Horizontal : Qt::Vertical); + splitter->setChildrenCollapsible(false); + + // Insert `splitter` where `target` currently sits in the tree. + QWidget *parent = target->parentWidget(); + if (auto *parentSplitter = qobject_cast(parent)) { + parentSplitter->replaceWidget(parentSplitter->indexOf(target), splitter); + } else if (parent && parent->layout()) { + delete parent->layout()->replaceWidget(target, splitter); + } else { + delete splitter; + delete surface; + return nullptr; + } + + if (newAfter) { + splitter->addWidget(target); + splitter->addWidget(surface); + } else { + splitter->addWidget(surface); + splitter->addWidget(target); + } + splitter->setSizes({1 << 20, 1 << 20}); // start the panes roughly equal + + m_surfaces.append(surface); + surface->setFocus(); + return surface; +} + +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. + QWidget *sibling = nullptr; + for (int i = 0; i < splitter->count(); ++i) + if (splitter->widget(i) != surface) 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); + } + // 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; + } + + // 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; destroys the surface too + // 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) { + QWidget *page = m_tabs->widget(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) { + m_skipCloseConfirm = true; + close(); + } +} + +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. + 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); + } + + 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 + 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); + if (index < 0) return; + // Store the terminal title as the tab's base; updateTabText decides + // whether it or a manual override is shown. + TabData data = m_tabs->tabBar()->tabData(index).value(); + data.base = title; + m_tabs->tabBar()->setTabData(index, QVariant::fromValue(data)); + updateTabText(index); +} + +void MainWindow::setTabTitleOverride(GhosttySurface *surface, + const QString &title) { + const int index = tabIndexForSurface(surface); + if (index < 0) return; + 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 TabData data = m_tabs->tabBar()->tabData(tab).value(); + const QString title = + !data.override_.isEmpty() ? data.override_ : data.base; + if (!title.isEmpty()) QGuiApplication::clipboard()->setText(title); +} + +void MainWindow::frame() { + 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. + // 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) { + 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::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::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 = {}; + 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) { + 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); + // 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); +} + +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. + if (s_quitDelayMs <= 0) return; + if (start) { + if (!s_quitTimer) { + // 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); + } + s_quitTimer->start(s_quitDelayMs); + } else if (s_quitTimer) { + s_quitTimer->stop(); + } +} + +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); + updateTabText(index); +} + +GhosttySurface *MainWindow::surfaceAt(int index) const { + QWidget *page = m_tabs->widget(index); + if (!page) return nullptr; + const auto surfaces = page->findChildren(); + return surfaces.isEmpty() ? nullptr : surfaces.first(); +} + +int MainWindow::tabIndexForSurface(GhosttySurface *surface) const { + for (int i = 0; i < m_tabs->count(); ++i) + if (m_tabs->widget(i)->isAncestorOf(surface)) return i; + return -1; +} + +QList MainWindow::surfacesInTab(int index) const { + QWidget *page = m_tabs->widget(index); + if (!page) return {}; + return page->findChildren(); +} + +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; + + const auto centerOf = [](GhosttySurface *s) { + return QRect(s->mapToGlobal(QPoint(0, 0)), s->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->setFocus(); +} + +void MainWindow::resizeSplit(GhosttySurface *from, + ghostty_action_resize_split_s rs) { + auto *splitter = qobject_cast(from->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(from); + 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); + } +} + +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); +} + +void MainWindow::ringBell(GhosttySurface *surface) { + // 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 & 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()) { + surface->setBellTitle(true); + updateTabText(tab); + } + } +} + +bool MainWindow::tabBellMarked(int tab) const { + for (GhosttySurface *s : surfacesInTab(tab)) + if (s->bellTitle()) return true; + return false; +} + +void MainWindow::updateTabText(int tab) { + if (tab < 0 || tab >= m_tabs->count()) return; + 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")); +} + +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); + // 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(); +} + +// 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(); + } +} + +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); + + // 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 { + const char *value = nullptr; + 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 (s_config) ghostty_config_get(s_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): 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` + // 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 (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; + } + } + QGuiApplication::styleHints()->setColorScheme(scheme); +#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 + // uses its own configured radius, so only on/off matters here. + short blur = 0; + configGet(s_config, &blur, "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. + 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 *) { + // 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. 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, + []() { + s_tickPending.store(false); + if (s_app) ghostty_app_tick(s_app); + }, + 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) { + 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, ... + } +} + +// 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, + ghostty_action_s action) { + // 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; + // *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. + switch (action.tag) { + case GHOSTTY_ACTION_RENDER: + // 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: { + if (!win) return false; + // `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: + 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; + post(win, [winp, srcp, dir]() { + if (winp && srcp) winp->splitSurface(srcp, dir); + }); + return true; + } + + case GHOSTTY_ACTION_CLOSE_TAB: + if (src) + 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); + post(win, [winp, srcp, t]() { + if (winp && srcp) winp->setSurfaceTitle(srcp, t); + }); + 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 : ""); + post(win, [winp, srcp, t]() { + if (winp && srcp) winp->setTabTitleOverride(srcp, t); + }); + return true; + } + + case GHOSTTY_ACTION_PROMPT_TITLE: { + if (!src) return true; + const bool tabScope = + action.action.prompt_title == GHOSTTY_PROMPT_TITLE_TAB; + post(src, [srcp, tabScope]() { + if (srcp) srcp->promptTitle(tabScope); + }); + return true; + } + + case GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD: + post(win, [winp]() { + if (winp) winp->copyTitleToClipboard(); + }); + return true; + + case GHOSTTY_ACTION_RESET_WINDOW_SIZE: + 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) { + post(src, [srcp]() { + if (srcp) srcp->endKeySequence(); + }); + return true; + } + const QString chord = formatTrigger(ks.trigger); + 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; + 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; + 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; + post(win, [winp, srcp, rs]() { + if (winp && srcp) winp->resizeSplit(srcp, rs); + }); + return true; + } + + case GHOSTTY_ACTION_EQUALIZE_SPLITS: + if (src) + post(win, [winp, srcp]() { + if (winp && srcp) winp->equalizeSplits(srcp); + }); + return true; + + case GHOSTTY_ACTION_TOGGLE_FULLSCREEN: + if (!win) return false; + 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; + 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: + post(qApp, []() { MainWindow::closeAllWindows(); }); + return true; + + case GHOSTTY_ACTION_QUIT_TIMER: { + const bool start = + action.action.quit_timer == GHOSTTY_QUIT_TIMER_START; + post(qApp, [start]() { MainWindow::handleQuitTimer(start); }); + return true; + } + + case GHOSTTY_ACTION_SHOW_CHILD_EXITED: { + if (!src) return false; + const int code = + static_cast(action.action.child_exited.exit_code); + post(src, [srcp, code]() { + if (srcp) srcp->showChildExited(code); + }); + return true; + } + + case GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM: + if (src) + post(win, [winp, srcp]() { + if (winp && srcp) winp->toggleSplitZoom(srcp); + }); + return true; + + case GHOSTTY_ACTION_OPEN_CONFIG: { + // 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)); + post(qApp, + [p]() { QDesktopServices::openUrl(QUrl::fromLocalFile(p)); }); + } + ghostty_string_free(path); + return true; + } + + case GHOSTTY_ACTION_RELOAD_CONFIG: + 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. + 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; + 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: + post(win, [winp]() { + if (winp) winp->close(); + }); + return true; + + case GHOSTTY_ACTION_RING_BELL: + 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); + post(src, [srcp, shape]() { + if (srcp) srcp->setCursor(shape); + }); + 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(); + post(src, [srcp, url]() { + if (srcp) srcp->setToolTip(url); + }); + 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)); + post(qApp, [s]() { + QDesktopServices::openUrl( + QUrl::fromUserInput(s, QString(), QUrl::AssumeLocalFile)); + }); + 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 : ""); + 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; + 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); + post(win, [winp, amount]() { + if (winp) winp->moveTab(amount); + }); + return true; + } + + case GHOSTTY_ACTION_MOUSE_VISIBILITY: { + if (!src) return false; + const bool hidden = + action.action.mouse_visibility == GHOSTTY_MOUSE_HIDDEN; + 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, "[ghastty] renderer reported unhealthy\n"); + return true; + + case GHOSTTY_ACTION_SCROLLBAR: { + if (!src) return false; + const ghostty_action_scrollbar_s s = action.action.scrollbar; + post(src, [srcp, s]() { + if (srcp) srcp->updateScrollbar(s.total, s.offset, s.len); + }); + 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; + post(qApp, [visible, fraction]() { postProgress(visible, fraction); }); + return true; + } + + case GHOSTTY_ACTION_TOGGLE_VISIBILITY: + post(qApp, []() { MainWindow::toggleVisibility(); }); + return true; + + case GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL: + post(qApp, []() { MainWindow::toggleQuickTerminal(); }); + return true; + + case GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE: + 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 : ""); + post(src, [srcp, n]() { + if (srcp) srcp->openSearch(n); + }); + return true; + } + + case GHOSTTY_ACTION_END_SEARCH: + if (src) + 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); + post(src, [srcp, total]() { + if (srcp) srcp->setSearchTotal(total); + }); + return true; + } + + case GHOSTTY_ACTION_SEARCH_SELECTED: { + if (!src) return true; + const int sel = + static_cast(action.action.search_selected.selected); + 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; + post(src, [srcp, mode]() { + if (srcp) srcp->toggleInspector(mode); + }); + return true; + } + + default: + return false; + } +} + +bool MainWindow::onReadClipboard(void *ud, ghostty_clipboard_e loc, + void *state) { + // surface userdata. Called synchronously when libghostty needs + // 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 (!surfaceAlive(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) { + // 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 (!surfaceAlive(surface) || !surface->surface()) return; + + QPointer sp(surface); + const QByteArray content(str); + QMetaObject::invokeMethod( + surface->owner(), + [sp, content, state]() { + if (!sp || !sp->surface()) return; + QString preview = QString::fromUtf8(content); + // 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( + 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( + sp->surface(), content.constData(), state, + reply == QMessageBox::Yes); + }, + Qt::QueuedConnection); +} + +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 (!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( + qApp, + [text, mode]() { QGuiApplication::clipboard()->setText(text, mode); }, + Qt::QueuedConnection); +} + +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 (!surfaceAlive(surface)) return; + MainWindow *self = surface->owner(); + QPointer selfp(self); + QPointer sp(surface); + QMetaObject::invokeMethod( + self, + [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 new file mode 100644 index 000000000..d609df8b4 --- /dev/null +++ b/qt/src/MainWindow.h @@ -0,0 +1,236 @@ +#pragma once + +#include + +#include +#include +#include + +#include "ghostty.h" + +class QAudioOutput; +class QCloseEvent; +class QMediaPlayer; +class QShowEvent; +class QSplitter; +class TabWidget; +class QTimer; +class CommandPalette; +class GhosttySurface; + +// A top-level window presenting terminal surfaces as tabs; each tab may +// be subdivided into splits. The libghostty app and config are shared +// process-wide across every window (the static s_* members below). +// +// Widget tree: QTabWidget -> tab page (QWidget) -> split tree, where a +// node is either a GhosttySurface (a QOpenGLWidget) or a QSplitter of +// two such nodes. +class MainWindow : public QWidget { + Q_OBJECT + +public: + MainWindow(); + ~MainWindow() override; + + // Per-window setup. The first call also creates the shared libghostty + // app and config; later windows reuse them. Call once before show(). + bool initialize(); + + // Open a new top-level window, sharing the libghostty app, with one + // tab whose surface inherits from `parent` (may be null). + static MainWindow *newWindow(ghostty_surface_t parent); + + // Show or hide every window at once (TOGGLE_VISIBILITY). + static void toggleVisibility(); + + // Show/hide the dropdown quick terminal, creating it on first use + // (TOGGLE_QUICK_TERMINAL). There is at most one per process. + static void toggleQuickTerminal(); + + // Open a new tab. `parent` (may be null) is the surface whose working + // directory etc. the new surface should inherit. + GhosttySurface *newTab(ghostty_surface_t parent); + + // Split `target`'s pane in two, adding a new surface beside it. + GhosttySurface *splitSurface(GhosttySurface *target, + ghostty_action_split_direction_e dir); + + // Remove a single surface: collapses its split, or closes the tab if + // it was the tab's only surface (and the window if it was the last). + void removeSurface(GhosttySurface *surface); + + // Update the tab label and window title for `surface`. + void setSurfaceTitle(GhosttySurface *surface, const QString &title); + + // The live libghostty config (for keybind lookups, etc.). + ghostty_config_t config() const { return s_config; } + + // Whether a custom shader is configured. With one, libghostty's final + // framebuffer is non-premultiplied and surfaces must premultiply it + // before Qt composites (see GhosttySurface::premultiplyFramebuffer). + bool needsPremultiply() const { return s_needsPremultiply; } + + // Whether `focus-follows-mouse` is enabled — a GhosttySurface grabs + // focus when the pointer enters it. + bool focusFollowsMouse() const; + +protected: + bool event(QEvent *) override; + void showEvent(QShowEvent *) override; + // Honors `confirm-close-surface`: prompts if a surface has a running + // process, and ignores the event if the user declines. + void closeEvent(QCloseEvent *) override; + // Drives quick-terminal autohide on loss of activation. + void changeEvent(QEvent *) override; + +private slots: + void onTabCloseRequested(int index); + void onCurrentChanged(int index); + +private: + // Create the first tab once the device pixel ratio has settled. + void createFirstTab(); + + // 60fps frame timer body. Static because there is only one timer + // per process — N windows pointing at the same shared ghostty_app_t. + // Ticks libghostty once and renders any dirty surface across every + // window. + static void frame(); + + void closeTab(int index); + // Tear tab `index` out into a new window (tabTornOff signal). + void detachTab(int index); + // Move `page` (a tab and its surfaces) from `src` into this window. + void adoptTab(MainWindow *src, QWidget *page); + GhosttySurface *surfaceAt(int index) const; + int tabIndexForSurface(GhosttySurface *surface) const; + QList surfacesInTab(int index) const; + + // Keybind-driven navigation between tabs and split panes. + void gotoTab(ghostty_action_goto_tab_e tab); + void gotoSplit(GhosttySurface *from, ghostty_action_goto_split_e dir); + void resizeSplit(GhosttySurface *from, ghostty_action_resize_split_s rs); + void equalizeSplits(GhosttySurface *from); + void moveTab(int amount); // reorder the current tab by `amount` + + // Ring the terminal bell, honoring the `bell-features` config. + void ringBell(GhosttySurface *surface); + void playBellAudio(); + + // Bell `title` feature: prefix a tab's title while any surface in it + // has an unacknowledged bell. + bool tabBellMarked(int tab) const; + + // Recompute a tab's displayed text from its stored base (terminal) + // title and manual override, plus any bell mark. Tab data holds a + // {base, override} QStringList. + void updateTabText(int tab); + // Set/clear a tab's manual title override (empty string clears it); + // while set, SET_TITLE no longer changes the tab text. + void setTabTitleOverride(GhosttySurface *surface, const QString &title); + // Copy the current tab's effective title to the clipboard. + void copyTitleToClipboard(); + + // Rebuild the config from disk and push it to libghostty. + void reloadConfig(); + // Refresh every window's chrome from the current config (used after a + // reload and on the CONFIG_CHANGE notification). + static void refreshChrome(); + + // Typed wrappers over ghostty_config_get. configString also serves + // enum keys — libghostty returns an enum as its tag name string. + QString configString(const char *key) const; + bool configBool(const char *key, bool fallback) const; + + // Apply config-driven window settings that may change on reload: the + // tab-bar visibility policy and the light/dark colour scheme. + void applyWindowConfig(); + + // Apply the `background-blur` config to this window via the KWin + // compositor (see WindowBlur). + void applyBlur(); + + // Turn this window into a layer-shell dropdown anchored to a screen + // edge, per the `quick-terminal-*` config. Quick-terminal only. + void setupLayerShell(); + + // Show/hide the command palette (TOGGLE_COMMAND_PALETTE), scoped to + // `surface` for executing the chosen command. + void toggleCommandPalette(GhosttySurface *surface); + + // Prompt (per `confirm-close-surface`) before closing `surfaces`. + // Returns true if the close may proceed. + bool confirmCloseSurfaces(const QList &surfaces); + + // Close every window, optionally quitting the process; prompts once + // via ghostty_app_needs_confirm_quit. + static void closeAllWindows(); + + // Wire the libghostty quit_timer action to a delayed QApplication + // quit, gated on `quit-after-last-window-closed`. + static void handleQuitTimer(bool start); + + // Toggle a split pane filling its tab. Re-parents the surface out of + // / back into the splitter tree. + void toggleSplitZoom(GhosttySurface *surface); + + // Runtime callbacks dispatched by libghostty. wakeup/action are + // app-level (routed via the target surface or s_windows); clipboard/ + // close carry the surface userdata. + static void onWakeup(void *ud); + static bool onAction(ghostty_app_t, ghostty_target_s, ghostty_action_s); + static bool onReadClipboard(void *ud, ghostty_clipboard_e, void *state); + static void onConfirmReadClipboard(void *ud, const char *, void *state, + ghostty_clipboard_request_e); + static void onWriteClipboard(void *ud, ghostty_clipboard_e, + const ghostty_clipboard_content_s *, size_t, + bool); + static void onCloseSurface(void *ud, bool process_active); + + // True if `s` is still owned by some live MainWindow. The surface + // userdata callbacks above use this to validate a libghostty-supplied + // pointer before dereferencing — a worker-thread callback can race + // the GhosttySurface destructor. + static bool surfaceAlive(GhosttySurface *s); + + TabWidget *m_tabs = nullptr; + QList m_surfaces; // every live surface in this window + bool m_firstTabPending = true; // first tab is created on show() + ghostty_surface_t m_firstTabParent = nullptr; // inherited by the 1st tab + bool m_skipCloseConfirm = false; // close already confirmed elsewhere + bool m_quickTerminal = false; // this is the dropdown quick terminal + QSize m_defaultWindowSize; // for RESET_WINDOW_SIZE; from INITIAL_SIZE + + // Process-shared libghostty state: one app and config drive every + // window. Created by the first initialize(), freed with the last + // window. s_windows tracks every live window. + static ghostty_app_t s_app; + static ghostty_config_t s_config; + static bool s_needsPremultiply; // a custom shader is configured + static QList s_windows; + static QTimer *s_quitTimer; // delayed quit-after-last-window + static int s_quitDelayMs; // 0 = no delay configured + static MainWindow *s_quickTerminal; // the one quick terminal, if any + // Process-wide 60Hz frame timer. Replaces a per-window timer, so N + // windows do not produce N ghostty_app_tick calls every 16ms for the + // same shared app. + static QTimer *s_frameTimer; + + // Coalesces wakeup-driven ticks: a tick is queued at most once at a + // time, so a busy surface can't flood the event loop. + static std::atomic s_tickPending; + + // Split-zoom state: the surface temporarily filling its tab, the + // splitter it came from, its index there, and the stashed tree root. + GhosttySurface *m_zoomed = nullptr; + QWidget *m_zoomRoot = nullptr; + QSplitter *m_zoomSplitter = nullptr; + int m_zoomIndex = 0; + + // Bell audio playback; created lazily on the first audio bell. + QMediaPlayer *m_bellPlayer = nullptr; + QAudioOutput *m_bellAudio = nullptr; + + // The command palette; created lazily on first use. + CommandPalette *m_commandPalette = nullptr; +}; diff --git a/qt/src/OverlayScrollbar.cpp b/qt/src/OverlayScrollbar.cpp new file mode 100644 index 000000000..293c624c4 --- /dev/null +++ b/qt/src/OverlayScrollbar.cpp @@ -0,0 +1,173 @@ +#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; + // Repaint when visible OR while a fade-out is in flight; the handle + // position changes constantly with output, and skipping the update + // makes the fading scrollbar lag behind the actual scrollback. + if (isVisible() || m_opacity > 0.0) update(); +} + +void OverlayScrollbar::fadeTo(qreal target, int ms) { + m_fade->stop(); + m_fade->setDuration(ms); + m_fade->setStartValue(m_opacity); + m_fade->setEndValue(target); + m_fade->start(); +} + +void OverlayScrollbar::reveal() { + if (m_total <= m_len) return; // nothing to scroll + show(); + raise(); + fadeTo(1.0, 110); + m_hideTimer->start(); +} + +QRect OverlayScrollbar::handleRect() const { + if (m_total <= m_len) return {}; + const int trackH = height() - 2 * kMargin; + if (trackH <= 0) return {}; + + int handleH = static_cast(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; +}; diff --git a/qt/src/SearchBar.cpp b/qt/src/SearchBar.cpp new file mode 100644 index 000000000..31998af07 --- /dev/null +++ b/qt/src/SearchBar.cpp @@ -0,0 +1,177 @@ +#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 is the parent so it normally outlives the bar, but + // during a window teardown Qt may deliver this signal mid-cascade. + if (m_surface) m_surface->setFocus(); + }); + hide(); +} + +void SearchBar::open(const QString &prefill) { + m_total = -1; + m_selected = -1; + updateCount(); + show(); + raise(); + if (!prefill.isEmpty()) + m_field->setText(prefill); // textChanged → debounced query + m_field->setFocus(); + m_field->selectAll(); +} + +void SearchBar::setTotal(int total) { + m_total = total; + updateCount(); +} + +void SearchBar::setSelected(int selected) { + m_selected = selected; + updateCount(); +} + +void SearchBar::updateCount() { + QString text; + if (m_total == 0) + text = QStringLiteral("No results"); + else if (m_total > 0) + text = QStringLiteral("%1/%2") + .arg(m_selected > 0 ? m_selected : 0) + .arg(m_total); + m_count->setText(text); + m_count->adjustSize(); + positionCount(); +} + +void SearchBar::positionCount() { + const int pad = 6; + m_count->move(m_field->width() - m_count->width() - pad, + (m_field->height() - m_count->height()) / 2); + // Reserve room so typed text never slides under the counter. + const int reserve = + m_count->text().isEmpty() ? 0 : m_count->width() + pad + 2; + m_field->setTextMargins(0, 0, reserve, 0); +} + +void SearchBar::sendQuery() { + // An empty needle cancels the search, which libghostty handles. + const QByteArray q = + QByteArrayLiteral("search:") + m_field->text().toUtf8(); + if (m_surface && m_surface->surface()) + ghostty_surface_binding_action(m_surface->surface(), q.constData(), + q.size()); +} + +void SearchBar::navigate(bool next) { + runAction(next ? "navigate_search:next" : "navigate_search:previous"); +} + +void SearchBar::runAction(const char *action) { + if (m_surface && m_surface->surface()) + ghostty_surface_binding_action(m_surface->surface(), action, + qstrlen(action)); +} + +bool SearchBar::eventFilter(QObject *obj, QEvent *event) { + if (obj == m_field) { + if (event->type() == QEvent::Resize) { + positionCount(); + } else if (event->type() == QEvent::KeyPress) { + auto *ke = static_cast(event); + switch (ke->key()) { + case Qt::Key_Escape: + runAction("end_search"); + hide(); + if (m_surface) m_surface->setFocus(); + return true; + case Qt::Key_Return: + case Qt::Key_Enter: + // Enter advances; Shift+Enter goes back. + navigate(!(ke->modifiers() & Qt::ShiftModifier)); + return true; + default: + break; + } + } + } + return QFrame::eventFilter(obj, event); +} 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; +}; diff --git a/qt/src/TabWidget.cpp b/qt/src/TabWidget.cpp new file mode 100644 index 000000000..2dcd2d6a2 --- /dev/null +++ b/qt/src/TabWidget.cpp @@ -0,0 +1,199 @@ +#include "TabWidget.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +// MIME role carrying a tagged origin record so a receiving bar's +// dropEvent can mark the originator's m_dropHandled. Can't rely on +// QDrag::exec()'s return value on Wayland, and a process-wide "drop +// handled" flag races simultaneous tear-offs in different windows. +constexpr char kTearOffOriginRole[] = "application/x-ghastty-tab-origin"; + +// Process-local set of live TabBars so decodeOrigin can validate the +// pointer before dereferencing it (cross-process Wayland delivery, or +// the originating bar dying during drag->exec, would otherwise UAF). +QSet &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) { + OriginPayload p{QCoreApplication::applicationPid(), bar}; + QByteArray bytes(reinterpret_cast(&p), sizeof(p)); + return bytes; +} + +TabBar *decodeOrigin(const QByteArray &bytes) { + if (bytes.size() != sizeof(OriginPayload)) return nullptr; + OriginPayload p; + std::memcpy(&p, bytes.constData(), sizeof(p)); + if (p.pid != QCoreApplication::applicationPid()) return nullptr; + if (!liveTabBars().contains(p.bar)) return nullptr; + return p.bar; +} +} // namespace + +TabBar::TabBar(QWidget *parent) : QTabBar(parent) { + liveTabBars().insert(this); + // Truncate long titles with an ellipsis instead of letting a single + // tab consume the whole bar. Matches the upstream GTK frontend + // (Adw.TabBar, which clamps + ellipsizes) and macOS (Cocoa tabs use + // lineBreakMode = byTruncatingTail). + setElideMode(Qt::ElideRight); + // Tabs size to content (subject to the per-tab cap from tabSizeHint + // below), leaving room on the bar rather than expanding to fill. + setExpanding(false); + // When tabs still don't fit (many tabs, all near the cap), Qt + // shows left/right scroll arrows instead of shrinking each tab to + // an unreadable sliver. + setUsesScrollButtons(true); +} + +TabBar::~TabBar() { liveTabBars().remove(this); } + +QSize TabBar::tabSizeHint(int index) const { + // Cap at ~28em — wide enough for a typical "shell — repo (branch)" + // title, narrow enough that 5+ tabs fit on a 1280-px window without + // triggering scroll arrows. Below the cap, fall back to Qt's + // content-fit hint so short titles still get short tabs. + const QSize base = QTabBar::tabSizeHint(index); + const int cap = fontMetrics().averageCharWidth() * 28 + + // include the close-button width when the tab is + // closable, so the title clamp matches actual + // available text space. + (tabsClosable() ? 28 : 0); + if (base.width() <= cap) return base; + return QSize(cap, base.height()); +} + +void TabBar::mousePressEvent(QMouseEvent *e) { + if (e->button() == Qt::LeftButton) { + m_pressIndex = tabAt(e->position().toPoint()); + m_pressPos = e->position().toPoint(); + } + QTabBar::mousePressEvent(e); +} + +void TabBar::mouseMoveEvent(QMouseEvent *e) { + // While the pointer is on the bar, QTabBar reorders normally. Once it + // leaves the bar in any direction, hand off to a tear-off drag. + const bool leftBar = + m_pressIndex >= 0 && count() > 1 && !m_tearing && + !rect().adjusted(-20, -20, 20, 20).contains(e->position().toPoint()); + if (leftBar) { + startTearOff(e); + return; + } + QTabBar::mouseMoveEvent(e); +} + +void TabBar::mouseReleaseEvent(QMouseEvent *e) { + m_pressIndex = -1; + QTabBar::mouseReleaseEvent(e); +} + +void TabBar::startTearOff(QMouseEvent *e) { + m_tearing = true; + + // End QTabBar's internal move-drag so the tab settles back into the + // bar rather than staying "lifted". + QMouseEvent release(QEvent::MouseButtonRelease, e->position(), + e->globalPosition(), Qt::LeftButton, Qt::NoButton, + e->modifiers()); + QTabBar::mouseReleaseEvent(&release); + + // The dragged tab is the current one; its index is stable now that any + // in-bar reorder has settled. + const int index = currentIndex(); + if (index < 0) { + m_tearing = false; + return; + } + + // A snapshot of the tab follows the cursor for the duration of the drag. + const QRect tabBox = tabRect(index); + QDrag *drag = new QDrag(this); + auto *mime = new QMimeData; + mime->setData(QString::fromLatin1(kGhosttyTabMime), QByteArray()); + // Tag the drag with a pointer to this bar so the receiving bar's + // dropEvent can mark *our* m_dropHandled — a process-global flag + // would race with simultaneous tear-offs in other windows. + mime->setData(QString::fromLatin1(kTearOffOriginRole), encodeOrigin(this)); + drag->setMimeData(mime); + drag->setPixmap(grab(tabBox)); + drag->setHotSpot(m_pressPos - tabBox.topLeft()); + // A tear-off has no real drop target. A 1x1 transparent cursor + // suppresses the "forbidden" cursor Qt would otherwise show over + // non-accepting areas — releasing there is a valid outcome. + QPixmap blank(1, 1); + blank.fill(Qt::transparent); + drag->setDragCursor(blank, Qt::IgnoreAction); + drag->setDragCursor(blank, Qt::MoveAction); + + // Released on a tab bar cancels the tear-off; released anywhere else + // (the terminal, another window, the desktop) tears it into a new + // window. m_dropHandled — set by a TabBar::dropEvent on the + // originating bar — is the signal, since QDrag::exec()'s result is + // unreliable across surfaces on Wayland. + // + // drag->exec spins a nested event loop. Anything queued onto this + // bar's window — a libghostty close action, the user closing the + // window mid-drag — can `delete this` while exec runs. Watch our own + // lifetime via QPointer and bail out before any post-exec member + // access if we've been deleted. + m_dropHandled = false; + QPointer self(this); + drag->exec(Qt::MoveAction); + if (!self) return; + + m_tearing = false; + m_pressIndex = -1; + if (!m_dropHandled) emit tabTornOff(index); +} + +void TabBar::dragEnterEvent(QDragEnterEvent *e) { + if (e->mimeData()->hasFormat(QString::fromLatin1(kGhosttyTabMime))) + e->acceptProposedAction(); +} + +void TabBar::dropEvent(QDropEvent *e) { + // Dropping a tear-off back on a tab bar cancels it. Mark the flag on + // the *originating* bar (carried in the MIME payload), not this one + // — a tear-off can be dropped onto a different window's bar. + if (e->mimeData()->hasFormat(QString::fromLatin1(kGhosttyTabMime))) { + if (TabBar *origin = decodeOrigin( + e->mimeData()->data(QString::fromLatin1(kTearOffOriginRole)))) + origin->m_dropHandled = true; + else + m_dropHandled = true; // fallback: mark ourselves + e->acceptProposedAction(); + } +} + +TabWidget::TabWidget(QWidget *parent) : QTabWidget(parent) { + auto *bar = new TabBar(this); + bar->setAcceptDrops(true); // so a tear-off can be dropped back on it + setTabBar(bar); // protected on QTabWidget; accessible to this subclass + connect(bar, &TabBar::tabTornOff, this, &TabWidget::tabTornOff); +} diff --git a/qt/src/TabWidget.h b/qt/src/TabWidget.h new file mode 100644 index 000000000..6b18ebcb2 --- /dev/null +++ b/qt/src/TabWidget.h @@ -0,0 +1,77 @@ +#pragma once + +#include +#include +#include +#include +#include + +class QDragEnterEvent; +class QDropEvent; +class QMouseEvent; + +// MIME type marking a Ghastty tab tear-off drag. Recognised by tab bars +// (to cancel the tear-off) and by terminal surfaces (to accept the drag +// so no "forbidden" cursor is shown over a Ghastty window). +inline constexpr char kGhosttyTabMime[] = "application/x-ghastty-tab"; + +// Per-tab data stored in QTabBar::tabData. `base` is the terminal-set +// title (libghostty SET_TITLE); `override` is a manual user-set title +// (libghostty SET_TAB_TITLE). updateTabText shows override when set, +// otherwise base. +struct TabData { + QString base; + QString override_; // `override` is a reserved C++ identifier +}; +Q_DECLARE_METATYPE(TabData) + +// A QTabBar that tears a tab off into its own window when it is dragged +// clear of the bar. QTabBar's built-in movable behaviour still handles +// reordering within the bar; once the pointer leaves the bar a QDrag +// takes over so a snapshot of the tab follows the cursor. +class TabBar : public QTabBar { + Q_OBJECT + +public: + explicit TabBar(QWidget *parent = nullptr); + ~TabBar() override; + +signals: + // The tab was dragged off and released clear of its window. + void tabTornOff(int index); + +protected: + void mousePressEvent(QMouseEvent *) override; + void mouseMoveEvent(QMouseEvent *) override; + void mouseReleaseEvent(QMouseEvent *) override; + // Accept a tear-off drag dropped back on a tab bar (cancels it). + void dragEnterEvent(QDragEnterEvent *) override; + void dropEvent(QDropEvent *) override; + + // Cap a tab's width so a single long terminal title can't take the + // entire bar. Matches the GTK frontend's Adw.TabBar (which clamps + // width and ellipsizes) and the macOS Cocoa tabs (which use + // lineBreakMode = byTruncatingTail). Without this, Qt's default is + // "size to fit full text," and a long working-directory title + // pushes every other tab off-screen. + QSize tabSizeHint(int index) const override; + +private: + void startTearOff(QMouseEvent *e); + + int m_pressIndex = -1; // tab under the press, or -1 + QPoint m_pressPos; // press point, for the drag hot spot + bool m_tearing = false; // a tear-off QDrag is in progress + bool m_dropHandled = false; // a TabBar dropEvent caught our tear-off +}; + +// A QTabWidget wired to the tear-off-aware TabBar. +class TabWidget : public QTabWidget { + Q_OBJECT + +public: + explicit TabWidget(QWidget *parent = nullptr); + +signals: + void tabTornOff(int index); +}; diff --git a/qt/src/Util.cpp b/qt/src/Util.cpp new file mode 100644 index 000000000..2989ed49d --- /dev/null +++ b/qt/src/Util.cpp @@ -0,0 +1,52 @@ +#include "Util.h" + +#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: + 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..baae6b1ea --- /dev/null +++ b/qt/src/Util.h @@ -0,0 +1,51 @@ +#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(). +// +// 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 new file mode 100644 index 000000000..63a2a7bf5 --- /dev/null +++ b/qt/src/WindowBlur.cpp @@ -0,0 +1,153 @@ +#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; +} + +// 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; + 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 + + auto &blurs = waylandBlurs(); + // `take` returns and removes the prior blur if any. Knowing whether + // we're seeing this window for the first time decides whether we + // need a fresh `destroyed` connection — re-applying blur on an + // already-tracked window must NOT add a second connection. + const bool firstTime = !blurs.contains(window); + if (org_kde_kwin_blur *old = blurs.take(window)) + org_kde_kwin_blur_release(old); + + if (enabled) { + org_kde_kwin_blur *blur = org_kde_kwin_blur_manager_create(manager, + surface); + org_kde_kwin_blur_set_region(blur, nullptr); // null = whole surface + org_kde_kwin_blur_commit(blur); + blurs.insert(window, blur); + + // Release the blur object when the window goes away. Connect once + // per window — repeated applyBlur(window, true) calls would + // otherwise stack N stale lambdas on the destroyed signal. + if (firstTime) { + QObject::connect(window, &QWindow::destroyed, qApp, [window]() { + auto &b = waylandBlurs(); + if (org_kde_kwin_blur *old = b.take(window)) + org_kde_kwin_blur_release(old); + }); + } + } else { + org_kde_kwin_blur_manager_unset(manager, surface); + } + wl_display_flush(display); +} + +// --- X11 (_KDE_NET_WM_BLUR_BEHIND_REGION) ---------------------------- + +void applyX11(QWindow *window, bool enabled) { + QPlatformNativeInterface *native = QGuiApplication::platformNativeInterface(); + if (!native) return; + auto *conn = static_cast( + 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); diff --git a/qt/src/main.cpp b/qt/src/main.cpp new file mode 100644 index 000000000..462645b9a --- /dev/null +++ b/qt/src/main.cpp @@ -0,0 +1,76 @@ +#include + +#include +#include +#include + +#include "GlobalShortcuts.h" +#include "MainWindow.h" +#include "ghostty.h" + +int main(int argc, char **argv) { + // Use the display's true fractional scale rather than rounding it up + // (Wayland otherwise reports e.g. 2.0 for a 1.2x display, which scales + // the terminal up). + QGuiApplication::setHighDpiScaleFactorRoundingPolicy( + Qt::HighDpiScaleFactorRoundingPolicy::PassThrough); + + // Multiple GL surfaces compose reliably with a shared GL context. + QApplication::setAttribute(Qt::AA_ShareOpenGLContexts); + + // Ghostty's OpenGL renderer requires at least OpenGL 4.3 core. + QSurfaceFormat fmt; + fmt.setRenderableType(QSurfaceFormat::OpenGL); + fmt.setProfile(QSurfaceFormat::CoreProfile); + fmt.setVersion(4, 3); + fmt.setAlphaBufferSize(8); // allow a translucent terminal background + QSurfaceFormat::setDefaultFormat(fmt); + + QApplication app(argc, argv); + + // Match the installed ghastty.desktop: this becomes the Wayland app-id + // (and X11 WM_CLASS), so the compositor associates the window with the + // desktop entry — taskbar icon, launcher identity. + QGuiApplication::setDesktopFileName(QStringLiteral("ghastty")); + + // The window icon, embedded so it works even running from the build + // tree (when ghastty.desktop / the icon theme are not yet installed). + QGuiApplication::setWindowIcon(QIcon(QStringLiteral(":/ghastty.svg"))); + + // We keep the user's system widget style rather than forcing Fusion. + // Some styles dim and blur translucent windows, which masks the + // terminal's own background-opacity: Kvantum themes do this when + // `blurring`/`reduce_window_opacity` are set. The fix belongs in the + // style's config, not here — for Kvantum, add "ghostty" to the + // theme's `opaque` app list (the same opt-out video players use). + + // ghostty_init must run *after* QApplication: QApplication strips its + // own options (e.g. -style) out of argv in place, and libghostty later + // re-scans that array for CLI config — scanning the pre-strip array + // would walk past its end into freed/null entries. + if (ghostty_init(static_cast(argc), argv) != GHOSTTY_SUCCESS) { + std::fprintf(stderr, "[ghastty] ghostty_init failed\n"); + return 1; + } + + // The first window; further windows are opened on demand by the + // new_window action. Each window owns itself (WA_DeleteOnClose). + if (!MainWindow::newWindow(nullptr)) { + std::fprintf(stderr, "[ghastty] window initialization failed\n"); + return 1; + } + + // Register global shortcuts via the XDG portal so the quick terminal + // can be toggled while Ghostty is unfocused. Keys are assigned by the + // desktop (KDE System Settings -> Shortcuts). + GlobalShortcuts globalShortcuts; + QObject::connect(&globalShortcuts, &GlobalShortcuts::activated, + [](const QString &id) { + if (id == QLatin1String("toggle-quick-terminal")) + MainWindow::toggleQuickTerminal(); + else if (id == QLatin1String("toggle-visibility")) + MainWindow::toggleVisibility(); + }); + + return app.exec(); +} diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 7310159cc..67c3967ea 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. @@ -345,6 +352,7 @@ pub const App = struct { pub const Platform = union(PlatformTag) { macos: MacOS, ios: IOS, + opengl: OpenGL, // If our build target for libghostty is not darwin then we do // not include macos support at all. @@ -358,6 +366,35 @@ pub const Platform = union(PlatformTag) { uiview: objc.Object, } else void; + /// Configuration for a host that provides its own OpenGL context + /// (e.g. a Qt, X11, or Wayland application embedding libghostty). + /// + /// libghostty draws on the app (GUI) thread for the OpenGL renderer + /// (the embedded apprt sets `must_draw_from_app_thread` for OpenGL), + /// so these callbacks all run on the same thread that calls + /// `ghostty_surface_new` and `ghostty_surface_draw`. The context + /// only needs to be usable from that thread; it does not need to + /// be thread-portable. + pub const OpenGL = struct { + /// Userdata passed as the first argument to every callback. + userdata: ?*anyopaque, + + /// Resolve the address of an OpenGL function by name. + get_proc_address: *const fn ( + ?*anyopaque, + [*:0]const u8, + ) callconv(.c) ?*anyopaque, + + /// Make the host's OpenGL context current on the calling thread. + make_current: *const fn (?*anyopaque) callconv(.c) void, + + /// Release the host's OpenGL context from the calling thread. + release_current: *const fn (?*anyopaque) callconv(.c) void, + + /// Present the most recently rendered frame (e.g. swap buffers). + present: *const fn (?*anyopaque) callconv(.c) void, + }; + // The C ABI compatible version of this union. The tag is expected // to be stored elsewhere. pub const C = extern union { @@ -368,6 +405,17 @@ pub const Platform = union(PlatformTag) { ios: extern struct { uiview: ?*anyopaque, }, + + opengl: extern struct { + userdata: ?*anyopaque, + get_proc_address: ?*const fn ( + ?*anyopaque, + [*:0]const u8, + ) callconv(.c) ?*anyopaque, + make_current: ?*const fn (?*anyopaque) callconv(.c) void, + release_current: ?*const fn (?*anyopaque) callconv(.c) void, + present: ?*const fn (?*anyopaque) callconv(.c) void, + }, }; /// Initialize a Platform a tag and configuration from the C ABI. @@ -387,6 +435,21 @@ pub const Platform = union(PlatformTag) { break :ios error.UIViewMustBeSet); break :ios .{ .ios = .{ .uiview = uiview } }; } else error.UnsupportedPlatform, + + .opengl => opengl: { + const config = c_platform.opengl; + break :opengl .{ .opengl = .{ + .userdata = config.userdata, + .get_proc_address = config.get_proc_address orelse + break :opengl error.GetProcAddressMustBeSet, + .make_current = config.make_current orelse + break :opengl error.MakeCurrentMustBeSet, + .release_current = config.release_current orelse + break :opengl error.ReleaseCurrentMustBeSet, + .present = config.present orelse + break :opengl error.PresentMustBeSet, + } }; + }, }; } }; @@ -397,6 +460,7 @@ pub const PlatformTag = enum(c_int) { macos = 1, ios = 2, + opengl = 3, }; pub const EnvVar = extern struct { @@ -1004,10 +1068,12 @@ pub const Inspector = struct { const Backend = enum { metal, + opengl, pub fn deinit(self: Backend) void { switch (self) { .metal => if (builtin.target.os.tag.isDarwin()) cimgui.ImGui_ImplMetal_Shutdown(), + .opengl => if (!builtin.target.os.tag.isDarwin()) cimgui.ImGui_ImplOpenGL3_ShutdownWithLoaderCleanup(), } } }; @@ -1034,6 +1100,11 @@ pub const Inspector = struct { pub fn deinit(self: *Inspector) void { self.surface.core_surface.deactivateInspector(); cimgui.c.ImGui_SetCurrentContext(self.ig_ctx); + // backend.deinit calls the ImGui backend shutdown + // (ImGui_ImplOpenGL3_ShutdownWithLoaderCleanup for `.opengl`, + // ImGui_ImplMetal_Shutdown for `.metal`). The OpenGL backend + // requires the host's GL context to be current on this thread; + // hosts must arrange that before calling ghostty_inspector_free. if (self.backend) |v| v.deinit(); cimgui.c.ImGui_DestroyContext(self.ig_ctx); } @@ -1108,6 +1179,52 @@ pub const Inspector = struct { ); } + /// Initialize the inspector for an OpenGL backend. The ImGui OpenGL3 + /// backend self-loads GL symbols and renders into the bound + /// framebuffer, so no device handle is needed. + pub fn initOpenGL(self: *Inspector) bool { + if (comptime builtin.target.os.tag.isDarwin()) return false; + cimgui.c.ImGui_SetCurrentContext(self.ig_ctx); + + if (self.backend) |v| { + v.deinit(); + self.backend = null; + } + + if (!cimgui.ImGui_ImplOpenGL3_Init(null)) { + log.warn("failed to initialize OpenGL backend", .{}); + return false; + } + self.backend = .opengl; + + log.debug("initialized OpenGL backend", .{}); + return true; + } + + /// Render the inspector into the currently bound GL framebuffer. + pub fn renderOpenGL(self: *Inspector) !void { + if (comptime builtin.target.os.tag.isDarwin()) return; + assert(self.backend == .opengl); + + // Render twice so ImGui completes its state processing (this + // mirrors renderMetal). + for (0..2) |_| { + cimgui.ImGui_ImplOpenGL3_NewFrame(); + try self.newFrame(); + cimgui.c.ImGui_NewFrame(); + + render: { + const surface = &self.surface.core_surface; + const inspector = surface.inspector orelse break :render; + inspector.render(surface); + } + + cimgui.c.ImGui_Render(); + } + + cimgui.ImGui_ImplOpenGL3_RenderDrawData(cimgui.c.ImGui_GetDrawData()); + } + pub fn updateContentScale(self: *Inspector, x: f64, y: f64) void { _ = y; cimgui.c.ImGui_SetCurrentContext(self.ig_ctx); @@ -1121,6 +1238,13 @@ pub const Inspector = struct { var style: cimgui.c.ImGuiStyle = undefined; cimgui.ext.ImGuiStyle_ImGuiStyle(&style); cimgui.c.ImGuiStyle_ScaleAllSizes(&style, @floatCast(x)); + + // The embedded inspector font is baked at a 2x content scale + // (see Inspector.setup); FontScaleMain scales the glyphs to the + // real DPI so the text matches the scaled widget style instead + // of assuming 2x. (io.FontGlobalScale was removed in ImGui 1.92.) + style.FontScaleMain = @floatCast(x / 2.0); + const active_style = cimgui.c.ImGui_GetStyle(); active_style.* = style; } @@ -1283,6 +1407,14 @@ pub const CAPI = struct { cell_height_px: u32, }; + // Sync with ghostty_surface_cursor_position_s + const SurfaceCursorPosition = extern struct { + x: u32, + y: u32, + width: u32, + height: u32, + }; + // ghostty_clipboard_content_s const ClipboardContent = extern struct { mime: [*:0]const u8, @@ -1711,6 +1843,26 @@ pub const CAPI = struct { }; } + /// Return the terminal cursor's pixel rectangle in the surface's + /// device-pixel coordinate space. Used to place an IME candidate + /// window at the cursor. + export fn ghostty_surface_cursor_position( + surface: *Surface, + ) SurfaceCursorPosition { + const core = &surface.core_surface; + core.renderer_state.mutex.lock(); + defer core.renderer_state.mutex.unlock(); + const cursor = core.renderer_state.terminal.screens.active.cursor; + const cell = core.size.cell; + const pad = core.size.padding; + return .{ + .x = @as(u32, cursor.x) * cell.width + pad.left, + .y = @as(u32, cursor.y) * cell.height + pad.top, + .width = cell.width, + .height = cell.height, + }; + } + /// Returns the PID of the foreground process for the surface PTY. export fn ghostty_surface_foreground_pid(surface: *Surface) u64 { return surface.core_surface.getProcessInfo(.foreground_pid) orelse 0; @@ -2099,6 +2251,23 @@ pub const CAPI = struct { ptr.focusCallback(focused); } + export fn ghostty_inspector_opengl_init(ptr: *Inspector) bool { + return ptr.initOpenGL(); + } + + export fn ghostty_inspector_opengl_render(ptr: *Inspector) void { + ptr.renderOpenGL() catch |err| { + log.err("error rendering inspector err={}", .{err}); + }; + } + + export fn ghostty_inspector_opengl_shutdown(ptr: *Inspector) void { + if (ptr.backend) |v| { + v.deinit(); + ptr.backend = null; + } + } + /// Sets the window background blur on macOS to the desired value. /// I do this in Zig as an extern function because I don't know how to /// call these functions in Swift. 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/input/key_encode.zig b/src/input/key_encode.zig index 6ab5a4cc8..99e85f3b0 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; }; @@ -1862,6 +1867,47 @@ test "kitty: enter with utf8 (dead key state)" { try testing.expectEqualStrings("A", writer.buffered()); } +// Regression: a release event with non-empty utf8 for a key that has no +// kitty entry (e.g. when the apprt does not populate unshifted_codepoint +// for punctuation) used to fall through and re-emit the character on +// key-up, doubling every keystroke under report_events. +test "kitty: no-entry release with utf8 emits nothing" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .action = .release, + .key = .unidentified, + .mods = .{}, + .utf8 = ":", + // unshifted_codepoint = 0 forces the no-entry fallback. + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_events = true, + }, + }); + try testing.expectEqualStrings("", writer.buffered()); +} + +// And a press in the same shape still emits the text (the fix is +// specifically scoped to releases). +test "kitty: no-entry press with utf8 still emits text" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .action = .press, + .key = .unidentified, + .mods = .{}, + .utf8 = ":", + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_events = true, + }, + }); + try testing.expectEqualStrings(":", writer.buffered()); +} + test "kitty: keypad number" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); 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..09f6d8188 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, }; } @@ -158,10 +164,35 @@ fn prepareContext(getProcAddress: anytype) !void { try gl.enable(gl.c.GL_FRAMEBUFFER_SRGB); } +/// Host-provided OpenGL callbacks for the embedded apprt. +/// +/// libghostty draws on the app thread for the OpenGL renderer +/// (`must_draw_from_app_thread`), so these callbacks are set in +/// `surfaceInit` and read by `present` on that same thread. The +/// renderer thread is a no-op for OpenGL (see threadEnter/threadExit). +/// `threadlocal` keeps the bookkeeping per-thread without an explicit +/// hand-off, and avoids threading the callbacks through `*const OpenGL`. +/// +/// Never set for non-embedded runtimes. +threadlocal var gl_host: ?apprt.embedded.Platform.OpenGL = null; + +/// Adapts the host's `get_proc_address` (which takes a userdata argument) +/// to glad's GLFW-style loader signature (which does not). Reads the host +/// callbacks from the thread-local `gl_host`. +fn gladHostLoader( + name: [*:0]const u8, +) callconv(.c) ?*const fn () callconv(.c) void { + const host = gl_host orelse return null; + // The host returns ?*anyopaque (alignment 1); a function pointer + // has alignment ≥ 4 on aarch64. eglGetProcAddress / glXGetProcAddress + // promise the returned pointer is suitable for the OpenGL ABI, so + // @alignCast is the assertion we want here. + const raw = host.get_proc_address(host.userdata, name) orelse return null; + return @ptrCast(@alignCast(raw)); +} + /// This is called early right after surface creation. pub fn surfaceInit(surface: *apprt.Surface) !void { - _ = surface; - switch (apprt.runtime) { else => @compileError("unsupported app runtime for OpenGL"), @@ -169,10 +200,19 @@ pub fn surfaceInit(surface: *apprt.Surface) !void { apprt.gtk, => try prepareContext(null), - apprt.embedded => { - // TODO(mitchellh): this does nothing today to allow libghostty - // to compile for OpenGL targets but libghostty is strictly - // broken for rendering on this platforms. + // The OpenGL embedded path draws on the app thread + // (must_draw_from_app_thread). Make the host context current on + // this — the app — thread and load GL; it stays current here for + // the surface's lifetime. + apprt.embedded => switch (surface.platform) { + .opengl => |host| { + host.make_current(host.userdata); + gl_host = host; + try prepareContext(&gladHostLoader); + }, + + // macOS and iOS use the Metal renderer. + .macos, .ios => return error.UnsupportedPlatform, }, } @@ -191,6 +231,8 @@ pub fn surfaceInit(surface: *apprt.Surface) !void { pub fn finalizeSurfaceInit(self: *const OpenGL, surface: *apprt.Surface) !void { _ = self; _ = surface; + // Nothing to do: GTK and the OpenGL embedded path both keep the GL + // context current on the app thread, where all drawing happens. } /// Callback called by renderer.Thread when it begins. @@ -201,18 +243,10 @@ pub fn threadEnter(self: *const OpenGL, surface: *apprt.Surface) !void { switch (apprt.runtime) { else => @compileError("unsupported app runtime for OpenGL"), - apprt.gtk => { - // GTK doesn't support threaded OpenGL operations as far as I can - // tell, so we use the renderer thread to setup all the state - // but then do the actual draws and texture syncs and all that - // on the main thread. As such, we don't do anything here. - }, - - apprt.embedded => { - // TODO(mitchellh): this does nothing today to allow libghostty - // to compile for OpenGL targets but libghostty is strictly - // broken for rendering on this platforms. - }, + // GTK and the OpenGL embedded path both draw on the app thread + // (must_draw_from_app_thread), so the renderer thread must not + // touch the GL context. + apprt.gtk, apprt.embedded => {}, } } @@ -223,14 +257,9 @@ pub fn threadExit(self: *const OpenGL) void { switch (apprt.runtime) { else => @compileError("unsupported app runtime for OpenGL"), - apprt.gtk => { - // We don't need to do any unloading for GTK because we may - // be sharing the global bindings with other windows. - }, - - apprt.embedded => { - // TODO: see threadEnter - }, + // See threadEnter: the renderer thread does not own the GL + // context for either runtime. + apprt.gtk, apprt.embedded => {}, } } @@ -277,13 +306,28 @@ pub fn initShaders( /// Get the current size of the runtime surface. pub fn surfaceSize(self: *const OpenGL) !struct { width: u32, height: u32 } { - _ = self; - var viewport: [4]gl.c.GLint = undefined; - gl.glad.context.GetIntegerv.?(gl.c.GL_VIEWPORT, &viewport); - return .{ - .width = @intCast(viewport[2]), - .height = @intCast(viewport[3]), - }; + switch (apprt.runtime) { + else => @compileError("unsupported app runtime for OpenGL"), + + // GTK keeps the GL viewport in sync with the GLArea size, so it + // is a reliable source of the drawable size. + apprt.gtk => { + var viewport: [4]gl.c.GLint = undefined; + gl.glad.context.GetIntegerv.?(gl.c.GL_VIEWPORT, &viewport); + return .{ + .width = @intCast(viewport[2]), + .height = @intCast(viewport[3]), + }; + }, + + // The embedded host owns the drawable. Nothing keeps the GL + // viewport in sync with it, so use the pixel size the host + // reports through ghostty_surface_set_size. + apprt.embedded => { + const size = self.rt_surface.size; + return .{ .width = size.width, .height = size.height }; + }, + } } /// Initialize a new render target which can be presented by this API. @@ -328,6 +372,24 @@ pub fn present(self: *OpenGL, target: Target) !void { // Keep track of this target in case we need to repeat it. self.last_target = target; + + // Embedded OpenGL hosts own buffer presentation: the blit above only + // writes the default framebuffer, so ask the host to swap buffers. + // (GTK presents implicitly via its GLArea, so this is embedded-only.) + if (apprt.runtime == apprt.embedded) { + if (gl_host) |host| { + host.present(host.userdata); + } else { + // We're being driven from a thread that never ran + // surfaceInit, so the threadlocal is empty. The host's + // present can't be called and the frame won't surface; + // log instead of silently dropping it. + log.warn( + "present called on a thread without an embedded GL host bound", + .{}, + ); + } + } } /// Present the last presented target again. diff --git a/src/renderer/opengl/RenderPass.zig b/src/renderer/opengl/RenderPass.zig index 180664942..2f3a05704 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; }; @@ -83,6 +99,21 @@ pub fn step(self: *Self, s: Step) void { defer self.step_number += 1; + // Set the viewport to match the attachment we're rendering into. + // Ghostty's GTK apprt keeps the GL viewport in sync via GLArea, but + // other apprts (e.g. embedded) do not, so we set it explicitly for + // every step rather than relying on external viewport state. + { + const vp_w, const vp_h = switch (self.attachments[0].target) { + .target => |t| .{ t.width, t.height }, + .texture => |t| .{ t.width, t.height }, + }; + gl.viewport(0, 0, @intCast(vp_w), @intCast(vp_h)) catch |err| { + log.warn("render pass: viewport failed err={}", .{err}); + return; + }; + } + // If we have a clear color and this is the // first step in the pass, go ahead and clear. if (self.step_number == 0) if (self.attachments[0].clear_color) |c| { @@ -92,18 +123,30 @@ pub fn step(self: *Self, s: Step) void { // Bind the uniform buffer we bind at index 1 to align with Metal. if (s.uniforms) |ubo| { - _ = ubo.bindBase(.uniform, 1) catch return; + _ = ubo.bindBase(.uniform, 1) catch |err| { + log.warn("render pass: UBO bindBase failed err={}", .{err}); + return; + }; } // Bind relevant texture units. for (s.textures, 0..) |t, i| if (t) |tex| { - gl.Texture.active(@intCast(i)) catch return; - _ = tex.texture.bind(tex.target) catch return; + gl.Texture.active(@intCast(i)) catch |err| { + log.warn("render pass: texture active({d}) failed err={}", .{ i, err }); + return; + }; + _ = tex.texture.bind(tex.target) catch |err| { + log.warn("render pass: texture bind({d}) failed err={}", .{ i, err }); + return; + }; }; // Bind relevant samplers. for (s.samplers, 0..) |s_, i| if (s_) |sampler| { - _ = sampler.sampler.bind(@intCast(i)) catch return; + _ = sampler.sampler.bind(@intCast(i)) catch |err| { + log.warn("render pass: sampler bind({d}) failed err={}", .{ i, err }); + return; + }; }; // Bind 0th buffer as the vertex buffer, @@ -114,18 +157,33 @@ pub fn step(self: *Self, s: Step) void { vbo.id, 0, @intCast(s.pipeline.stride), - ) catch return; + ) catch |err| { + log.warn("render pass: VBO bindVertexBuffer failed err={}", .{err}); + return; + }; for (s.buffers[1..], 1..) |b, i| if (b) |buf| { - _ = buf.bindBase(.storage, @intCast(i)) catch return; + _ = buf.bindBase(.storage, @intCast(i)) catch |err| { + log.warn("render pass: SSBO bindBase({d}) failed err={}", .{ i, err }); + return; + }; }; } if (s.pipeline.blending_enabled) { - gl.enable(gl.c.GL_BLEND) catch return; - gl.blendFunc(gl.c.GL_ONE, gl.c.GL_ONE_MINUS_SRC_ALPHA) catch return; + gl.enable(gl.c.GL_BLEND) catch |err| { + log.warn("render pass: enable BLEND failed err={}", .{err}); + return; + }; + gl.blendFunc(gl.c.GL_ONE, gl.c.GL_ONE_MINUS_SRC_ALPHA) catch |err| { + log.warn("render pass: blendFunc failed err={}", .{err}); + return; + }; } else { - gl.disable(gl.c.GL_BLEND) catch return; + gl.disable(gl.c.GL_BLEND) catch |err| { + log.warn("render pass: disable BLEND failed err={}", .{err}); + return; + }; } gl.drawArraysInstanced( @@ -133,7 +191,10 @@ pub fn step(self: *Self, s: Step) void { 0, @intCast(s.draw.vertex_count), @intCast(s.draw.instance_count), - ) catch return; + ) catch |err| { + log.warn("render pass: drawArraysInstanced failed err={}", .{err}); + return; + }; } /// Complete this render pass.