Merge pull request #1 from fuddlesworth/qt-build-libghostty-opengl
Ghastty: Qt 6 frontend + libghostty embedded-OpenGLpull/12846/head
commit
7354bf7c36
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -0,0 +1 @@
|
|||
harness
|
||||
|
|
@ -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"
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
// libghostty OpenGL embedding harness (Workstream A4).
|
||||
//
|
||||
// A minimal GLFW host that drives libghostty through the
|
||||
// GHOSTTY_PLATFORM_OPENGL embedded API. Its only purpose is to verify
|
||||
// that the OpenGL embedded render path (A1 + A2) actually produces a
|
||||
// rendered terminal. This is throwaway verification scaffolding, not a
|
||||
// real terminal frontend.
|
||||
//
|
||||
// Build: see build.sh in this directory.
|
||||
|
||||
#include <stdatomic.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#include <GLFW/glfw3.h>
|
||||
|
||||
#include "ghostty.h"
|
||||
|
||||
// State shared with the libghostty callbacks.
|
||||
typedef struct {
|
||||
GLFWwindow *window;
|
||||
} host_t;
|
||||
|
||||
// The single surface, so GLFW input callbacks can reach it.
|
||||
static ghostty_surface_t g_surface = NULL;
|
||||
|
||||
// Set when libghostty asks for a redraw; the main loop then draws.
|
||||
// libghostty action callbacks may run on a worker thread, so this
|
||||
// must be atomic to pair the write in on_action with the read in
|
||||
// the main loop.
|
||||
static atomic_int g_needs_draw = 1;
|
||||
|
||||
// Count of presented frames. A nonzero value confirms the OpenGL
|
||||
// embedded render path is producing frames.
|
||||
static atomic_int g_frames = 0;
|
||||
|
||||
// --- ghostty_platform_opengl_s callbacks -----------------------------
|
||||
//
|
||||
// libghostty draws on the app thread (must_draw_from_app_thread), so
|
||||
// these run on the main thread.
|
||||
|
||||
static void *gl_get_proc_address(void *userdata, const char *name) {
|
||||
(void)userdata;
|
||||
return (void *)glfwGetProcAddress(name);
|
||||
}
|
||||
|
||||
static void gl_make_current(void *userdata) {
|
||||
host_t *h = userdata;
|
||||
glfwMakeContextCurrent(h->window);
|
||||
}
|
||||
|
||||
static void gl_release_current(void *userdata) {
|
||||
(void)userdata;
|
||||
glfwMakeContextCurrent(NULL);
|
||||
}
|
||||
|
||||
static void gl_present(void *userdata) {
|
||||
host_t *h = userdata;
|
||||
glfwSwapBuffers(h->window);
|
||||
atomic_fetch_add(&g_frames, 1);
|
||||
}
|
||||
|
||||
// --- ghostty_runtime_config_s callbacks ------------------------------
|
||||
|
||||
static void on_wakeup(void *userdata) {
|
||||
(void)userdata;
|
||||
// Called from another thread; nudge the main loop so it ticks.
|
||||
glfwPostEmptyEvent();
|
||||
}
|
||||
|
||||
static bool on_action(ghostty_app_t app, ghostty_target_s target,
|
||||
ghostty_action_s action) {
|
||||
(void)app;
|
||||
(void)target;
|
||||
// libghostty requests a redraw via the render action; the main loop
|
||||
// services it. Other actions are ignored by this harness.
|
||||
if (action.tag == GHOSTTY_ACTION_RENDER) {
|
||||
atomic_store(&g_needs_draw, 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool on_read_clipboard(void *userdata, ghostty_clipboard_e loc,
|
||||
void *state) {
|
||||
(void)userdata;
|
||||
(void)loc;
|
||||
(void)state;
|
||||
return false;
|
||||
}
|
||||
|
||||
static void on_confirm_read_clipboard(void *userdata, const char *str,
|
||||
void *state,
|
||||
ghostty_clipboard_request_e req) {
|
||||
(void)userdata;
|
||||
(void)str;
|
||||
(void)state;
|
||||
(void)req;
|
||||
}
|
||||
|
||||
static void on_write_clipboard(void *userdata, ghostty_clipboard_e loc,
|
||||
const ghostty_clipboard_content_s *content,
|
||||
size_t n, bool confirm) {
|
||||
(void)userdata;
|
||||
(void)loc;
|
||||
(void)content;
|
||||
(void)n;
|
||||
(void)confirm;
|
||||
}
|
||||
|
||||
static void on_close_surface(void *userdata, bool process_active) {
|
||||
(void)userdata;
|
||||
(void)process_active;
|
||||
}
|
||||
|
||||
// --- GLFW input -> libghostty ----------------------------------------
|
||||
|
||||
static void on_char(GLFWwindow *win, unsigned int cp) {
|
||||
(void)win;
|
||||
if (!g_surface) return;
|
||||
|
||||
// Encode the UTF-32 codepoint to UTF-8 and feed it as text.
|
||||
char buf[4];
|
||||
int len = 0;
|
||||
if (cp < 0x80) {
|
||||
buf[len++] = (char)cp;
|
||||
} else if (cp < 0x800) {
|
||||
buf[len++] = (char)(0xC0 | (cp >> 6));
|
||||
buf[len++] = (char)(0x80 | (cp & 0x3F));
|
||||
} else if (cp < 0x10000) {
|
||||
buf[len++] = (char)(0xE0 | (cp >> 12));
|
||||
buf[len++] = (char)(0x80 | ((cp >> 6) & 0x3F));
|
||||
buf[len++] = (char)(0x80 | (cp & 0x3F));
|
||||
} else {
|
||||
buf[len++] = (char)(0xF0 | (cp >> 18));
|
||||
buf[len++] = (char)(0x80 | ((cp >> 12) & 0x3F));
|
||||
buf[len++] = (char)(0x80 | ((cp >> 6) & 0x3F));
|
||||
buf[len++] = (char)(0x80 | (cp & 0x3F));
|
||||
}
|
||||
ghostty_surface_text(g_surface, buf, (uintptr_t)len);
|
||||
}
|
||||
|
||||
static void on_framebuffer_size(GLFWwindow *win, int w, int h) {
|
||||
(void)win;
|
||||
if (g_surface && w > 0 && h > 0) {
|
||||
ghostty_surface_set_size(g_surface, (uint32_t)w, (uint32_t)h);
|
||||
atomic_store(&g_needs_draw, 1);
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (ghostty_init((uintptr_t)argc, argv) != GHOSTTY_SUCCESS) {
|
||||
fprintf(stderr, "ghostty_init failed\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!glfwInit()) {
|
||||
fprintf(stderr, "glfwInit failed (no display?)\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Ghostty's OpenGL renderer requires at least OpenGL 4.3 core.
|
||||
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
|
||||
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
|
||||
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
|
||||
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE);
|
||||
|
||||
GLFWwindow *window =
|
||||
glfwCreateWindow(800, 600, "libghostty OpenGL harness", NULL, NULL);
|
||||
if (!window) {
|
||||
fprintf(stderr, "glfwCreateWindow failed\n");
|
||||
glfwTerminate();
|
||||
return 1;
|
||||
}
|
||||
|
||||
// libghostty draws on the app thread, so the GL context stays current
|
||||
// on this (the main) thread, where glfwCreateWindow left it.
|
||||
|
||||
host_t host = {.window = window};
|
||||
|
||||
// App-level runtime config.
|
||||
ghostty_runtime_config_s rt = {0};
|
||||
rt.userdata = &host;
|
||||
rt.supports_selection_clipboard = false;
|
||||
rt.wakeup_cb = on_wakeup;
|
||||
rt.action_cb = on_action;
|
||||
rt.read_clipboard_cb = on_read_clipboard;
|
||||
rt.confirm_read_clipboard_cb = on_confirm_read_clipboard;
|
||||
rt.write_clipboard_cb = on_write_clipboard;
|
||||
rt.close_surface_cb = on_close_surface;
|
||||
|
||||
ghostty_config_t cfg = ghostty_config_new();
|
||||
ghostty_config_finalize(cfg);
|
||||
|
||||
ghostty_app_t app = ghostty_app_new(&rt, cfg);
|
||||
if (!app) {
|
||||
fprintf(stderr, "ghostty_app_new failed\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
float xscale = 1.0f, yscale = 1.0f;
|
||||
glfwGetWindowContentScale(window, &xscale, &yscale);
|
||||
|
||||
// Force a widely-available TERM since the libghostty built with
|
||||
// -Dapp-runtime=none does not install Ghostty's terminfo.
|
||||
ghostty_env_var_s env[] = {
|
||||
{"TERM", "xterm-256color"},
|
||||
};
|
||||
|
||||
// Surface config: hand libghostty our OpenGL context callbacks.
|
||||
ghostty_surface_config_s sc = ghostty_surface_config_new();
|
||||
sc.platform_tag = GHOSTTY_PLATFORM_OPENGL;
|
||||
sc.platform.opengl = (ghostty_platform_opengl_s){
|
||||
.userdata = &host,
|
||||
.get_proc_address = gl_get_proc_address,
|
||||
.make_current = gl_make_current,
|
||||
.release_current = gl_release_current,
|
||||
.present = gl_present,
|
||||
};
|
||||
sc.userdata = &host;
|
||||
sc.scale_factor = xscale;
|
||||
sc.env_vars = env;
|
||||
sc.env_var_count = sizeof(env) / sizeof(env[0]);
|
||||
|
||||
ghostty_surface_t surface = ghostty_surface_new(app, &sc);
|
||||
if (!surface) {
|
||||
fprintf(stderr, "ghostty_surface_new failed\n");
|
||||
return 1;
|
||||
}
|
||||
g_surface = surface;
|
||||
|
||||
int fbw, fbh;
|
||||
glfwGetFramebufferSize(window, &fbw, &fbh);
|
||||
ghostty_surface_set_content_scale(surface, xscale, yscale);
|
||||
ghostty_surface_set_size(surface, (uint32_t)fbw, (uint32_t)fbh);
|
||||
ghostty_surface_set_focus(surface, true);
|
||||
|
||||
glfwSetCharCallback(window, on_char);
|
||||
glfwSetFramebufferSizeCallback(window, on_framebuffer_size);
|
||||
|
||||
printf("harness running — a terminal should render. "
|
||||
"Close the window to exit.\n");
|
||||
fflush(stdout);
|
||||
|
||||
// Main loop: pump GLFW events, tick libghostty, and draw when asked.
|
||||
double next_report = glfwGetTime() + 1.0;
|
||||
while (!glfwWindowShouldClose(window)) {
|
||||
glfwWaitEventsTimeout(0.1);
|
||||
ghostty_app_tick(app);
|
||||
if (ghostty_surface_process_exited(surface)) {
|
||||
glfwSetWindowShouldClose(window, GLFW_TRUE);
|
||||
break;
|
||||
}
|
||||
|
||||
// libghostty requested a draw (via the render action); service it
|
||||
// on this thread. atomic_exchange clears the flag and reads it in
|
||||
// one step, so a wakeup setting it again between the read and
|
||||
// the draw is preserved for the next iteration.
|
||||
if (atomic_exchange(&g_needs_draw, 0)) {
|
||||
ghostty_surface_draw(surface);
|
||||
}
|
||||
|
||||
// Report presented-frame count once per second.
|
||||
double now = glfwGetTime();
|
||||
if (now >= next_report) {
|
||||
printf("frames presented: %d\n", atomic_load(&g_frames));
|
||||
fflush(stdout);
|
||||
next_report = now + 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
printf("exiting — total frames presented: %d\n", atomic_load(&g_frames));
|
||||
|
||||
ghostty_surface_free(surface);
|
||||
ghostty_app_free(app);
|
||||
ghostty_config_free(cfg);
|
||||
glfwDestroyWindow(window);
|
||||
glfwTerminate();
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -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*);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
build/
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
cmake_minimum_required(VERSION 3.16)
|
||||
# C is needed for the wayland-scanner-generated protocol code.
|
||||
project(ghastty LANGUAGES CXX C)
|
||||
|
||||
# Ghastty: a Qt6 frontend for Ghostty that embeds libghostty through the
|
||||
# GHOSTTY_PLATFORM_OPENGL C API. libghostty's OpenGL renderer draws each
|
||||
# terminal into an offscreen framebuffer (a private QOpenGLContext); the
|
||||
# frame is read back into a QImage and painted on an ordinary translucent
|
||||
# QWidget. That keeps the terminal a plain widget — it embeds in the
|
||||
# tab/split tree and its transparent background composites like any other
|
||||
# widget. libghostty draws on the GUI thread (the embedded apprt sets
|
||||
# must_draw_from_app_thread for the OpenGL renderer).
|
||||
#
|
||||
# Build libghostty first, from the repo root. Use ReleaseFast: a Debug
|
||||
# build of libghostty leaves the whole terminal core unoptimized and
|
||||
# makes rendering and surface creation noticeably sluggish.
|
||||
#
|
||||
# zig build -Dapp-runtime=none -Doptimize=ReleaseFast
|
||||
#
|
||||
# Then build, run, and (optionally) install this app:
|
||||
#
|
||||
# cmake -S qt -B qt/build && cmake --build qt/build
|
||||
# ./qt/build/ghastty
|
||||
# cmake --install qt/build --prefix ~/.local
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
|
||||
include(GNUInstallDirs)
|
||||
|
||||
find_package(Qt6 REQUIRED COMPONENTS Gui Widgets OpenGL DBus
|
||||
Multimedia Svg)
|
||||
# WindowBlur uses qpa/qplatformnativeinterface.h to reach the wl_display
|
||||
# / wl_surface / xcb_connection_t for the native compositor blur calls.
|
||||
# Qt's official path is `find_package(Qt6 COMPONENTS GuiPrivate)`, but
|
||||
# Debian's qt6-base-private-dev ships the private headers without the
|
||||
# matching Qt6GuiPrivateConfig.cmake. Detect the component when present;
|
||||
# otherwise fall back to wiring the private include dir by hand.
|
||||
find_package(Qt6 QUIET OPTIONAL_COMPONENTS GuiPrivate)
|
||||
|
||||
# LayerShellQt: the quick terminal is a wlr-layer-shell dropdown window.
|
||||
find_package(LayerShellQt REQUIRED)
|
||||
|
||||
# KWin background-blur is applied through the native compositor: the
|
||||
# org_kde_kwin_blur Wayland protocol and the _KDE_NET_WM_BLUR atom on
|
||||
# X11. Qt6::GuiPrivate gives the QPA native-handle accessors.
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(WAYLAND_CLIENT REQUIRED IMPORTED_TARGET wayland-client)
|
||||
pkg_check_modules(XCB REQUIRED IMPORTED_TARGET xcb)
|
||||
# libxkbcommon: derive the unshifted Unicode codepoint for a key event
|
||||
# from its XKB keycode, so libghostty's kitty encoder finds an entry for
|
||||
# punctuation keys (Qt's ev->key() reports the SHIFTED symbol, e.g.
|
||||
# Qt::Key_Colon for Shift+;).
|
||||
pkg_check_modules(XKBCOMMON REQUIRED IMPORTED_TARGET xkbcommon)
|
||||
find_program(WAYLAND_SCANNER wayland-scanner REQUIRED)
|
||||
|
||||
# Generate client glue for the org_kde_kwin_blur protocol.
|
||||
set(BLUR_XML "${CMAKE_CURRENT_SOURCE_DIR}/protocols/blur.xml")
|
||||
set(BLUR_HEADER "${CMAKE_CURRENT_BINARY_DIR}/blur-client-protocol.h")
|
||||
set(BLUR_CODE "${CMAKE_CURRENT_BINARY_DIR}/blur-protocol.c")
|
||||
add_custom_command(OUTPUT "${BLUR_HEADER}"
|
||||
COMMAND "${WAYLAND_SCANNER}" client-header "${BLUR_XML}" "${BLUR_HEADER}"
|
||||
DEPENDS "${BLUR_XML}" VERBATIM)
|
||||
add_custom_command(OUTPUT "${BLUR_CODE}"
|
||||
COMMAND "${WAYLAND_SCANNER}" private-code "${BLUR_XML}" "${BLUR_CODE}"
|
||||
DEPENDS "${BLUR_XML}" VERBATIM)
|
||||
|
||||
# libghostty is built out-of-tree by Zig.
|
||||
get_filename_component(GHOSTTY_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/.." ABSOLUTE)
|
||||
set(GHOSTTY_LIB_DIR "${GHOSTTY_ROOT}/zig-out/lib")
|
||||
set(GHOSTTY_SO "${GHOSTTY_LIB_DIR}/ghostty-internal.so")
|
||||
|
||||
if(NOT EXISTS "${GHOSTTY_SO}")
|
||||
message(FATAL_ERROR
|
||||
"libghostty not found at ${GHOSTTY_SO}\n"
|
||||
"Build it first from the repo root: zig build -Dapp-runtime=none")
|
||||
endif()
|
||||
|
||||
# The .so's SONAME is libghostty.so but it is installed as
|
||||
# ghostty-internal.so. Create a symlink in the build tree (NOT in the
|
||||
# Zig output dir, which is the source tree from CMake's perspective)
|
||||
# so the loader finds it under the SONAME the binary links against.
|
||||
set(GHOSTTY_LINK_DIR "${CMAKE_CURRENT_BINARY_DIR}/lib")
|
||||
set(GHOSTTY_LINK_SO "${GHOSTTY_LINK_DIR}/libghostty.so")
|
||||
add_custom_command(OUTPUT "${GHOSTTY_LINK_SO}"
|
||||
COMMAND "${CMAKE_COMMAND}" -E make_directory "${GHOSTTY_LINK_DIR}"
|
||||
COMMAND "${CMAKE_COMMAND}" -E create_symlink
|
||||
"${GHOSTTY_SO}" "${GHOSTTY_LINK_SO}"
|
||||
DEPENDS "${GHOSTTY_SO}" VERBATIM)
|
||||
add_custom_target(ghostty_link DEPENDS "${GHOSTTY_LINK_SO}")
|
||||
|
||||
add_executable(ghastty
|
||||
src/main.cpp
|
||||
src/CommandPalette.cpp
|
||||
src/GhosttySurface.cpp
|
||||
src/GlobalShortcuts.cpp
|
||||
src/InspectorWindow.cpp
|
||||
src/MainWindow.cpp
|
||||
src/OverlayScrollbar.cpp
|
||||
src/SearchBar.cpp
|
||||
src/TabWidget.cpp
|
||||
src/Util.cpp
|
||||
src/WindowBlur.cpp
|
||||
"${BLUR_CODE}"
|
||||
"${BLUR_HEADER}"
|
||||
)
|
||||
|
||||
# Embed the app icon so it is available even running from the build tree.
|
||||
qt_add_resources(ghastty "appicon"
|
||||
PREFIX "/"
|
||||
BASE dist
|
||||
FILES dist/ghastty.svg)
|
||||
|
||||
target_include_directories(ghastty PRIVATE
|
||||
"${GHOSTTY_ROOT}/include"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}" # generated blur-client-protocol.h
|
||||
)
|
||||
|
||||
add_dependencies(ghastty ghostty_link)
|
||||
|
||||
target_link_libraries(ghastty PRIVATE
|
||||
Qt6::Gui
|
||||
Qt6::Widgets
|
||||
Qt6::OpenGL
|
||||
Qt6::DBus
|
||||
Qt6::Multimedia
|
||||
Qt6::Svg
|
||||
PkgConfig::WAYLAND_CLIENT
|
||||
PkgConfig::XCB
|
||||
PkgConfig::XKBCOMMON
|
||||
LayerShellQt::Interface
|
||||
"${GHOSTTY_LINK_SO}"
|
||||
)
|
||||
|
||||
# Hook up the private QPA headers (see find_package above).
|
||||
if(TARGET Qt6::GuiPrivate)
|
||||
target_link_libraries(ghastty PRIVATE Qt6::GuiPrivate)
|
||||
else()
|
||||
# Debian fallback: locate qplatformnativeinterface.h and add its
|
||||
# parent dir as a private include (the header lives at
|
||||
# <prefix>/QtGui/<version>/QtGui/qpa/qplatformnativeinterface.h, so we
|
||||
# add the dir containing the `qpa` subfolder).
|
||||
# Multi-arch Debian uses ${CMAKE_LIBRARY_ARCHITECTURE} (e.g.
|
||||
# aarch64-linux-gnu) for include and lib subdirs. Hardcoding a list
|
||||
# missed armhf/riscv64; deriving from the toolchain handles every
|
||||
# arch the Qt 6 packages support.
|
||||
find_path(QT_QPA_INCLUDE_DIR
|
||||
NAMES qpa/qplatformnativeinterface.h
|
||||
HINTS
|
||||
"${Qt6Gui_PRIVATE_INCLUDE_DIRS}"
|
||||
"${Qt6_DIR}/../../../include/${CMAKE_LIBRARY_ARCHITECTURE}/qt6/QtGui/${Qt6_VERSION}/QtGui"
|
||||
"${Qt6_DIR}/../../../include/qt6/QtGui/${Qt6_VERSION}/QtGui"
|
||||
PATH_SUFFIXES
|
||||
"qt6/QtGui/${Qt6_VERSION}/QtGui"
|
||||
)
|
||||
if(NOT QT_QPA_INCLUDE_DIR)
|
||||
message(FATAL_ERROR
|
||||
"Qt6 private QPA headers not found.\n"
|
||||
"Install qt6-base-private-dev (Debian/Ubuntu) or the equivalent "
|
||||
"Qt 6 private headers package.")
|
||||
endif()
|
||||
message(STATUS "Using Qt private include dir: ${QT_QPA_INCLUDE_DIR}")
|
||||
target_include_directories(ghastty PRIVATE "${QT_QPA_INCLUDE_DIR}")
|
||||
endif()
|
||||
|
||||
# RPATH:
|
||||
# - Build tree: libghostty.so lives in our build dir (a symlink to the
|
||||
# actual zig-out artifact), and the .so's NEEDED entries also point
|
||||
# into zig-out/lib for transitive deps.
|
||||
# - Installed: libghostty.so lives next to the binary ($ORIGIN/../lib).
|
||||
set_target_properties(ghastty PROPERTIES
|
||||
BUILD_RPATH "${GHOSTTY_LINK_DIR};${GHOSTTY_LIB_DIR}"
|
||||
INSTALL_RPATH "$ORIGIN/../${CMAKE_INSTALL_LIBDIR}"
|
||||
)
|
||||
|
||||
# --- install ---------------------------------------------------------
|
||||
install(TARGETS ghastty RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}")
|
||||
|
||||
# libghostty.so the binary links against (SONAME is libghostty.so).
|
||||
install(FILES "${GHOSTTY_SO}"
|
||||
DESTINATION "${CMAKE_INSTALL_LIBDIR}"
|
||||
RENAME libghostty.so)
|
||||
|
||||
install(FILES dist/ghastty.desktop
|
||||
DESTINATION "${CMAKE_INSTALL_DATADIR}/applications")
|
||||
|
||||
# The custom scalable app icon.
|
||||
install(FILES dist/ghastty.svg
|
||||
DESTINATION "${CMAKE_INSTALL_DATADIR}/icons/hicolor/scalable/apps")
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- V1: Original dome ghost + 4 wavy feet, on the new purple CRT.
|
||||
Wide-set >_ prompt eyes (upstream Ghostty lineage). No glitch
|
||||
effects. Cleanest small-size legibility. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024"
|
||||
viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<linearGradient id="bezel" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#3a2c5e"/>
|
||||
<stop offset="0.5" stop-color="#1c1430"/>
|
||||
<stop offset="1" stop-color="#08060f"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="bezelSheen" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#7d6cb0" stop-opacity="0.4"/>
|
||||
<stop offset="0.15" stop-color="#7d6cb0" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="screen" cx="0.5" cy="0.35" r="0.85">
|
||||
<stop offset="0" stop-color="#221d52"/>
|
||||
<stop offset="0.6" stop-color="#0f0c2a"/>
|
||||
<stop offset="1" stop-color="#040312"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="screenBloom" cx="0.5" cy="0.3" r="0.55">
|
||||
<stop offset="0" stop-color="#8a4ad8" stop-opacity="0.30"/>
|
||||
<stop offset="1" stop-color="#8a4ad8" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="screenGloss" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#ffffff" stop-opacity="0.10"/>
|
||||
<stop offset="0.45" stop-color="#ffffff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="ghostBody" gradientUnits="userSpaceOnUse"
|
||||
x1="512" y1="280" x2="512" y2="780">
|
||||
<stop offset="0" stop-color="#f4f1ea"/>
|
||||
<stop offset="0.55" stop-color="#e3ddd0"/>
|
||||
<stop offset="1" stop-color="#a89fa8"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="mouthGlow" cx="0.5" cy="0.5" r="0.5">
|
||||
<stop offset="0" stop-color="#ff4838" stop-opacity="0.55"/>
|
||||
<stop offset="1" stop-color="#ff4838" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="1024" height="1024" rx="200" fill="url(#bezel)"/>
|
||||
<rect x="0" y="0" width="1024" height="1024" rx="200" fill="url(#bezelSheen)"/>
|
||||
<rect x="88" y="88" width="848" height="848" rx="120" fill="url(#screen)"/>
|
||||
<rect x="88" y="88" width="848" height="848" rx="120" fill="url(#screenBloom)"/>
|
||||
<rect x="88" y="88" width="848" height="848" rx="120" fill="url(#screenGloss)"/>
|
||||
|
||||
<!-- Dome ghost: rounded top (radius 190) + straight sides + 4 wavy
|
||||
feet at bottom. Path goes counter-clockwise from top center. -->
|
||||
<path d="
|
||||
M 322 658
|
||||
L 322 460
|
||||
A 190 190 0 0 1 702 460
|
||||
L 702 658
|
||||
Q 654 720 607 658
|
||||
Q 559 720 512 658
|
||||
Q 464 720 417 658
|
||||
Q 369 720 322 658 Z
|
||||
" fill="url(#ghostBody)"/>
|
||||
<path d="
|
||||
M 322 658
|
||||
L 322 460
|
||||
A 190 190 0 0 1 702 460
|
||||
L 702 658
|
||||
Q 654 720 607 658
|
||||
Q 559 720 512 658
|
||||
Q 464 720 417 658
|
||||
Q 369 720 322 658 Z
|
||||
" fill="none" stroke="#0a0c14" stroke-opacity="0.55"
|
||||
stroke-width="9" stroke-linejoin="round"/>
|
||||
|
||||
<!-- > _ prompt eyes, centered on ghost x=512. Pair geometry:
|
||||
chevron 66 wide (L-arm tip to apex), gap 68, cursor 98 wide.
|
||||
Total 232; leftmost = 512 - 116 = 396.
|
||||
chevron: x=396..462 (apex at 462)
|
||||
cursor: x=530..628
|
||||
Pair midpoint = (396+628)/2 = 512 ✓
|
||||
Vertical center y=496. -->
|
||||
<ellipse cx="512" cy="496" rx="200" ry="80" fill="url(#mouthGlow)"/>
|
||||
<path d="M 396 458 L 462 496 L 396 534"
|
||||
fill="none" stroke="#e63232" stroke-width="28"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="530" y="478" width="98" height="36" rx="6" fill="#e63232"/>
|
||||
|
||||
<!-- Small mouth: sits just below the eyes, above the feet trough.
|
||||
The dome's straight-side region ends at y=658 where the feet
|
||||
begin; placing the mouth at y=580..612 keeps it on the body
|
||||
proper, not in the foot scallops. -->
|
||||
<ellipse cx="512" cy="596" rx="56" ry="32" fill="url(#mouthGlow)"/>
|
||||
<rect x="492" y="580" width="40" height="32" rx="4" fill="#e63232"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
|
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="blur">
|
||||
<copyright><![CDATA[
|
||||
SPDX-FileCopyrightText: 2015 Martin Gräßlin
|
||||
SPDX-FileCopyrightText: 2015 Marco Martin
|
||||
|
||||
SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
]]></copyright>
|
||||
<interface name="org_kde_kwin_blur_manager" version="1">
|
||||
<request name="create">
|
||||
<arg name="id" type="new_id" interface="org_kde_kwin_blur"/>
|
||||
<arg name="surface" type="object" interface="wl_surface"/>
|
||||
</request>
|
||||
<request name="unset">
|
||||
<arg name="surface" type="object" interface="wl_surface"/>
|
||||
</request>
|
||||
</interface>
|
||||
<interface name="org_kde_kwin_blur" version="1">
|
||||
<request name="commit">
|
||||
</request>
|
||||
<request name="set_region">
|
||||
<arg name="region" type="object" interface="wl_region" allow-null="true"/>
|
||||
</request>
|
||||
<request name="release" type="destructor">
|
||||
<description summary="release the blur object"/>
|
||||
</request>
|
||||
</interface>
|
||||
</protocol>
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
#include "CommandPalette.h"
|
||||
|
||||
#include <QAbstractItemView>
|
||||
#include <QByteArray>
|
||||
#include <QEvent>
|
||||
#include <QKeyEvent>
|
||||
#include <QLineEdit>
|
||||
#include <QListView>
|
||||
#include <QSortFilterProxyModel>
|
||||
#include <QStandardItemModel>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "GhosttySurface.h"
|
||||
#include "MainWindow.h"
|
||||
#include "Util.h"
|
||||
|
||||
namespace {
|
||||
// Item data roles: the keybind action to run, and the text the filter
|
||||
// matches against (title plus the bare action name).
|
||||
constexpr int kActionRole = Qt::UserRole;
|
||||
constexpr int kFilterRole = Qt::UserRole + 1;
|
||||
} // namespace
|
||||
|
||||
CommandPalette::CommandPalette(QWidget *owner)
|
||||
: QWidget(owner, Qt::Popup), m_owner(owner) {
|
||||
resize(620, 420);
|
||||
|
||||
m_search = new QLineEdit(this);
|
||||
m_search->setPlaceholderText(QStringLiteral("Run a command…"));
|
||||
m_search->setClearButtonEnabled(true);
|
||||
m_search->installEventFilter(this);
|
||||
|
||||
m_model = new QStandardItemModel(this);
|
||||
m_filter = new QSortFilterProxyModel(this);
|
||||
m_filter->setSourceModel(m_model);
|
||||
m_filter->setFilterRole(kFilterRole);
|
||||
m_filter->setFilterCaseSensitivity(Qt::CaseInsensitive);
|
||||
|
||||
m_list = new QListView(this);
|
||||
m_list->setModel(m_filter);
|
||||
m_list->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
||||
m_list->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
m_list->setUniformItemSizes(true);
|
||||
m_list->setFocusPolicy(Qt::NoFocus); // keep typing in the search box
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->setContentsMargins(8, 8, 8, 8);
|
||||
layout->addWidget(m_search);
|
||||
layout->addWidget(m_list);
|
||||
|
||||
// Debounce filter updates so a fast typist on a long
|
||||
// command-palette-entry list doesn't thrash QSortFilterProxyModel.
|
||||
// 80ms feels live without flooring slow systems.
|
||||
m_filterDebounce = new QTimer(this);
|
||||
m_filterDebounce->setSingleShot(true);
|
||||
m_filterDebounce->setInterval(80);
|
||||
connect(m_filterDebounce, &QTimer::timeout, this, [this]() {
|
||||
m_filter->setFilterFixedString(m_search->text());
|
||||
selectFirstRow();
|
||||
});
|
||||
connect(m_search, &QLineEdit::textChanged, this,
|
||||
[this]() { m_filterDebounce->start(); });
|
||||
connect(m_list, &QListView::activated, this,
|
||||
[this](const QModelIndex &) { runSelected(); });
|
||||
hide();
|
||||
}
|
||||
|
||||
void CommandPalette::toggleFor(GhosttySurface *surface) {
|
||||
if (isVisible()) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
// If the owner window is gone (closed while we were hidden), the
|
||||
// QPointer auto-nulled — we can't place ourselves sensibly without
|
||||
// it, so bail. The next initialize on a new window creates a fresh
|
||||
// palette.
|
||||
if (!m_owner) return;
|
||||
|
||||
m_surface = surface;
|
||||
populate();
|
||||
m_search->clear();
|
||||
m_filter->setFilterFixedString(QString());
|
||||
|
||||
// Centre over the owner, biased toward the top. As a Qt::Popup the
|
||||
// position is interpreted relative to the parent window, so this
|
||||
// places correctly on Wayland too.
|
||||
const QPoint p((m_owner->width() - width()) / 2, m_owner->height() / 6);
|
||||
move(m_owner->mapToGlobal(p));
|
||||
show();
|
||||
m_search->setFocus();
|
||||
selectFirstRow();
|
||||
}
|
||||
|
||||
void CommandPalette::populate() {
|
||||
m_model->clear();
|
||||
if (!m_surface || !m_surface->owner()) return;
|
||||
ghostty_config_t cfg = m_surface->owner()->config();
|
||||
if (!cfg) return;
|
||||
|
||||
// command-palette-entry defaults to a large built-in command set.
|
||||
ghostty_config_command_list_s list = {};
|
||||
if (!configGet(cfg, &list, "command-palette-entry")) return;
|
||||
for (size_t i = 0; i < list.len; ++i) {
|
||||
const ghostty_command_s &c = list.commands[i];
|
||||
const QString title = QString::fromUtf8(c.title ? c.title : "");
|
||||
const QString action = QString::fromUtf8(c.action ? c.action : "");
|
||||
if (title.isEmpty() || action.isEmpty()) continue;
|
||||
auto *item = new QStandardItem(title);
|
||||
item->setData(action, kActionRole);
|
||||
item->setData(title + QLatin1Char(' ') +
|
||||
QString::fromUtf8(c.action_key ? c.action_key : ""),
|
||||
kFilterRole);
|
||||
if (c.description && *c.description)
|
||||
item->setToolTip(QString::fromUtf8(c.description));
|
||||
m_model->appendRow(item);
|
||||
}
|
||||
}
|
||||
|
||||
void CommandPalette::selectFirstRow() {
|
||||
if (m_filter->rowCount() > 0)
|
||||
m_list->setCurrentIndex(m_filter->index(0, 0));
|
||||
}
|
||||
|
||||
void CommandPalette::moveSelection(int delta) {
|
||||
const int n = m_filter->rowCount();
|
||||
if (n == 0) return;
|
||||
int row = m_list->currentIndex().row();
|
||||
row = qBound(0, (row < 0 ? 0 : row) + delta, n - 1);
|
||||
m_list->setCurrentIndex(m_filter->index(row, 0));
|
||||
}
|
||||
|
||||
void CommandPalette::runSelected() {
|
||||
const QModelIndex idx = m_list->currentIndex();
|
||||
if (!idx.isValid()) return;
|
||||
const QString action = idx.data(kActionRole).toString();
|
||||
GhosttySurface *surface = m_surface;
|
||||
hide(); // close before executing, matching the GTK palette
|
||||
if (surface && surface->surface() && !action.isEmpty()) {
|
||||
const QByteArray a = action.toUtf8();
|
||||
ghostty_surface_binding_action(surface->surface(), a.constData(),
|
||||
a.size());
|
||||
}
|
||||
}
|
||||
|
||||
bool CommandPalette::eventFilter(QObject *obj, QEvent *event) {
|
||||
if (obj == m_search && event->type() == QEvent::KeyPress) {
|
||||
auto *ke = static_cast<QKeyEvent *>(event);
|
||||
switch (ke->key()) {
|
||||
case Qt::Key_Up: moveSelection(-1); return true;
|
||||
case Qt::Key_Down: moveSelection(1); return true;
|
||||
case Qt::Key_Return:
|
||||
case Qt::Key_Enter: runSelected(); return true;
|
||||
case Qt::Key_Escape: hide(); return true;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
return QWidget::eventFilter(obj, event);
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
#pragma once
|
||||
|
||||
#include <QPointer>
|
||||
#include <QWidget>
|
||||
|
||||
class GhosttySurface;
|
||||
class QLineEdit;
|
||||
class QListView;
|
||||
class QSortFilterProxyModel;
|
||||
class QStandardItemModel;
|
||||
class QTimer;
|
||||
|
||||
// A searchable command palette (the TOGGLE_COMMAND_PALETTE action).
|
||||
//
|
||||
// It lists the commands from the `command-palette-entry` config (a
|
||||
// large built-in default set plus any user additions) and runs the
|
||||
// chosen command's keybind action on the active surface. Shown as a
|
||||
// Qt::Popup over its owner — Qt anchors it to the parent window (so it
|
||||
// places correctly on Wayland) and dismisses it on an outside click.
|
||||
class CommandPalette : public QWidget {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit CommandPalette(QWidget *owner);
|
||||
|
||||
// Show the palette for `surface` (populating from the live config),
|
||||
// or hide it if already visible.
|
||||
void toggleFor(GhosttySurface *surface);
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject *obj, QEvent *event) override;
|
||||
|
||||
private:
|
||||
void populate(); // (re)load the command list from config
|
||||
void runSelected(); // execute the highlighted command
|
||||
void moveSelection(int delta);
|
||||
void selectFirstRow();
|
||||
|
||||
// Owner window the palette centres over. QPointer so a window
|
||||
// closed while the palette is hidden doesn't leave us with a
|
||||
// dangling raw pointer (toggleFor dereferences it for placement).
|
||||
QPointer<QWidget> m_owner;
|
||||
QLineEdit *m_search = nullptr;
|
||||
QListView *m_list = nullptr;
|
||||
QStandardItemModel *m_model = nullptr;
|
||||
QSortFilterProxyModel *m_filter = nullptr;
|
||||
QTimer *m_filterDebounce = nullptr; // coalesces rapid keystrokes
|
||||
QPointer<GhosttySurface> m_surface; // active surface; may go away
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,207 @@
|
|||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
|
||||
#include <QImage>
|
||||
#include <QPointer>
|
||||
#include <QStringList>
|
||||
#include <QWidget>
|
||||
|
||||
#include "ghostty.h"
|
||||
|
||||
class MainWindow;
|
||||
class QContextMenuEvent;
|
||||
class QDragEnterEvent;
|
||||
class QDropEvent;
|
||||
class QEnterEvent;
|
||||
class QTimer;
|
||||
class InspectorWindow;
|
||||
class SearchBar;
|
||||
class QInputMethodEvent;
|
||||
class QKeySequence;
|
||||
class QLabel;
|
||||
class QOffscreenSurface;
|
||||
class QOpenGLContext;
|
||||
class QOpenGLFramebufferObject;
|
||||
class QOpenGLShaderProgram;
|
||||
class QOpenGLVertexArrayObject;
|
||||
class OverlayScrollbar;
|
||||
|
||||
// One Ghostty terminal pane.
|
||||
//
|
||||
// libghostty's OpenGL renderer draws the terminal into an offscreen
|
||||
// framebuffer owned by a private QOpenGLContext (there is no on-screen
|
||||
// GL surface). Each frame is read back into a QImage and painted with
|
||||
// QPainter. That keeps this an ordinary translucent QWidget, so it
|
||||
// embeds in the QTabWidget / QSplitter tree and its transparent
|
||||
// background composites to the desktop exactly like the rest of the
|
||||
// widget chrome — avoiding QOpenGLWidget (composites opaque on Wayland)
|
||||
// and an embedded QOpenGLWindow (does not present when embedded).
|
||||
class GhosttySurface : public QWidget {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
// `parent_surface` (may be null) is the surface whose working
|
||||
// directory etc. a new surface should inherit.
|
||||
GhosttySurface(ghostty_app_t app, MainWindow *owner,
|
||||
ghostty_surface_t parent_surface);
|
||||
~GhosttySurface() override;
|
||||
|
||||
ghostty_surface_t surface() const { return m_surface; }
|
||||
MainWindow *owner() const { return m_owner; }
|
||||
// Reassign the owning window (used when a tab is torn off into one).
|
||||
void setOwner(MainWindow *owner) { m_owner = owner; }
|
||||
|
||||
// Show a dismissable "process exited" overlay over the terminal. The
|
||||
// surface stays open until the user dismisses it (key or click).
|
||||
void showChildExited(int exitCode);
|
||||
|
||||
// Arm a one-shot desktop notification for the next command to finish
|
||||
// (context-menu item); consumeCommandNotify reads-and-clears the flag.
|
||||
void armCommandNotify() { m_notifyOnCommand = true; }
|
||||
bool consumeCommandNotify() {
|
||||
const bool armed = m_notifyOnCommand;
|
||||
m_notifyOnCommand = false;
|
||||
return armed;
|
||||
}
|
||||
|
||||
public:
|
||||
// Render coalescing: markDirty() flags the surface (called from the
|
||||
// RENDER action, possibly off-thread); renderIfDirty(), called once a
|
||||
// frame by MainWindow, does the actual render.
|
||||
void markDirty() { m_dirty.store(true); }
|
||||
void renderIfDirty();
|
||||
|
||||
// Reflect a libghostty SCROLLBAR action: total scrollback rows, the
|
||||
// viewport-top row, and the visible row count.
|
||||
void updateScrollbar(uint64_t total, uint64_t offset, uint64_t len);
|
||||
|
||||
// Open the title-change dialog (PROMPT_TITLE action / context menu);
|
||||
// `tabScope` picks the tab vs surface title.
|
||||
void promptTitle(bool tabScope);
|
||||
|
||||
// Pending-keybind chord overlay, driven by the KEY_SEQUENCE action:
|
||||
// pushKeySequence appends a chord, endKeySequence clears the overlay.
|
||||
void pushKeySequence(const QString &chord);
|
||||
void endKeySequence();
|
||||
|
||||
// Show/hide/toggle the terminal inspector window (INSPECTOR action).
|
||||
void toggleInspector(ghostty_action_inspector_e mode);
|
||||
|
||||
// In-terminal search (the *_SEARCH actions): openSearch shows the
|
||||
// search bar (optionally pre-filled), closeSearch hides it, and the
|
||||
// setSearch* calls mirror libghostty's reported match counters.
|
||||
void openSearch(const QString &prefill);
|
||||
void closeSearch();
|
||||
void setSearchTotal(int total);
|
||||
void setSearchSelected(int selected);
|
||||
|
||||
// Bell `border` feature: briefly flash a border over the terminal.
|
||||
void flashBorder();
|
||||
// Bell `title` feature: mark/unmark an unacknowledged bell. MainWindow
|
||||
// prefixes the tab title while any surface in the tab is marked.
|
||||
void setBellTitle(bool marked) { m_bellTitle = marked; }
|
||||
bool bellTitle() const { return m_bellTitle; }
|
||||
|
||||
protected:
|
||||
bool event(QEvent *) override;
|
||||
void paintEvent(QPaintEvent *) override;
|
||||
void resizeEvent(QResizeEvent *) override;
|
||||
|
||||
// Disable Qt's Tab/Backtab focus traversal so those keys reach
|
||||
// keyPressEvent and can be forwarded to the terminal.
|
||||
bool focusNextPrevChild(bool) override { return false; }
|
||||
|
||||
void keyPressEvent(QKeyEvent *) override;
|
||||
void keyReleaseEvent(QKeyEvent *) override;
|
||||
void mousePressEvent(QMouseEvent *) override;
|
||||
void mouseReleaseEvent(QMouseEvent *) override;
|
||||
void mouseMoveEvent(QMouseEvent *) override;
|
||||
void contextMenuEvent(QContextMenuEvent *) override;
|
||||
void dragEnterEvent(QDragEnterEvent *) override;
|
||||
void dropEvent(QDropEvent *) override;
|
||||
void wheelEvent(QWheelEvent *) override;
|
||||
void enterEvent(QEnterEvent *) override; // focus-follows-mouse
|
||||
void focusInEvent(QFocusEvent *) override;
|
||||
void focusOutEvent(QFocusEvent *) override;
|
||||
|
||||
// IME composition: preedit text is forwarded to libghostty for inline
|
||||
// display; committed text is inserted as input.
|
||||
void inputMethodEvent(QInputMethodEvent *) override;
|
||||
QVariant inputMethodQuery(Qt::InputMethodQuery) const override;
|
||||
|
||||
private:
|
||||
bool makeCurrent();
|
||||
void syncSurfaceSize();
|
||||
void renderTerminal();
|
||||
void layoutScrollbar(); // position the scrollbar at the edge
|
||||
bool scrollbarAllowed() const; // false when `scrollbar = never`
|
||||
void flashScrollbar(); // reveal the overlay scrollbar, arm hide
|
||||
void buildExitOverlay(int exitCode);
|
||||
void showResizeOverlay(); // transient grid-size overlay on resize
|
||||
void layoutSearchBar(); // position the search bar at the top edge
|
||||
void sendKey(QKeyEvent *, ghostty_input_action_e action);
|
||||
void commitText(const QString &text);
|
||||
void sendMouseButton(QMouseEvent *, ghostty_input_mouse_state_e state);
|
||||
bool rightClickOpensMenu(QMouseEvent *ev) const;
|
||||
|
||||
// The keybind currently bound to `action` (for context-menu hints),
|
||||
// or an empty sequence if none / not displayable.
|
||||
QKeySequence shortcutFor(const char *action) const;
|
||||
|
||||
// Premultiply the framebuffer's alpha; only used when a custom shader
|
||||
// is configured (see GhosttySurface.cpp).
|
||||
void initPremultiply();
|
||||
void premultiplyFramebuffer();
|
||||
|
||||
// libghostty GL platform callbacks (all run on the GUI thread).
|
||||
static void *glGetProcAddress(void *ud, const char *name);
|
||||
static void glMakeCurrent(void *ud);
|
||||
static void glReleaseCurrent(void *ud);
|
||||
static void glPresent(void *ud);
|
||||
|
||||
ghostty_app_t m_app; // shared; owned by MainWindow
|
||||
MainWindow *m_owner; // not owned
|
||||
ghostty_surface_t m_parentSurface; // inherited-config source; may be null
|
||||
ghostty_surface_t m_surface = nullptr;
|
||||
|
||||
// Private offscreen GL context libghostty renders into.
|
||||
QOpenGLContext *m_context = nullptr;
|
||||
QOffscreenSurface *m_offscreen = nullptr;
|
||||
QOpenGLFramebufferObject *m_fbo = nullptr;
|
||||
QImage m_image; // last frame, read back from m_fbo
|
||||
|
||||
// GL objects for the alpha-premultiply pass.
|
||||
QOpenGLShaderProgram *m_premultProg = nullptr;
|
||||
QOpenGLVertexArrayObject *m_premultVao = nullptr;
|
||||
|
||||
int m_fbw = 0; // framebuffer size, device pixels
|
||||
int m_fbh = 0;
|
||||
double m_fbDpr = 1.0; // DPR the framebuffer was sized at
|
||||
|
||||
QLabel *m_exitOverlay = nullptr; // "process exited" banner; lazily made
|
||||
QLabel *m_keySeqOverlay = nullptr; // pending keybind chord; lazily made
|
||||
QStringList m_keySeq; // accumulated pending chords
|
||||
QLabel *m_resizeOverlay = nullptr; // transient "cols x rows"; lazily made
|
||||
QTimer *m_resizeHideTimer = nullptr; // auto-hides m_resizeOverlay
|
||||
bool m_firstGridSeen = false; // for `resize-overlay = after-first`
|
||||
int m_lastCols = 0; // last grid size, to detect changes
|
||||
int m_lastRows = 0;
|
||||
SearchBar *m_searchBar = nullptr; // in-terminal search; lazily made
|
||||
// Terminal inspector window; lazily made. QPointer so a WM-driven
|
||||
// close (treated as hide) or a parent-destroyed cascade leaves the
|
||||
// pointer null instead of dangling.
|
||||
QPointer<InspectorWindow> m_inspectorWindow;
|
||||
OverlayScrollbar *m_scrollbar = nullptr; // floating scrollback scrollbar
|
||||
bool m_scrollAtBottom = true; // viewport is following the buffer tail
|
||||
bool m_notifyOnCommand = false; // one-shot: notify on next cmd finish
|
||||
bool m_bellFlash = false; // bell `border` flash in progress
|
||||
bool m_bellTitle = false; // unacknowledged bell `title` mark
|
||||
// Tracks whether the prior inputMethodEvent reported active preedit.
|
||||
// Used to distinguish a real post-composition commit (forward to the
|
||||
// terminal) from the duplicate ASCII commit that Wayland's
|
||||
// text-input-v3 fires alongside a keyPressEvent (drop it — the key
|
||||
// event will deliver the same text).
|
||||
bool m_hadPreedit = false;
|
||||
std::atomic<bool> m_dirty{false}; // a frame render is pending
|
||||
};
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
#include "GlobalShortcuts.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
#include <QDBusArgument>
|
||||
#include <QDBusConnection>
|
||||
#include <QDBusError>
|
||||
#include <QDBusMessage>
|
||||
#include <QDBusMetaType>
|
||||
#include <QDBusObjectPath>
|
||||
#include <QDBusPendingCallWatcher>
|
||||
#include <QDBusPendingReply>
|
||||
#include <QVariant>
|
||||
#include <QVariantList>
|
||||
|
||||
namespace {
|
||||
constexpr const char *kService = "org.freedesktop.portal.Desktop";
|
||||
constexpr const char *kPath = "/org/freedesktop/portal/desktop";
|
||||
constexpr const char *kInterface = "org.freedesktop.portal.GlobalShortcuts";
|
||||
constexpr const char *kRequest = "org.freedesktop.portal.Request";
|
||||
} // namespace
|
||||
|
||||
// One declared shortcut, marshalled as the portal's `(sa{sv})`.
|
||||
struct PortalShortcut {
|
||||
QString id;
|
||||
QVariantMap props;
|
||||
};
|
||||
Q_DECLARE_METATYPE(PortalShortcut)
|
||||
|
||||
QDBusArgument &operator<<(QDBusArgument &arg, const PortalShortcut &s) {
|
||||
arg.beginStructure();
|
||||
arg << s.id << s.props;
|
||||
arg.endStructure();
|
||||
return arg;
|
||||
}
|
||||
const QDBusArgument &operator>>(const QDBusArgument &arg, PortalShortcut &s) {
|
||||
arg.beginStructure();
|
||||
arg >> s.id >> s.props;
|
||||
arg.endStructure();
|
||||
return arg;
|
||||
}
|
||||
|
||||
GlobalShortcuts::GlobalShortcuts(QObject *parent) : QObject(parent) {
|
||||
qDBusRegisterMetaType<PortalShortcut>();
|
||||
qDBusRegisterMetaType<QList<PortalShortcut>>();
|
||||
|
||||
QDBusConnection bus = QDBusConnection::sessionBus();
|
||||
// One broad subscription, registered now (before the event loop), so
|
||||
// no portal Response can outrun its match rule. An empty path makes
|
||||
// it match every Request object.
|
||||
bus.connect(QString::fromLatin1(kService), QString(),
|
||||
QString::fromLatin1(kRequest), QStringLiteral("Response"), this,
|
||||
SLOT(onResponse(QDBusMessage)));
|
||||
bus.connect(QString::fromLatin1(kService), QString::fromLatin1(kPath),
|
||||
QString::fromLatin1(kInterface), QStringLiteral("Activated"),
|
||||
this, SLOT(onActivated(QDBusMessage)));
|
||||
|
||||
// Create a portal session; shortcut binding follows in its response.
|
||||
QVariantMap options;
|
||||
options[QStringLiteral("session_handle_token")] = nextToken();
|
||||
portalCall(QStringLiteral("CreateSession"), {}, options);
|
||||
}
|
||||
|
||||
QString GlobalShortcuts::nextToken() {
|
||||
return QStringLiteral("ghostty%1").arg(m_tokenCounter++);
|
||||
}
|
||||
|
||||
QString GlobalShortcuts::requestPath(const QString &token) const {
|
||||
// Per the portal Request docs: the path is derived from the caller's
|
||||
// unique bus name with the leading ':' dropped and '.' -> '_'.
|
||||
QString unique = QDBusConnection::sessionBus().baseService();
|
||||
if (unique.startsWith(QLatin1Char(':'))) unique.remove(0, 1);
|
||||
unique.replace(QLatin1Char('.'), QLatin1Char('_'));
|
||||
return QStringLiteral("/org/freedesktop/portal/desktop/request/%1/%2")
|
||||
.arg(unique, token);
|
||||
}
|
||||
|
||||
void GlobalShortcuts::portalCall(const QString &method, QVariantList args,
|
||||
QVariantMap options) {
|
||||
const QString token = nextToken();
|
||||
options[QStringLiteral("handle_token")] = token;
|
||||
args.append(QVariant(options)); // the trailing a{sv} every method takes
|
||||
const QString path = requestPath(token);
|
||||
m_requests.insert(path, method);
|
||||
|
||||
QDBusMessage msg = QDBusMessage::createMethodCall(
|
||||
QString::fromLatin1(kService), QString::fromLatin1(kPath),
|
||||
QString::fromLatin1(kInterface), method);
|
||||
msg.setArguments(args);
|
||||
|
||||
// The real result arrives via the Response signal; watch the call
|
||||
// itself so a failed invocation is not silently swallowed AND the
|
||||
// m_requests entry is dropped (otherwise an errored portal call
|
||||
// would leak a Request entry forever).
|
||||
auto *watcher = new QDBusPendingCallWatcher(
|
||||
QDBusConnection::sessionBus().asyncCall(msg), this);
|
||||
connect(watcher, &QDBusPendingCallWatcher::finished, this,
|
||||
[this, method, path](QDBusPendingCallWatcher *w) {
|
||||
QDBusPendingReply<QDBusObjectPath> reply = *w;
|
||||
if (reply.isError()) {
|
||||
std::fprintf(stderr, "[ghastty] portal %s failed: %s\n",
|
||||
method.toUtf8().constData(),
|
||||
reply.error().message().toUtf8().constData());
|
||||
m_requests.remove(path);
|
||||
}
|
||||
w->deleteLater();
|
||||
});
|
||||
}
|
||||
|
||||
void GlobalShortcuts::onResponse(const QDBusMessage &message) {
|
||||
const QString method = m_requests.take(message.path());
|
||||
if (method.isEmpty()) return; // not one of ours
|
||||
const QVariantList args = message.arguments();
|
||||
if (args.isEmpty()) return;
|
||||
const uint code = args.at(0).toUInt();
|
||||
const QVariantMap results =
|
||||
args.size() > 1 ? qdbus_cast<QVariantMap>(args.at(1)) : QVariantMap();
|
||||
if (code != 0)
|
||||
std::fprintf(stderr, "[ghastty] portal %s response code=%u\n",
|
||||
method.toUtf8().constData(), code);
|
||||
if (method == QLatin1String("CreateSession"))
|
||||
handleCreateSession(code, results);
|
||||
}
|
||||
|
||||
void GlobalShortcuts::handleCreateSession(uint code,
|
||||
const QVariantMap &results) {
|
||||
if (code != 0) return;
|
||||
m_sessionHandle = results.value(QStringLiteral("session_handle")).toString();
|
||||
if (m_sessionHandle.isEmpty()) return;
|
||||
|
||||
// Declare the shortcuts; the desktop owns the actual key assignment
|
||||
// (KDE System Settings -> Shortcuts).
|
||||
// preferred_trigger uses MOD+keysym form (LOGO == Super); the desktop
|
||||
// may honor it as the default key or let the user rebind it.
|
||||
QList<PortalShortcut> shortcuts;
|
||||
shortcuts.append(
|
||||
{QStringLiteral("toggle-quick-terminal"),
|
||||
{{QStringLiteral("description"),
|
||||
QStringLiteral("Toggle the Ghastty quick terminal")},
|
||||
{QStringLiteral("preferred_trigger"), QStringLiteral("LOGO+grave")}}});
|
||||
shortcuts.append(
|
||||
{QStringLiteral("toggle-visibility"),
|
||||
{{QStringLiteral("description"),
|
||||
QStringLiteral("Toggle Ghastty window visibility")}}});
|
||||
|
||||
portalCall(QStringLiteral("BindShortcuts"),
|
||||
{QVariant::fromValue(QDBusObjectPath(m_sessionHandle)),
|
||||
QVariant::fromValue(shortcuts), QString()},
|
||||
{});
|
||||
}
|
||||
|
||||
void GlobalShortcuts::onActivated(const QDBusMessage &message) {
|
||||
// Activated(o session_handle, s shortcut_id, t timestamp, a{sv} options)
|
||||
const QVariantList args = message.arguments();
|
||||
if (args.size() < 2) return;
|
||||
emit activated(args.at(1).toString());
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
#pragma once
|
||||
|
||||
#include <QHash>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QVariantMap>
|
||||
|
||||
class QDBusMessage;
|
||||
|
||||
// Registers global shortcuts through the org.freedesktop.portal
|
||||
// GlobalShortcuts XDG portal, so actions like the quick terminal can be
|
||||
// triggered while Ghostty is unfocused (a normal Wayland client cannot
|
||||
// see keys when unfocused).
|
||||
//
|
||||
// The portal model: the app declares named shortcuts; the desktop
|
||||
// (KDE System Settings -> Shortcuts) owns the actual key assignment.
|
||||
// activated() fires with the shortcut id when one is triggered.
|
||||
class GlobalShortcuts : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit GlobalShortcuts(QObject *parent = nullptr);
|
||||
|
||||
signals:
|
||||
void activated(const QString &id);
|
||||
|
||||
private slots:
|
||||
// Every portal Request's Response lands here; m_requests maps the
|
||||
// request path back to the method that started it.
|
||||
void onResponse(const QDBusMessage &message);
|
||||
void onActivated(const QDBusMessage &message);
|
||||
|
||||
private:
|
||||
// Invoke a GlobalShortcuts method on org.freedesktop.portal.Desktop.
|
||||
// `options` gets a fresh handle_token and is appended as the trailing
|
||||
// argument every portal method expects.
|
||||
void portalCall(const QString &method, QVariantList args,
|
||||
QVariantMap options);
|
||||
void handleCreateSession(uint code, const QVariantMap &results);
|
||||
QString requestPath(const QString &token) const;
|
||||
QString nextToken();
|
||||
|
||||
QString m_sessionHandle; // the portal session object path
|
||||
QHash<QString, QString> m_requests; // request path -> method name
|
||||
int m_tokenCounter = 0;
|
||||
};
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
#include "InspectorWindow.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
#include <QCloseEvent>
|
||||
#include <QKeyEvent>
|
||||
#include <QMouseEvent>
|
||||
#include <QShowEvent>
|
||||
#include <QOffscreenSurface>
|
||||
#include <QOpenGLContext>
|
||||
#include <QOpenGLFramebufferObject>
|
||||
#include <QOpenGLFunctions>
|
||||
#include <QPainter>
|
||||
#include <QSurfaceFormat>
|
||||
#include <QTimer>
|
||||
#include <QWheelEvent>
|
||||
|
||||
#include "Util.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// The editing/navigation keys an ImGui text field needs; other keys
|
||||
// arrive as text via ghostty_inspector_text.
|
||||
ghostty_input_key_e translateKey(int key) {
|
||||
switch (key) {
|
||||
case Qt::Key_Backspace: return GHOSTTY_KEY_BACKSPACE;
|
||||
case Qt::Key_Delete: return GHOSTTY_KEY_DELETE;
|
||||
case Qt::Key_Return:
|
||||
case Qt::Key_Enter: return GHOSTTY_KEY_ENTER;
|
||||
case Qt::Key_Tab: return GHOSTTY_KEY_TAB;
|
||||
case Qt::Key_Escape: return GHOSTTY_KEY_ESCAPE;
|
||||
case Qt::Key_Home: return GHOSTTY_KEY_HOME;
|
||||
case Qt::Key_End: return GHOSTTY_KEY_END;
|
||||
case Qt::Key_Left: return GHOSTTY_KEY_ARROW_LEFT;
|
||||
case Qt::Key_Right: return GHOSTTY_KEY_ARROW_RIGHT;
|
||||
case Qt::Key_Up: return GHOSTTY_KEY_ARROW_UP;
|
||||
case Qt::Key_Down: return GHOSTTY_KEY_ARROW_DOWN;
|
||||
default: return GHOSTTY_KEY_UNIDENTIFIED;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
InspectorWindow::InspectorWindow(ghostty_surface_t surface)
|
||||
: m_surface(surface) {
|
||||
setWindowTitle(QStringLiteral("Ghastty Inspector"));
|
||||
setFocusPolicy(Qt::StrongFocus);
|
||||
setMouseTracking(true);
|
||||
resize(800, 600);
|
||||
|
||||
m_inspector = ghostty_surface_inspector(m_surface);
|
||||
|
||||
// ~30fps: ImGui is immediate-mode, so it must re-render to reflect
|
||||
// hover and animation, not just on explicit RENDER_INSPECTOR actions.
|
||||
m_timer = new QTimer(this);
|
||||
connect(m_timer, &QTimer::timeout, this, &InspectorWindow::renderFrame);
|
||||
m_timer->start(33);
|
||||
}
|
||||
|
||||
InspectorWindow::~InspectorWindow() {
|
||||
// ghostty_inspector_free runs the ImGui OpenGL backend shutdown
|
||||
// (ImGui_ImplOpenGL3_ShutdownWithLoaderCleanup), which needs the GL
|
||||
// context current. Keep the context current across that call, then
|
||||
// release it.
|
||||
//
|
||||
// If makeCurrent fails (display already gone on Wayland teardown,
|
||||
// GPU reset, or the ctor failed before m_context was created) and
|
||||
// the OpenGL backend had been initialized (m_glReady), the backend
|
||||
// shutdown will run without a current context — accepting a
|
||||
// GL-side resource leak. The process is exiting; the OS reclaims.
|
||||
const bool current = m_inspector && makeCurrent();
|
||||
if (current) {
|
||||
ghostty_inspector_opengl_shutdown(m_inspector);
|
||||
delete m_fbo;
|
||||
} else if (m_glReady) {
|
||||
std::fprintf(stderr,
|
||||
"[ghastty] inspector dtor: GL context unavailable; "
|
||||
"leaking inspector GL resources at shutdown\n");
|
||||
}
|
||||
if (m_surface) ghostty_inspector_free(m_surface);
|
||||
if (current) m_context->doneCurrent();
|
||||
delete m_offscreen;
|
||||
}
|
||||
|
||||
bool InspectorWindow::makeCurrent() {
|
||||
if (!m_context) {
|
||||
m_context = new QOpenGLContext(this);
|
||||
m_context->setFormat(QSurfaceFormat::defaultFormat());
|
||||
if (!m_context->create()) return false;
|
||||
m_offscreen = new QOffscreenSurface;
|
||||
m_offscreen->setFormat(m_context->format());
|
||||
m_offscreen->create();
|
||||
}
|
||||
return m_context->makeCurrent(m_offscreen);
|
||||
}
|
||||
|
||||
void InspectorWindow::syncSize() {
|
||||
if (!m_inspector) return;
|
||||
const qreal dpr = devicePixelRatioF();
|
||||
// updateContentScale rebuilds the ImGui style, so only push it when
|
||||
// the scale actually changes; set_size already ignores no-op resizes.
|
||||
if (dpr != m_lastDpr) {
|
||||
ghostty_inspector_set_content_scale(m_inspector, dpr, dpr);
|
||||
m_lastDpr = dpr;
|
||||
}
|
||||
ghostty_inspector_set_size(m_inspector,
|
||||
static_cast<uint32_t>(width() * dpr),
|
||||
static_cast<uint32_t>(height() * dpr));
|
||||
}
|
||||
|
||||
void InspectorWindow::renderFrame() {
|
||||
if (!isVisible() || !m_inspector || !makeCurrent()) return;
|
||||
syncSize();
|
||||
|
||||
const qreal dpr = devicePixelRatioF();
|
||||
const int w = qMax(1, static_cast<int>(width() * dpr));
|
||||
const int h = qMax(1, static_cast<int>(height() * dpr));
|
||||
if (!m_fbo || m_fbo->width() != w || m_fbo->height() != h) {
|
||||
delete m_fbo;
|
||||
m_fbo = new QOpenGLFramebufferObject(w, h);
|
||||
}
|
||||
|
||||
m_fbo->bind();
|
||||
QOpenGLFunctions *gl = m_context->functions();
|
||||
gl->glViewport(0, 0, w, h);
|
||||
gl->glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
|
||||
gl->glClear(GL_COLOR_BUFFER_BIT);
|
||||
|
||||
if (!m_glReady) m_glReady = ghostty_inspector_opengl_init(m_inspector);
|
||||
if (m_glReady) ghostty_inspector_opengl_render(m_inspector);
|
||||
|
||||
m_image = m_fbo->toImage();
|
||||
m_image.setDevicePixelRatio(dpr);
|
||||
m_fbo->release();
|
||||
m_context->doneCurrent();
|
||||
update();
|
||||
}
|
||||
|
||||
void InspectorWindow::paintEvent(QPaintEvent *) {
|
||||
if (m_image.isNull()) return;
|
||||
QPainter painter(this);
|
||||
painter.drawImage(QPointF(0, 0), m_image);
|
||||
}
|
||||
|
||||
void InspectorWindow::resizeEvent(QResizeEvent *) { syncSize(); }
|
||||
|
||||
void InspectorWindow::closeEvent(QCloseEvent *e) {
|
||||
// Hide rather than destroy: the owning GhosttySurface keeps a
|
||||
// QPointer to this window across show/hide cycles. The window is
|
||||
// deleted only when the surface is destroyed. Stop the redraw
|
||||
// timer too — a hidden inspector has no work to do.
|
||||
if (m_timer) m_timer->stop();
|
||||
hide();
|
||||
e->ignore();
|
||||
}
|
||||
|
||||
void InspectorWindow::showEvent(QShowEvent *e) {
|
||||
QWidget::showEvent(e);
|
||||
if (m_timer && !m_timer->isActive()) m_timer->start(33);
|
||||
}
|
||||
|
||||
void InspectorWindow::sendMouseButton(QMouseEvent *ev,
|
||||
ghostty_input_mouse_state_e state) {
|
||||
if (!m_inspector) return;
|
||||
ghostty_input_mouse_button_e button;
|
||||
switch (ev->button()) {
|
||||
case Qt::LeftButton: button = GHOSTTY_MOUSE_LEFT; break;
|
||||
case Qt::RightButton: button = GHOSTTY_MOUSE_RIGHT; break;
|
||||
case Qt::MiddleButton: button = GHOSTTY_MOUSE_MIDDLE; break;
|
||||
default: button = GHOSTTY_MOUSE_UNKNOWN; break;
|
||||
}
|
||||
ghostty_inspector_mouse_button(m_inspector, state, button,
|
||||
translateMods(ev->modifiers()));
|
||||
}
|
||||
|
||||
void InspectorWindow::mouseMoveEvent(QMouseEvent *ev) {
|
||||
if (m_inspector)
|
||||
ghostty_inspector_mouse_pos(m_inspector, ev->position().x(),
|
||||
ev->position().y());
|
||||
}
|
||||
|
||||
void InspectorWindow::mousePressEvent(QMouseEvent *ev) {
|
||||
sendMouseButton(ev, GHOSTTY_MOUSE_PRESS);
|
||||
}
|
||||
|
||||
void InspectorWindow::mouseReleaseEvent(QMouseEvent *ev) {
|
||||
sendMouseButton(ev, GHOSTTY_MOUSE_RELEASE);
|
||||
}
|
||||
|
||||
void InspectorWindow::wheelEvent(QWheelEvent *ev) {
|
||||
if (!m_inspector) return;
|
||||
const QPoint d = ev->angleDelta();
|
||||
ghostty_inspector_mouse_scroll(m_inspector, d.x() / 120.0, d.y() / 120.0,
|
||||
0);
|
||||
}
|
||||
|
||||
void InspectorWindow::keyPressEvent(QKeyEvent *ev) {
|
||||
if (!m_inspector) return;
|
||||
const ghostty_input_key_e key = translateKey(ev->key());
|
||||
if (key != GHOSTTY_KEY_UNIDENTIFIED)
|
||||
ghostty_inspector_key(m_inspector, GHOSTTY_ACTION_PRESS, key,
|
||||
translateMods(ev->modifiers()));
|
||||
// Printable text drives ImGui's text input. Reject the whole string
|
||||
// if any byte is a C0 control or DEL — checking only the first byte
|
||||
// would let multi-char text starting with a printable then a newline
|
||||
// sneak control codes through.
|
||||
const QByteArray text = ev->text().toUtf8();
|
||||
if (text.isEmpty()) return;
|
||||
for (char c : text) {
|
||||
const auto u = static_cast<unsigned char>(c);
|
||||
if (u < 0x20 || u == 0x7f) return;
|
||||
}
|
||||
ghostty_inspector_text(m_inspector, text.constData());
|
||||
}
|
||||
|
||||
void InspectorWindow::keyReleaseEvent(QKeyEvent *ev) {
|
||||
if (!m_inspector) return;
|
||||
const ghostty_input_key_e key = translateKey(ev->key());
|
||||
if (key != GHOSTTY_KEY_UNIDENTIFIED)
|
||||
ghostty_inspector_key(m_inspector, GHOSTTY_ACTION_RELEASE, key,
|
||||
translateMods(ev->modifiers()));
|
||||
}
|
||||
|
||||
void InspectorWindow::focusInEvent(QFocusEvent *) {
|
||||
if (m_inspector) ghostty_inspector_set_focus(m_inspector, true);
|
||||
}
|
||||
|
||||
void InspectorWindow::focusOutEvent(QFocusEvent *) {
|
||||
if (m_inspector) ghostty_inspector_set_focus(m_inspector, false);
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
#pragma once
|
||||
|
||||
#include <QImage>
|
||||
#include <QWidget>
|
||||
|
||||
#include "ghostty.h"
|
||||
|
||||
class QOffscreenSurface;
|
||||
class QOpenGLContext;
|
||||
class QOpenGLFramebufferObject;
|
||||
class QTimer;
|
||||
|
||||
// A window hosting libghostty's terminal inspector — a Dear ImGui debug
|
||||
// UI. libghostty renders the inspector through ghostty_inspector_opengl_*
|
||||
// into an offscreen framebuffer owned by a private QOpenGLContext; each
|
||||
// frame is read back into a QImage and painted, mirroring how
|
||||
// GhosttySurface composites the terminal.
|
||||
//
|
||||
// The inspector window is shown via a normal Qt::Widget close (WM
|
||||
// close button), which is treated as "hide", not "destroy" — the
|
||||
// owning surface keeps a QPointer to it and toggles visibility. The
|
||||
// window only deletes when its owning GhosttySurface is destroyed.
|
||||
class InspectorWindow : public QWidget {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
// `surface` is the terminal surface being inspected.
|
||||
explicit InspectorWindow(ghostty_surface_t surface);
|
||||
~InspectorWindow() override;
|
||||
|
||||
protected:
|
||||
// Treat the WM close button as a hide rather than a destroy. The
|
||||
// GhosttySurface owns the inspector's lifetime; closing here would
|
||||
// dangle its QPointer and skip libghostty inspector teardown.
|
||||
void closeEvent(QCloseEvent *) override;
|
||||
// Restart the redraw timer when the inspector becomes visible
|
||||
// again; closeEvent stops it to avoid CPU waste while hidden.
|
||||
void showEvent(QShowEvent *) override;
|
||||
void paintEvent(QPaintEvent *) override;
|
||||
void resizeEvent(QResizeEvent *) override;
|
||||
void mouseMoveEvent(QMouseEvent *) override;
|
||||
void mousePressEvent(QMouseEvent *) override;
|
||||
void mouseReleaseEvent(QMouseEvent *) override;
|
||||
void wheelEvent(QWheelEvent *) override;
|
||||
void keyPressEvent(QKeyEvent *) override;
|
||||
void keyReleaseEvent(QKeyEvent *) override;
|
||||
void focusInEvent(QFocusEvent *) override;
|
||||
void focusOutEvent(QFocusEvent *) override;
|
||||
|
||||
private:
|
||||
bool makeCurrent();
|
||||
void renderFrame(); // render the inspector, read it back
|
||||
void syncSize(); // push the size/scale to libghostty
|
||||
void sendMouseButton(QMouseEvent *, ghostty_input_mouse_state_e state);
|
||||
|
||||
ghostty_surface_t m_surface;
|
||||
ghostty_inspector_t m_inspector = nullptr;
|
||||
|
||||
// Private offscreen GL context the inspector renders into.
|
||||
QOpenGLContext *m_context = nullptr;
|
||||
QOffscreenSurface *m_offscreen = nullptr;
|
||||
QOpenGLFramebufferObject *m_fbo = nullptr;
|
||||
QImage m_image; // last frame, read back from m_fbo
|
||||
QTimer *m_timer = nullptr; // drives ~30fps redraws while visible
|
||||
bool m_glReady = false; // ghostty_inspector_opengl_init done
|
||||
double m_lastDpr = 0; // last pushed content scale
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,236 @@
|
|||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
|
||||
#include <QList>
|
||||
#include <QSize>
|
||||
#include <QWidget>
|
||||
|
||||
#include "ghostty.h"
|
||||
|
||||
class QAudioOutput;
|
||||
class QCloseEvent;
|
||||
class QMediaPlayer;
|
||||
class QShowEvent;
|
||||
class QSplitter;
|
||||
class TabWidget;
|
||||
class QTimer;
|
||||
class CommandPalette;
|
||||
class GhosttySurface;
|
||||
|
||||
// A top-level window presenting terminal surfaces as tabs; each tab may
|
||||
// be subdivided into splits. The libghostty app and config are shared
|
||||
// process-wide across every window (the static s_* members below).
|
||||
//
|
||||
// Widget tree: QTabWidget -> tab page (QWidget) -> split tree, where a
|
||||
// node is either a GhosttySurface (a QOpenGLWidget) or a QSplitter of
|
||||
// two such nodes.
|
||||
class MainWindow : public QWidget {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
MainWindow();
|
||||
~MainWindow() override;
|
||||
|
||||
// Per-window setup. The first call also creates the shared libghostty
|
||||
// app and config; later windows reuse them. Call once before show().
|
||||
bool initialize();
|
||||
|
||||
// Open a new top-level window, sharing the libghostty app, with one
|
||||
// tab whose surface inherits from `parent` (may be null).
|
||||
static MainWindow *newWindow(ghostty_surface_t parent);
|
||||
|
||||
// Show or hide every window at once (TOGGLE_VISIBILITY).
|
||||
static void toggleVisibility();
|
||||
|
||||
// Show/hide the dropdown quick terminal, creating it on first use
|
||||
// (TOGGLE_QUICK_TERMINAL). There is at most one per process.
|
||||
static void toggleQuickTerminal();
|
||||
|
||||
// Open a new tab. `parent` (may be null) is the surface whose working
|
||||
// directory etc. the new surface should inherit.
|
||||
GhosttySurface *newTab(ghostty_surface_t parent);
|
||||
|
||||
// Split `target`'s pane in two, adding a new surface beside it.
|
||||
GhosttySurface *splitSurface(GhosttySurface *target,
|
||||
ghostty_action_split_direction_e dir);
|
||||
|
||||
// Remove a single surface: collapses its split, or closes the tab if
|
||||
// it was the tab's only surface (and the window if it was the last).
|
||||
void removeSurface(GhosttySurface *surface);
|
||||
|
||||
// Update the tab label and window title for `surface`.
|
||||
void setSurfaceTitle(GhosttySurface *surface, const QString &title);
|
||||
|
||||
// The live libghostty config (for keybind lookups, etc.).
|
||||
ghostty_config_t config() const { return s_config; }
|
||||
|
||||
// Whether a custom shader is configured. With one, libghostty's final
|
||||
// framebuffer is non-premultiplied and surfaces must premultiply it
|
||||
// before Qt composites (see GhosttySurface::premultiplyFramebuffer).
|
||||
bool needsPremultiply() const { return s_needsPremultiply; }
|
||||
|
||||
// Whether `focus-follows-mouse` is enabled — a GhosttySurface grabs
|
||||
// focus when the pointer enters it.
|
||||
bool focusFollowsMouse() const;
|
||||
|
||||
protected:
|
||||
bool event(QEvent *) override;
|
||||
void showEvent(QShowEvent *) override;
|
||||
// Honors `confirm-close-surface`: prompts if a surface has a running
|
||||
// process, and ignores the event if the user declines.
|
||||
void closeEvent(QCloseEvent *) override;
|
||||
// Drives quick-terminal autohide on loss of activation.
|
||||
void changeEvent(QEvent *) override;
|
||||
|
||||
private slots:
|
||||
void onTabCloseRequested(int index);
|
||||
void onCurrentChanged(int index);
|
||||
|
||||
private:
|
||||
// Create the first tab once the device pixel ratio has settled.
|
||||
void createFirstTab();
|
||||
|
||||
// 60fps frame timer body. Static because there is only one timer
|
||||
// per process — N windows pointing at the same shared ghostty_app_t.
|
||||
// Ticks libghostty once and renders any dirty surface across every
|
||||
// window.
|
||||
static void frame();
|
||||
|
||||
void closeTab(int index);
|
||||
// Tear tab `index` out into a new window (tabTornOff signal).
|
||||
void detachTab(int index);
|
||||
// Move `page` (a tab and its surfaces) from `src` into this window.
|
||||
void adoptTab(MainWindow *src, QWidget *page);
|
||||
GhosttySurface *surfaceAt(int index) const;
|
||||
int tabIndexForSurface(GhosttySurface *surface) const;
|
||||
QList<GhosttySurface *> surfacesInTab(int index) const;
|
||||
|
||||
// Keybind-driven navigation between tabs and split panes.
|
||||
void gotoTab(ghostty_action_goto_tab_e tab);
|
||||
void gotoSplit(GhosttySurface *from, ghostty_action_goto_split_e dir);
|
||||
void resizeSplit(GhosttySurface *from, ghostty_action_resize_split_s rs);
|
||||
void equalizeSplits(GhosttySurface *from);
|
||||
void moveTab(int amount); // reorder the current tab by `amount`
|
||||
|
||||
// Ring the terminal bell, honoring the `bell-features` config.
|
||||
void ringBell(GhosttySurface *surface);
|
||||
void playBellAudio();
|
||||
|
||||
// Bell `title` feature: prefix a tab's title while any surface in it
|
||||
// has an unacknowledged bell.
|
||||
bool tabBellMarked(int tab) const;
|
||||
|
||||
// Recompute a tab's displayed text from its stored base (terminal)
|
||||
// title and manual override, plus any bell mark. Tab data holds a
|
||||
// {base, override} QStringList.
|
||||
void updateTabText(int tab);
|
||||
// Set/clear a tab's manual title override (empty string clears it);
|
||||
// while set, SET_TITLE no longer changes the tab text.
|
||||
void setTabTitleOverride(GhosttySurface *surface, const QString &title);
|
||||
// Copy the current tab's effective title to the clipboard.
|
||||
void copyTitleToClipboard();
|
||||
|
||||
// Rebuild the config from disk and push it to libghostty.
|
||||
void reloadConfig();
|
||||
// Refresh every window's chrome from the current config (used after a
|
||||
// reload and on the CONFIG_CHANGE notification).
|
||||
static void refreshChrome();
|
||||
|
||||
// Typed wrappers over ghostty_config_get. configString also serves
|
||||
// enum keys — libghostty returns an enum as its tag name string.
|
||||
QString configString(const char *key) const;
|
||||
bool configBool(const char *key, bool fallback) const;
|
||||
|
||||
// Apply config-driven window settings that may change on reload: the
|
||||
// tab-bar visibility policy and the light/dark colour scheme.
|
||||
void applyWindowConfig();
|
||||
|
||||
// Apply the `background-blur` config to this window via the KWin
|
||||
// compositor (see WindowBlur).
|
||||
void applyBlur();
|
||||
|
||||
// Turn this window into a layer-shell dropdown anchored to a screen
|
||||
// edge, per the `quick-terminal-*` config. Quick-terminal only.
|
||||
void setupLayerShell();
|
||||
|
||||
// Show/hide the command palette (TOGGLE_COMMAND_PALETTE), scoped to
|
||||
// `surface` for executing the chosen command.
|
||||
void toggleCommandPalette(GhosttySurface *surface);
|
||||
|
||||
// Prompt (per `confirm-close-surface`) before closing `surfaces`.
|
||||
// Returns true if the close may proceed.
|
||||
bool confirmCloseSurfaces(const QList<GhosttySurface *> &surfaces);
|
||||
|
||||
// Close every window, optionally quitting the process; prompts once
|
||||
// via ghostty_app_needs_confirm_quit.
|
||||
static void closeAllWindows();
|
||||
|
||||
// Wire the libghostty quit_timer action to a delayed QApplication
|
||||
// quit, gated on `quit-after-last-window-closed`.
|
||||
static void handleQuitTimer(bool start);
|
||||
|
||||
// Toggle a split pane filling its tab. Re-parents the surface out of
|
||||
// / back into the splitter tree.
|
||||
void toggleSplitZoom(GhosttySurface *surface);
|
||||
|
||||
// Runtime callbacks dispatched by libghostty. wakeup/action are
|
||||
// app-level (routed via the target surface or s_windows); clipboard/
|
||||
// close carry the surface userdata.
|
||||
static void onWakeup(void *ud);
|
||||
static bool onAction(ghostty_app_t, ghostty_target_s, ghostty_action_s);
|
||||
static bool onReadClipboard(void *ud, ghostty_clipboard_e, void *state);
|
||||
static void onConfirmReadClipboard(void *ud, const char *, void *state,
|
||||
ghostty_clipboard_request_e);
|
||||
static void onWriteClipboard(void *ud, ghostty_clipboard_e,
|
||||
const ghostty_clipboard_content_s *, size_t,
|
||||
bool);
|
||||
static void onCloseSurface(void *ud, bool process_active);
|
||||
|
||||
// True if `s` is still owned by some live MainWindow. The surface
|
||||
// userdata callbacks above use this to validate a libghostty-supplied
|
||||
// pointer before dereferencing — a worker-thread callback can race
|
||||
// the GhosttySurface destructor.
|
||||
static bool surfaceAlive(GhosttySurface *s);
|
||||
|
||||
TabWidget *m_tabs = nullptr;
|
||||
QList<GhosttySurface *> m_surfaces; // every live surface in this window
|
||||
bool m_firstTabPending = true; // first tab is created on show()
|
||||
ghostty_surface_t m_firstTabParent = nullptr; // inherited by the 1st tab
|
||||
bool m_skipCloseConfirm = false; // close already confirmed elsewhere
|
||||
bool m_quickTerminal = false; // this is the dropdown quick terminal
|
||||
QSize m_defaultWindowSize; // for RESET_WINDOW_SIZE; from INITIAL_SIZE
|
||||
|
||||
// Process-shared libghostty state: one app and config drive every
|
||||
// window. Created by the first initialize(), freed with the last
|
||||
// window. s_windows tracks every live window.
|
||||
static ghostty_app_t s_app;
|
||||
static ghostty_config_t s_config;
|
||||
static bool s_needsPremultiply; // a custom shader is configured
|
||||
static QList<MainWindow *> s_windows;
|
||||
static QTimer *s_quitTimer; // delayed quit-after-last-window
|
||||
static int s_quitDelayMs; // 0 = no delay configured
|
||||
static MainWindow *s_quickTerminal; // the one quick terminal, if any
|
||||
// Process-wide 60Hz frame timer. Replaces a per-window timer, so N
|
||||
// windows do not produce N ghostty_app_tick calls every 16ms for the
|
||||
// same shared app.
|
||||
static QTimer *s_frameTimer;
|
||||
|
||||
// Coalesces wakeup-driven ticks: a tick is queued at most once at a
|
||||
// time, so a busy surface can't flood the event loop.
|
||||
static std::atomic<bool> s_tickPending;
|
||||
|
||||
// Split-zoom state: the surface temporarily filling its tab, the
|
||||
// splitter it came from, its index there, and the stashed tree root.
|
||||
GhosttySurface *m_zoomed = nullptr;
|
||||
QWidget *m_zoomRoot = nullptr;
|
||||
QSplitter *m_zoomSplitter = nullptr;
|
||||
int m_zoomIndex = 0;
|
||||
|
||||
// Bell audio playback; created lazily on the first audio bell.
|
||||
QMediaPlayer *m_bellPlayer = nullptr;
|
||||
QAudioOutput *m_bellAudio = nullptr;
|
||||
|
||||
// The command palette; created lazily on first use.
|
||||
CommandPalette *m_commandPalette = nullptr;
|
||||
};
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
#include "OverlayScrollbar.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include <QEnterEvent>
|
||||
#include <QMouseEvent>
|
||||
#include <QPainter>
|
||||
#include <QPropertyAnimation>
|
||||
#include <QTimer>
|
||||
|
||||
namespace {
|
||||
constexpr int kMargin = 3; // handle inset from the strip edges
|
||||
constexpr int kMinHandle = 36; // minimum handle height
|
||||
constexpr int kIdleWidth = 6; // handle width when idle
|
||||
constexpr int kHoverWidth = 9; // handle width when hovered
|
||||
constexpr int kHideDelayMs = 1400;
|
||||
} // namespace
|
||||
|
||||
OverlayScrollbar::OverlayScrollbar(QWidget *parent) : QWidget(parent) {
|
||||
setMouseTracking(true);
|
||||
|
||||
m_fade = new QPropertyAnimation(this, "opacity", this);
|
||||
connect(m_fade, &QPropertyAnimation::finished, this, [this]() {
|
||||
if (m_opacity <= 0.0) hide();
|
||||
});
|
||||
|
||||
m_hideTimer = new QTimer(this);
|
||||
m_hideTimer->setSingleShot(true);
|
||||
m_hideTimer->setInterval(kHideDelayMs);
|
||||
connect(m_hideTimer, &QTimer::timeout, this, [this]() {
|
||||
if (m_dragging || m_hover) {
|
||||
m_hideTimer->start(); // still in use — check again later
|
||||
return;
|
||||
}
|
||||
fadeTo(0.0, 280);
|
||||
});
|
||||
hide();
|
||||
}
|
||||
|
||||
void OverlayScrollbar::setOpacity(qreal o) {
|
||||
o = std::clamp(o, 0.0, 1.0);
|
||||
if (o == m_opacity) return;
|
||||
m_opacity = o;
|
||||
update();
|
||||
}
|
||||
|
||||
void OverlayScrollbar::setHandleColor(const QColor &color) {
|
||||
if (color == m_handleColor) return;
|
||||
m_handleColor = color;
|
||||
update();
|
||||
}
|
||||
|
||||
void OverlayScrollbar::setMetrics(quint64 total, quint64 offset,
|
||||
quint64 len) {
|
||||
m_total = total;
|
||||
m_offset = offset;
|
||||
m_len = len;
|
||||
// Repaint when visible OR while a fade-out is in flight; the handle
|
||||
// position changes constantly with output, and skipping the update
|
||||
// makes the fading scrollbar lag behind the actual scrollback.
|
||||
if (isVisible() || m_opacity > 0.0) update();
|
||||
}
|
||||
|
||||
void OverlayScrollbar::fadeTo(qreal target, int ms) {
|
||||
m_fade->stop();
|
||||
m_fade->setDuration(ms);
|
||||
m_fade->setStartValue(m_opacity);
|
||||
m_fade->setEndValue(target);
|
||||
m_fade->start();
|
||||
}
|
||||
|
||||
void OverlayScrollbar::reveal() {
|
||||
if (m_total <= m_len) return; // nothing to scroll
|
||||
show();
|
||||
raise();
|
||||
fadeTo(1.0, 110);
|
||||
m_hideTimer->start();
|
||||
}
|
||||
|
||||
QRect OverlayScrollbar::handleRect() const {
|
||||
if (m_total <= m_len) return {};
|
||||
const int trackH = height() - 2 * kMargin;
|
||||
if (trackH <= 0) return {};
|
||||
|
||||
int handleH = static_cast<int>(static_cast<double>(trackH) * m_len /
|
||||
static_cast<double>(m_total));
|
||||
handleH = std::clamp(handleH, std::min(kMinHandle, trackH), trackH);
|
||||
|
||||
const quint64 scrollable = m_total - m_len;
|
||||
const int travel = trackH - handleH;
|
||||
const int handleY =
|
||||
kMargin + (scrollable ? static_cast<int>(static_cast<double>(travel) *
|
||||
m_offset / scrollable)
|
||||
: 0);
|
||||
|
||||
const int w = m_hover || m_dragging ? kHoverWidth : kIdleWidth;
|
||||
return QRect(width() - w - kMargin, handleY, w, handleH);
|
||||
}
|
||||
|
||||
void OverlayScrollbar::paintEvent(QPaintEvent *) {
|
||||
if (m_opacity <= 0.0) return;
|
||||
const QRect handle = handleRect();
|
||||
if (handle.isEmpty()) return;
|
||||
|
||||
QPainter painter(this);
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
QColor c = m_handleColor;
|
||||
// Idle is fairly subtle; hover/drag brighten it. The fade scales it all.
|
||||
const qreal base = m_dragging ? 0.80 : m_hover ? 0.62 : 0.42;
|
||||
c.setAlphaF(base * m_opacity);
|
||||
painter.setPen(Qt::NoPen);
|
||||
painter.setBrush(c);
|
||||
const qreal radius = handle.width() / 2.0;
|
||||
painter.drawRoundedRect(handle, radius, radius);
|
||||
}
|
||||
|
||||
void OverlayScrollbar::emitRowForHandleTop(int top) {
|
||||
if (m_total <= m_len) return;
|
||||
const int trackH = height() - 2 * kMargin;
|
||||
const int travel = trackH - handleRect().height();
|
||||
double frac = travel > 0
|
||||
? static_cast<double>(top - kMargin) / travel
|
||||
: 0.0;
|
||||
frac = std::clamp(frac, 0.0, 1.0);
|
||||
emit scrollToRow(static_cast<int>(frac * (m_total - m_len)));
|
||||
}
|
||||
|
||||
void OverlayScrollbar::mousePressEvent(QMouseEvent *ev) {
|
||||
if (ev->button() != Qt::LeftButton || m_total <= m_len) {
|
||||
ev->ignore();
|
||||
return;
|
||||
}
|
||||
const QPoint pos = ev->position().toPoint();
|
||||
const QRect handle = handleRect();
|
||||
if (handle.contains(pos)) {
|
||||
m_dragging = true;
|
||||
m_dragGrab = pos.y() - handle.top();
|
||||
} else {
|
||||
// Trough click: page toward the cursor.
|
||||
const qint64 page = static_cast<qint64>(m_len);
|
||||
qint64 row = static_cast<qint64>(m_offset) +
|
||||
(pos.y() < handle.top() ? -page : page);
|
||||
row = std::clamp<qint64>(row, 0,
|
||||
static_cast<qint64>(m_total - m_len));
|
||||
emit scrollToRow(static_cast<int>(row));
|
||||
}
|
||||
m_hideTimer->start();
|
||||
update();
|
||||
}
|
||||
|
||||
void OverlayScrollbar::mouseMoveEvent(QMouseEvent *ev) {
|
||||
if (!m_dragging) return;
|
||||
emitRowForHandleTop(ev->position().toPoint().y() - m_dragGrab);
|
||||
m_hideTimer->start();
|
||||
}
|
||||
|
||||
void OverlayScrollbar::mouseReleaseEvent(QMouseEvent *) {
|
||||
m_dragging = false;
|
||||
m_hideTimer->start();
|
||||
update();
|
||||
}
|
||||
|
||||
void OverlayScrollbar::enterEvent(QEnterEvent *) {
|
||||
m_hover = true;
|
||||
reveal(); // re-reveal in case it was mid fade-out
|
||||
update();
|
||||
}
|
||||
|
||||
void OverlayScrollbar::leaveEvent(QEvent *) {
|
||||
m_hover = false;
|
||||
m_hideTimer->start();
|
||||
update();
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
#pragma once
|
||||
|
||||
#include <QColor>
|
||||
#include <QWidget>
|
||||
|
||||
class QPropertyAnimation;
|
||||
class QTimer;
|
||||
|
||||
// A thin scrollback scrollbar that floats over the terminal.
|
||||
//
|
||||
// Qt's QScrollBar has no overlay/auto-hide mode (unlike the native GTK4
|
||||
// and AppKit scrollbars the other Ghostty frontends get for free), so
|
||||
// this is a small purpose-built widget: it custom-paints a rounded-pill
|
||||
// handle, fades in on scroll activity and out when idle, and expands
|
||||
// slightly on hover. It is driven by the libghostty SCROLLBAR action.
|
||||
class OverlayScrollbar : public QWidget {
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(qreal opacity READ opacity WRITE setOpacity)
|
||||
|
||||
public:
|
||||
// Logical width of the strip the widget occupies at the window edge.
|
||||
static constexpr int kWidth = 16;
|
||||
|
||||
explicit OverlayScrollbar(QWidget *parent);
|
||||
|
||||
// Scrollback metrics from the SCROLLBAR action (rows).
|
||||
void setMetrics(quint64 total, quint64 offset, quint64 len);
|
||||
// Fade the scrollbar in and (re)arm the idle-hide timer.
|
||||
void reveal();
|
||||
// Handle colour, typically derived from the terminal background.
|
||||
void setHandleColor(const QColor &color);
|
||||
|
||||
qreal opacity() const { return m_opacity; }
|
||||
void setOpacity(qreal o);
|
||||
|
||||
signals:
|
||||
// The user dragged or clicked the scrollbar to this scrollback row.
|
||||
void scrollToRow(int row);
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *) override;
|
||||
void mousePressEvent(QMouseEvent *) override;
|
||||
void mouseMoveEvent(QMouseEvent *) override;
|
||||
void mouseReleaseEvent(QMouseEvent *) override;
|
||||
void enterEvent(QEnterEvent *) override;
|
||||
void leaveEvent(QEvent *) override;
|
||||
|
||||
private:
|
||||
QRect handleRect() const; // pixel rect of the handle
|
||||
void emitRowForHandleTop(int top);
|
||||
void fadeTo(qreal target, int ms);
|
||||
|
||||
quint64 m_total = 0; // total scrollback rows
|
||||
quint64 m_offset = 0; // viewport-top row
|
||||
quint64 m_len = 0; // visible rows
|
||||
qreal m_opacity = 0.0;
|
||||
QColor m_handleColor = QColor(235, 235, 235);
|
||||
bool m_hover = false;
|
||||
bool m_dragging = false;
|
||||
int m_dragGrab = 0; // cursor offset within the handle
|
||||
QPropertyAnimation *m_fade = nullptr;
|
||||
QTimer *m_hideTimer = nullptr;
|
||||
};
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
#include "SearchBar.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QEvent>
|
||||
#include <QHBoxLayout>
|
||||
#include <QIcon>
|
||||
#include <QKeyEvent>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QPalette>
|
||||
#include <QTimer>
|
||||
#include <QToolButton>
|
||||
|
||||
#include "GhosttySurface.h"
|
||||
|
||||
namespace {
|
||||
// A themed tool button, icon from the icon theme with a text fallback.
|
||||
QToolButton *makeButton(QWidget *parent, const QString &iconName,
|
||||
const QString &fallback, const QString &tip) {
|
||||
auto *b = new QToolButton(parent);
|
||||
const QIcon icon = QIcon::fromTheme(iconName);
|
||||
if (icon.isNull())
|
||||
b->setText(fallback);
|
||||
else
|
||||
b->setIcon(icon);
|
||||
b->setAutoRaise(true);
|
||||
b->setToolTip(tip);
|
||||
b->setFocusPolicy(Qt::NoFocus);
|
||||
return b;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
SearchBar::SearchBar(GhosttySurface *surface)
|
||||
: QFrame(surface), m_surface(surface) {
|
||||
// A themed panel: the frame, field and buttons follow the active Qt
|
||||
// style and palette.
|
||||
setFrameShape(QFrame::StyledPanel);
|
||||
setAutoFillBackground(true);
|
||||
|
||||
m_field = new QLineEdit(this);
|
||||
m_field->setPlaceholderText(QStringLiteral("Find"));
|
||||
m_field->setMinimumWidth(200);
|
||||
m_field->installEventFilter(this);
|
||||
|
||||
// The match counter lives inside the field, at its right edge, in the
|
||||
// muted placeholder-text colour.
|
||||
m_count = new QLabel(m_field);
|
||||
m_count->setAttribute(Qt::WA_TransparentForMouseEvents);
|
||||
QPalette pal = m_count->palette();
|
||||
pal.setColor(QPalette::WindowText, pal.color(QPalette::PlaceholderText));
|
||||
m_count->setPalette(pal);
|
||||
|
||||
QToolButton *prev = makeButton(this, QStringLiteral("go-up"),
|
||||
QStringLiteral("▲"),
|
||||
QStringLiteral("Previous match"));
|
||||
QToolButton *next = makeButton(this, QStringLiteral("go-down"),
|
||||
QStringLiteral("▼"),
|
||||
QStringLiteral("Next match"));
|
||||
QToolButton *close = makeButton(this, QStringLiteral("window-close"),
|
||||
QStringLiteral("✕"),
|
||||
QStringLiteral("Close search"));
|
||||
|
||||
auto *layout = new QHBoxLayout(this);
|
||||
layout->setContentsMargins(6, 4, 6, 4);
|
||||
layout->setSpacing(2);
|
||||
layout->addWidget(m_field);
|
||||
layout->addWidget(prev);
|
||||
layout->addWidget(next);
|
||||
layout->addWidget(close);
|
||||
|
||||
// Coalesce keystrokes so a fast typist does not thrash the search.
|
||||
m_debounce = new QTimer(this);
|
||||
m_debounce->setSingleShot(true);
|
||||
m_debounce->setInterval(200);
|
||||
connect(m_debounce, &QTimer::timeout, this, &SearchBar::sendQuery);
|
||||
connect(m_field, &QLineEdit::textChanged, this,
|
||||
[this]() { m_debounce->start(); });
|
||||
connect(prev, &QToolButton::clicked, this, [this]() { navigate(false); });
|
||||
connect(next, &QToolButton::clicked, this, [this]() { navigate(true); });
|
||||
connect(close, &QToolButton::clicked, this, [this]() {
|
||||
runAction("end_search");
|
||||
hide();
|
||||
// m_surface is the parent so it normally outlives the bar, but
|
||||
// during a window teardown Qt may deliver this signal mid-cascade.
|
||||
if (m_surface) m_surface->setFocus();
|
||||
});
|
||||
hide();
|
||||
}
|
||||
|
||||
void SearchBar::open(const QString &prefill) {
|
||||
m_total = -1;
|
||||
m_selected = -1;
|
||||
updateCount();
|
||||
show();
|
||||
raise();
|
||||
if (!prefill.isEmpty())
|
||||
m_field->setText(prefill); // textChanged → debounced query
|
||||
m_field->setFocus();
|
||||
m_field->selectAll();
|
||||
}
|
||||
|
||||
void SearchBar::setTotal(int total) {
|
||||
m_total = total;
|
||||
updateCount();
|
||||
}
|
||||
|
||||
void SearchBar::setSelected(int selected) {
|
||||
m_selected = selected;
|
||||
updateCount();
|
||||
}
|
||||
|
||||
void SearchBar::updateCount() {
|
||||
QString text;
|
||||
if (m_total == 0)
|
||||
text = QStringLiteral("No results");
|
||||
else if (m_total > 0)
|
||||
text = QStringLiteral("%1/%2")
|
||||
.arg(m_selected > 0 ? m_selected : 0)
|
||||
.arg(m_total);
|
||||
m_count->setText(text);
|
||||
m_count->adjustSize();
|
||||
positionCount();
|
||||
}
|
||||
|
||||
void SearchBar::positionCount() {
|
||||
const int pad = 6;
|
||||
m_count->move(m_field->width() - m_count->width() - pad,
|
||||
(m_field->height() - m_count->height()) / 2);
|
||||
// Reserve room so typed text never slides under the counter.
|
||||
const int reserve =
|
||||
m_count->text().isEmpty() ? 0 : m_count->width() + pad + 2;
|
||||
m_field->setTextMargins(0, 0, reserve, 0);
|
||||
}
|
||||
|
||||
void SearchBar::sendQuery() {
|
||||
// An empty needle cancels the search, which libghostty handles.
|
||||
const QByteArray q =
|
||||
QByteArrayLiteral("search:") + m_field->text().toUtf8();
|
||||
if (m_surface && m_surface->surface())
|
||||
ghostty_surface_binding_action(m_surface->surface(), q.constData(),
|
||||
q.size());
|
||||
}
|
||||
|
||||
void SearchBar::navigate(bool next) {
|
||||
runAction(next ? "navigate_search:next" : "navigate_search:previous");
|
||||
}
|
||||
|
||||
void SearchBar::runAction(const char *action) {
|
||||
if (m_surface && m_surface->surface())
|
||||
ghostty_surface_binding_action(m_surface->surface(), action,
|
||||
qstrlen(action));
|
||||
}
|
||||
|
||||
bool SearchBar::eventFilter(QObject *obj, QEvent *event) {
|
||||
if (obj == m_field) {
|
||||
if (event->type() == QEvent::Resize) {
|
||||
positionCount();
|
||||
} else if (event->type() == QEvent::KeyPress) {
|
||||
auto *ke = static_cast<QKeyEvent *>(event);
|
||||
switch (ke->key()) {
|
||||
case Qt::Key_Escape:
|
||||
runAction("end_search");
|
||||
hide();
|
||||
if (m_surface) m_surface->setFocus();
|
||||
return true;
|
||||
case Qt::Key_Return:
|
||||
case Qt::Key_Enter:
|
||||
// Enter advances; Shift+Enter goes back.
|
||||
navigate(!(ke->modifiers() & Qt::ShiftModifier));
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return QFrame::eventFilter(obj, event);
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
#pragma once
|
||||
|
||||
#include <QFrame>
|
||||
|
||||
class GhosttySurface;
|
||||
class QLabel;
|
||||
class QLineEdit;
|
||||
class QTimer;
|
||||
|
||||
// An in-terminal search bar overlaying a GhosttySurface.
|
||||
//
|
||||
// The query is fed to libghostty through keybind actions (`search:`,
|
||||
// `navigate_search:`, `end_search`) — the same path the macOS frontend
|
||||
// uses — and the match counter mirrors the SEARCH_TOTAL and
|
||||
// SEARCH_SELECTED actions libghostty reports back. Match highlighting
|
||||
// in the terminal is drawn by the shared core renderer.
|
||||
//
|
||||
// It is a themed QFrame: the panel, field and buttons all use the
|
||||
// active Qt style/palette rather than hardcoded colours.
|
||||
class SearchBar : public QFrame {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit SearchBar(GhosttySurface *surface);
|
||||
|
||||
// Show the bar, focused, optionally pre-filled with a needle.
|
||||
void open(const QString &prefill);
|
||||
|
||||
// Match counts reported by libghostty; -1 means none/unknown.
|
||||
void setTotal(int total);
|
||||
void setSelected(int selected);
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject *obj, QEvent *event) override;
|
||||
|
||||
private:
|
||||
void sendQuery(); // push the field text as `search:<text>`
|
||||
void navigate(bool next); // `navigate_search:next` / `:previous`
|
||||
void runAction(const char *action);
|
||||
void updateCount();
|
||||
void positionCount(); // place the counter inside the field
|
||||
|
||||
GhosttySurface *m_surface; // not owned
|
||||
QLineEdit *m_field = nullptr;
|
||||
QLabel *m_count = nullptr; // match counter, shown inside m_field
|
||||
QTimer *m_debounce = nullptr; // coalesces keystrokes into one query
|
||||
int m_total = -1;
|
||||
int m_selected = -1;
|
||||
};
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
#include "TabWidget.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QCoreApplication>
|
||||
#include <QDataStream>
|
||||
#include <QDrag>
|
||||
#include <QDragEnterEvent>
|
||||
#include <QDropEvent>
|
||||
#include <QMimeData>
|
||||
#include <QMouseEvent>
|
||||
#include <QPixmap>
|
||||
#include <QPointer>
|
||||
#include <QRect>
|
||||
#include <QSet>
|
||||
|
||||
namespace {
|
||||
// MIME role carrying a tagged origin record so a receiving bar's
|
||||
// dropEvent can mark the originator's m_dropHandled. Can't rely on
|
||||
// QDrag::exec()'s return value on Wayland, and a process-wide "drop
|
||||
// handled" flag races simultaneous tear-offs in different windows.
|
||||
constexpr char kTearOffOriginRole[] = "application/x-ghastty-tab-origin";
|
||||
|
||||
// Process-local set of live TabBars so decodeOrigin can validate the
|
||||
// pointer before dereferencing it (cross-process Wayland delivery, or
|
||||
// the originating bar dying during drag->exec, would otherwise UAF).
|
||||
QSet<TabBar *> &liveTabBars() {
|
||||
static QSet<TabBar *> s;
|
||||
return s;
|
||||
}
|
||||
|
||||
// Origin payload: the source PID followed by the originating TabBar*.
|
||||
// The PID rejects cross-process delivery; the live-set check rejects
|
||||
// same-process pointers whose target was destroyed mid-drag.
|
||||
struct OriginPayload {
|
||||
qint64 pid;
|
||||
TabBar *bar;
|
||||
};
|
||||
|
||||
QByteArray encodeOrigin(TabBar *bar) {
|
||||
OriginPayload p{QCoreApplication::applicationPid(), bar};
|
||||
QByteArray bytes(reinterpret_cast<const char *>(&p), sizeof(p));
|
||||
return bytes;
|
||||
}
|
||||
|
||||
TabBar *decodeOrigin(const QByteArray &bytes) {
|
||||
if (bytes.size() != sizeof(OriginPayload)) return nullptr;
|
||||
OriginPayload p;
|
||||
std::memcpy(&p, bytes.constData(), sizeof(p));
|
||||
if (p.pid != QCoreApplication::applicationPid()) return nullptr;
|
||||
if (!liveTabBars().contains(p.bar)) return nullptr;
|
||||
return p.bar;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TabBar::TabBar(QWidget *parent) : QTabBar(parent) {
|
||||
liveTabBars().insert(this);
|
||||
// Truncate long titles with an ellipsis instead of letting a single
|
||||
// tab consume the whole bar. Matches the upstream GTK frontend
|
||||
// (Adw.TabBar, which clamps + ellipsizes) and macOS (Cocoa tabs use
|
||||
// lineBreakMode = byTruncatingTail).
|
||||
setElideMode(Qt::ElideRight);
|
||||
// Tabs size to content (subject to the per-tab cap from tabSizeHint
|
||||
// below), leaving room on the bar rather than expanding to fill.
|
||||
setExpanding(false);
|
||||
// When tabs still don't fit (many tabs, all near the cap), Qt
|
||||
// shows left/right scroll arrows instead of shrinking each tab to
|
||||
// an unreadable sliver.
|
||||
setUsesScrollButtons(true);
|
||||
}
|
||||
|
||||
TabBar::~TabBar() { liveTabBars().remove(this); }
|
||||
|
||||
QSize TabBar::tabSizeHint(int index) const {
|
||||
// Cap at ~28em — wide enough for a typical "shell — repo (branch)"
|
||||
// title, narrow enough that 5+ tabs fit on a 1280-px window without
|
||||
// triggering scroll arrows. Below the cap, fall back to Qt's
|
||||
// content-fit hint so short titles still get short tabs.
|
||||
const QSize base = QTabBar::tabSizeHint(index);
|
||||
const int cap = fontMetrics().averageCharWidth() * 28 +
|
||||
// include the close-button width when the tab is
|
||||
// closable, so the title clamp matches actual
|
||||
// available text space.
|
||||
(tabsClosable() ? 28 : 0);
|
||||
if (base.width() <= cap) return base;
|
||||
return QSize(cap, base.height());
|
||||
}
|
||||
|
||||
void TabBar::mousePressEvent(QMouseEvent *e) {
|
||||
if (e->button() == Qt::LeftButton) {
|
||||
m_pressIndex = tabAt(e->position().toPoint());
|
||||
m_pressPos = e->position().toPoint();
|
||||
}
|
||||
QTabBar::mousePressEvent(e);
|
||||
}
|
||||
|
||||
void TabBar::mouseMoveEvent(QMouseEvent *e) {
|
||||
// While the pointer is on the bar, QTabBar reorders normally. Once it
|
||||
// leaves the bar in any direction, hand off to a tear-off drag.
|
||||
const bool leftBar =
|
||||
m_pressIndex >= 0 && count() > 1 && !m_tearing &&
|
||||
!rect().adjusted(-20, -20, 20, 20).contains(e->position().toPoint());
|
||||
if (leftBar) {
|
||||
startTearOff(e);
|
||||
return;
|
||||
}
|
||||
QTabBar::mouseMoveEvent(e);
|
||||
}
|
||||
|
||||
void TabBar::mouseReleaseEvent(QMouseEvent *e) {
|
||||
m_pressIndex = -1;
|
||||
QTabBar::mouseReleaseEvent(e);
|
||||
}
|
||||
|
||||
void TabBar::startTearOff(QMouseEvent *e) {
|
||||
m_tearing = true;
|
||||
|
||||
// End QTabBar's internal move-drag so the tab settles back into the
|
||||
// bar rather than staying "lifted".
|
||||
QMouseEvent release(QEvent::MouseButtonRelease, e->position(),
|
||||
e->globalPosition(), Qt::LeftButton, Qt::NoButton,
|
||||
e->modifiers());
|
||||
QTabBar::mouseReleaseEvent(&release);
|
||||
|
||||
// The dragged tab is the current one; its index is stable now that any
|
||||
// in-bar reorder has settled.
|
||||
const int index = currentIndex();
|
||||
if (index < 0) {
|
||||
m_tearing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// A snapshot of the tab follows the cursor for the duration of the drag.
|
||||
const QRect tabBox = tabRect(index);
|
||||
QDrag *drag = new QDrag(this);
|
||||
auto *mime = new QMimeData;
|
||||
mime->setData(QString::fromLatin1(kGhosttyTabMime), QByteArray());
|
||||
// Tag the drag with a pointer to this bar so the receiving bar's
|
||||
// dropEvent can mark *our* m_dropHandled — a process-global flag
|
||||
// would race with simultaneous tear-offs in other windows.
|
||||
mime->setData(QString::fromLatin1(kTearOffOriginRole), encodeOrigin(this));
|
||||
drag->setMimeData(mime);
|
||||
drag->setPixmap(grab(tabBox));
|
||||
drag->setHotSpot(m_pressPos - tabBox.topLeft());
|
||||
// A tear-off has no real drop target. A 1x1 transparent cursor
|
||||
// suppresses the "forbidden" cursor Qt would otherwise show over
|
||||
// non-accepting areas — releasing there is a valid outcome.
|
||||
QPixmap blank(1, 1);
|
||||
blank.fill(Qt::transparent);
|
||||
drag->setDragCursor(blank, Qt::IgnoreAction);
|
||||
drag->setDragCursor(blank, Qt::MoveAction);
|
||||
|
||||
// Released on a tab bar cancels the tear-off; released anywhere else
|
||||
// (the terminal, another window, the desktop) tears it into a new
|
||||
// window. m_dropHandled — set by a TabBar::dropEvent on the
|
||||
// originating bar — is the signal, since QDrag::exec()'s result is
|
||||
// unreliable across surfaces on Wayland.
|
||||
//
|
||||
// drag->exec spins a nested event loop. Anything queued onto this
|
||||
// bar's window — a libghostty close action, the user closing the
|
||||
// window mid-drag — can `delete this` while exec runs. Watch our own
|
||||
// lifetime via QPointer and bail out before any post-exec member
|
||||
// access if we've been deleted.
|
||||
m_dropHandled = false;
|
||||
QPointer<TabBar> self(this);
|
||||
drag->exec(Qt::MoveAction);
|
||||
if (!self) return;
|
||||
|
||||
m_tearing = false;
|
||||
m_pressIndex = -1;
|
||||
if (!m_dropHandled) emit tabTornOff(index);
|
||||
}
|
||||
|
||||
void TabBar::dragEnterEvent(QDragEnterEvent *e) {
|
||||
if (e->mimeData()->hasFormat(QString::fromLatin1(kGhosttyTabMime)))
|
||||
e->acceptProposedAction();
|
||||
}
|
||||
|
||||
void TabBar::dropEvent(QDropEvent *e) {
|
||||
// Dropping a tear-off back on a tab bar cancels it. Mark the flag on
|
||||
// the *originating* bar (carried in the MIME payload), not this one
|
||||
// — a tear-off can be dropped onto a different window's bar.
|
||||
if (e->mimeData()->hasFormat(QString::fromLatin1(kGhosttyTabMime))) {
|
||||
if (TabBar *origin = decodeOrigin(
|
||||
e->mimeData()->data(QString::fromLatin1(kTearOffOriginRole))))
|
||||
origin->m_dropHandled = true;
|
||||
else
|
||||
m_dropHandled = true; // fallback: mark ourselves
|
||||
e->acceptProposedAction();
|
||||
}
|
||||
}
|
||||
|
||||
TabWidget::TabWidget(QWidget *parent) : QTabWidget(parent) {
|
||||
auto *bar = new TabBar(this);
|
||||
bar->setAcceptDrops(true); // so a tear-off can be dropped back on it
|
||||
setTabBar(bar); // protected on QTabWidget; accessible to this subclass
|
||||
connect(bar, &TabBar::tabTornOff, this, &TabWidget::tabTornOff);
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
#pragma once
|
||||
|
||||
#include <QMetaType>
|
||||
#include <QPoint>
|
||||
#include <QString>
|
||||
#include <QTabBar>
|
||||
#include <QTabWidget>
|
||||
|
||||
class QDragEnterEvent;
|
||||
class QDropEvent;
|
||||
class QMouseEvent;
|
||||
|
||||
// MIME type marking a Ghastty tab tear-off drag. Recognised by tab bars
|
||||
// (to cancel the tear-off) and by terminal surfaces (to accept the drag
|
||||
// so no "forbidden" cursor is shown over a Ghastty window).
|
||||
inline constexpr char kGhosttyTabMime[] = "application/x-ghastty-tab";
|
||||
|
||||
// Per-tab data stored in QTabBar::tabData. `base` is the terminal-set
|
||||
// title (libghostty SET_TITLE); `override` is a manual user-set title
|
||||
// (libghostty SET_TAB_TITLE). updateTabText shows override when set,
|
||||
// otherwise base.
|
||||
struct TabData {
|
||||
QString base;
|
||||
QString override_; // `override` is a reserved C++ identifier
|
||||
};
|
||||
Q_DECLARE_METATYPE(TabData)
|
||||
|
||||
// A QTabBar that tears a tab off into its own window when it is dragged
|
||||
// clear of the bar. QTabBar's built-in movable behaviour still handles
|
||||
// reordering within the bar; once the pointer leaves the bar a QDrag
|
||||
// takes over so a snapshot of the tab follows the cursor.
|
||||
class TabBar : public QTabBar {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit TabBar(QWidget *parent = nullptr);
|
||||
~TabBar() override;
|
||||
|
||||
signals:
|
||||
// The tab was dragged off and released clear of its window.
|
||||
void tabTornOff(int index);
|
||||
|
||||
protected:
|
||||
void mousePressEvent(QMouseEvent *) override;
|
||||
void mouseMoveEvent(QMouseEvent *) override;
|
||||
void mouseReleaseEvent(QMouseEvent *) override;
|
||||
// Accept a tear-off drag dropped back on a tab bar (cancels it).
|
||||
void dragEnterEvent(QDragEnterEvent *) override;
|
||||
void dropEvent(QDropEvent *) override;
|
||||
|
||||
// Cap a tab's width so a single long terminal title can't take the
|
||||
// entire bar. Matches the GTK frontend's Adw.TabBar (which clamps
|
||||
// width and ellipsizes) and the macOS Cocoa tabs (which use
|
||||
// lineBreakMode = byTruncatingTail). Without this, Qt's default is
|
||||
// "size to fit full text," and a long working-directory title
|
||||
// pushes every other tab off-screen.
|
||||
QSize tabSizeHint(int index) const override;
|
||||
|
||||
private:
|
||||
void startTearOff(QMouseEvent *e);
|
||||
|
||||
int m_pressIndex = -1; // tab under the press, or -1
|
||||
QPoint m_pressPos; // press point, for the drag hot spot
|
||||
bool m_tearing = false; // a tear-off QDrag is in progress
|
||||
bool m_dropHandled = false; // a TabBar dropEvent caught our tear-off
|
||||
};
|
||||
|
||||
// A QTabWidget wired to the tear-off-aware TabBar.
|
||||
class TabWidget : public QTabWidget {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit TabWidget(QWidget *parent = nullptr);
|
||||
|
||||
signals:
|
||||
void tabTornOff(int index);
|
||||
};
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
#include "Util.h"
|
||||
|
||||
#include <QChar>
|
||||
#include <QStringLiteral>
|
||||
|
||||
// We index libghostty's GHOSTTY_KEY_DIGIT_0..9 and GHOSTTY_KEY_A..Z
|
||||
// enum ranges by arithmetic offset. If libghostty ever inserts an
|
||||
// entry into either range the math goes wrong silently — pin the
|
||||
// contiguity at compile time.
|
||||
static_assert(GHOSTTY_KEY_DIGIT_9 - GHOSTTY_KEY_DIGIT_0 == 9,
|
||||
"ghostty_input_key_e DIGIT_0..9 must be contiguous");
|
||||
static_assert(GHOSTTY_KEY_Z - GHOSTTY_KEY_A == 25,
|
||||
"ghostty_input_key_e A..Z must be contiguous");
|
||||
|
||||
QString triggerKeyName(const ghostty_input_trigger_s &t) {
|
||||
switch (t.tag) {
|
||||
case GHOSTTY_TRIGGER_UNICODE:
|
||||
if (t.key.unicode) return QString(QChar(t.key.unicode)).toUpper();
|
||||
return {};
|
||||
case GHOSTTY_TRIGGER_PHYSICAL: {
|
||||
const ghostty_input_key_e k = t.key.physical;
|
||||
if (k >= GHOSTTY_KEY_DIGIT_0 && k <= GHOSTTY_KEY_DIGIT_9)
|
||||
return QChar('0' + (k - GHOSTTY_KEY_DIGIT_0));
|
||||
if (k >= GHOSTTY_KEY_A && k <= GHOSTTY_KEY_Z)
|
||||
return QChar('A' + (k - GHOSTTY_KEY_A));
|
||||
if (k == GHOSTTY_KEY_ENTER) return QStringLiteral("Return");
|
||||
if (k == GHOSTTY_KEY_SPACE) return QStringLiteral("Space");
|
||||
if (k == GHOSTTY_KEY_TAB) return QStringLiteral("Tab");
|
||||
return {};
|
||||
}
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
QString formatTrigger(const ghostty_input_trigger_s &t) {
|
||||
QString s;
|
||||
if (t.mods & GHOSTTY_MODS_CTRL) s += QStringLiteral("Ctrl+");
|
||||
if (t.mods & GHOSTTY_MODS_ALT) s += QStringLiteral("Alt+");
|
||||
if (t.mods & GHOSTTY_MODS_SHIFT) s += QStringLiteral("Shift+");
|
||||
if (t.mods & GHOSTTY_MODS_SUPER) s += QStringLiteral("Super+");
|
||||
|
||||
const QString name = triggerKeyName(t);
|
||||
if (!name.isEmpty()) {
|
||||
s += name;
|
||||
} else if (t.tag == GHOSTTY_TRIGGER_PHYSICAL) {
|
||||
s += QStringLiteral("•"); // an unmapped physical key
|
||||
} else {
|
||||
s += QStringLiteral("…"); // CATCH_ALL etc.
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <Qt>
|
||||
|
||||
#include "ghostty.h"
|
||||
|
||||
// Shared helpers used across the Qt frontend. Kept header-only where
|
||||
// possible so trivial wrappers stay inlined.
|
||||
|
||||
// bell-features is a packed struct returned by ghostty_config_get as a
|
||||
// bitfield. Layout is fixed by the libghostty C ABI; do not reorder.
|
||||
enum BellFeature : unsigned int {
|
||||
BellSystem = 1u << 0,
|
||||
BellAudio = 1u << 1,
|
||||
BellAttention = 1u << 2,
|
||||
BellTitle = 1u << 3,
|
||||
BellBorder = 1u << 4,
|
||||
};
|
||||
|
||||
// Translate Qt keyboard modifiers into libghostty's modifier bitfield.
|
||||
inline ghostty_input_mods_e translateMods(Qt::KeyboardModifiers m) {
|
||||
int r = GHOSTTY_MODS_NONE;
|
||||
if (m & Qt::ShiftModifier) r |= GHOSTTY_MODS_SHIFT;
|
||||
if (m & Qt::ControlModifier) r |= GHOSTTY_MODS_CTRL;
|
||||
if (m & Qt::AltModifier) r |= GHOSTTY_MODS_ALT;
|
||||
if (m & Qt::MetaModifier) r |= GHOSTTY_MODS_SUPER;
|
||||
return static_cast<ghostty_input_mods_e>(r);
|
||||
}
|
||||
|
||||
// Render the printable letter/digit/named key portion of a libghostty
|
||||
// trigger. Returns an empty string if the key is not displayable
|
||||
// (CATCH_ALL, an unmapped physical key, etc.).
|
||||
QString triggerKeyName(const ghostty_input_trigger_s &t);
|
||||
|
||||
// Format a libghostty trigger as a human-readable chord (e.g. "Ctrl+B").
|
||||
// Used for context-menu shortcut hints and the key-sequence overlay.
|
||||
// Unmapped physical keys render as "•"; trigger.tag CATCH_ALL renders
|
||||
// as "…".
|
||||
QString formatTrigger(const ghostty_input_trigger_s &t);
|
||||
|
||||
// Wrapper around ghostty_config_get that infers the value's length
|
||||
// from a string literal, so call sites stop repeating qstrlen().
|
||||
//
|
||||
// The template only binds to char-array references (string literals);
|
||||
// passing a `const char*` is intentionally a compile error — runtime-
|
||||
// length keys must call ghostty_config_get directly with qstrlen.
|
||||
template <typename T, size_t N>
|
||||
inline bool configGet(ghostty_config_t cfg, T *out, const char (&key)[N]) {
|
||||
return cfg && ghostty_config_get(cfg, out, key, N - 1);
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
#include "WindowBlur.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
#include <QGuiApplication>
|
||||
#include <QHash>
|
||||
#include <QString>
|
||||
#include <QWidget>
|
||||
#include <QWindow>
|
||||
#include <qpa/qplatformnativeinterface.h>
|
||||
|
||||
#include <wayland-client.h>
|
||||
|
||||
#include <xcb/xcb.h>
|
||||
|
||||
#include "blur-client-protocol.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// --- Wayland (org_kde_kwin_blur) -------------------------------------
|
||||
|
||||
// Found while enumerating the registry; cached for the process.
|
||||
struct BlurGlobals {
|
||||
org_kde_kwin_blur_manager *manager = nullptr;
|
||||
bool searched = false;
|
||||
};
|
||||
|
||||
void registryGlobal(void *data, wl_registry *registry, uint32_t name,
|
||||
const char *interface, uint32_t) {
|
||||
auto *g = static_cast<BlurGlobals *>(data);
|
||||
if (std::strcmp(interface, org_kde_kwin_blur_manager_interface.name) == 0)
|
||||
g->manager = static_cast<org_kde_kwin_blur_manager *>(wl_registry_bind(
|
||||
registry, name, &org_kde_kwin_blur_manager_interface, 1));
|
||||
}
|
||||
void registryGlobalRemove(void *, wl_registry *, uint32_t) {}
|
||||
|
||||
const wl_registry_listener kRegistryListener = {registryGlobal,
|
||||
registryGlobalRemove};
|
||||
|
||||
// Bind the KWin blur manager, enumerating the registry on a private
|
||||
// event queue so this never dispatches Qt's own Wayland events.
|
||||
org_kde_kwin_blur_manager *blurManager(wl_display *display) {
|
||||
static BlurGlobals globals;
|
||||
if (globals.searched) return globals.manager;
|
||||
globals.searched = true;
|
||||
|
||||
wl_event_queue *queue = wl_display_create_queue(display);
|
||||
wl_registry *registry = wl_display_get_registry(display);
|
||||
wl_proxy_set_queue(reinterpret_cast<wl_proxy *>(registry), queue);
|
||||
wl_registry_add_listener(registry, &kRegistryListener, &globals);
|
||||
wl_display_roundtrip_queue(display, queue);
|
||||
wl_registry_destroy(registry);
|
||||
// The manager outlives this private queue, so move it (and the blur
|
||||
// objects later created from it) back to the display's default queue
|
||||
// before the private queue is destroyed.
|
||||
if (globals.manager)
|
||||
wl_proxy_set_queue(reinterpret_cast<wl_proxy *>(globals.manager), nullptr);
|
||||
wl_event_queue_destroy(queue);
|
||||
return globals.manager;
|
||||
}
|
||||
|
||||
// The live blur object per window — kept so it can be released when
|
||||
// blur is turned off, re-applied on a config change, or the window
|
||||
// itself is destroyed.
|
||||
static QHash<QWindow *, org_kde_kwin_blur *> &waylandBlurs() {
|
||||
static QHash<QWindow *, org_kde_kwin_blur *> blurs;
|
||||
return blurs;
|
||||
}
|
||||
|
||||
void applyWayland(QWindow *window, bool enabled) {
|
||||
QPlatformNativeInterface *native = QGuiApplication::platformNativeInterface();
|
||||
if (!native) return;
|
||||
auto *display = static_cast<wl_display *>(
|
||||
native->nativeResourceForIntegration("wl_display"));
|
||||
auto *surface = static_cast<wl_surface *>(
|
||||
native->nativeResourceForWindow("surface", window));
|
||||
if (!display || !surface) return;
|
||||
|
||||
org_kde_kwin_blur_manager *manager = blurManager(display);
|
||||
if (!manager) return; // compositor advertises no blur support
|
||||
|
||||
auto &blurs = waylandBlurs();
|
||||
// `take` returns and removes the prior blur if any. Knowing whether
|
||||
// we're seeing this window for the first time decides whether we
|
||||
// need a fresh `destroyed` connection — re-applying blur on an
|
||||
// already-tracked window must NOT add a second connection.
|
||||
const bool firstTime = !blurs.contains(window);
|
||||
if (org_kde_kwin_blur *old = blurs.take(window))
|
||||
org_kde_kwin_blur_release(old);
|
||||
|
||||
if (enabled) {
|
||||
org_kde_kwin_blur *blur = org_kde_kwin_blur_manager_create(manager,
|
||||
surface);
|
||||
org_kde_kwin_blur_set_region(blur, nullptr); // null = whole surface
|
||||
org_kde_kwin_blur_commit(blur);
|
||||
blurs.insert(window, blur);
|
||||
|
||||
// Release the blur object when the window goes away. Connect once
|
||||
// per window — repeated applyBlur(window, true) calls would
|
||||
// otherwise stack N stale lambdas on the destroyed signal.
|
||||
if (firstTime) {
|
||||
QObject::connect(window, &QWindow::destroyed, qApp, [window]() {
|
||||
auto &b = waylandBlurs();
|
||||
if (org_kde_kwin_blur *old = b.take(window))
|
||||
org_kde_kwin_blur_release(old);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
org_kde_kwin_blur_manager_unset(manager, surface);
|
||||
}
|
||||
wl_display_flush(display);
|
||||
}
|
||||
|
||||
// --- X11 (_KDE_NET_WM_BLUR_BEHIND_REGION) ----------------------------
|
||||
|
||||
void applyX11(QWindow *window, bool enabled) {
|
||||
QPlatformNativeInterface *native = QGuiApplication::platformNativeInterface();
|
||||
if (!native) return;
|
||||
auto *conn = static_cast<xcb_connection_t *>(
|
||||
native->nativeResourceForIntegration("connection"));
|
||||
if (!conn) return;
|
||||
const auto xid = static_cast<xcb_window_t>(window->winId());
|
||||
|
||||
static const char kName[] = "_KDE_NET_WM_BLUR_BEHIND_REGION";
|
||||
xcb_intern_atom_reply_t *reply = xcb_intern_atom_reply(
|
||||
conn, xcb_intern_atom(conn, 0, std::strlen(kName), kName), nullptr);
|
||||
if (!reply) return;
|
||||
const xcb_atom_t atom = reply->atom;
|
||||
std::free(reply);
|
||||
|
||||
if (enabled)
|
||||
// An empty region property blurs the whole window.
|
||||
xcb_change_property(conn, XCB_PROP_MODE_REPLACE, xid, atom,
|
||||
XCB_ATOM_CARDINAL, 32, 0, nullptr);
|
||||
else
|
||||
xcb_delete_property(conn, xid, atom);
|
||||
xcb_flush(conn);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void applyWindowBlur(QWidget *window, bool enabled) {
|
||||
if (!window) return;
|
||||
QWindow *handle = window->windowHandle();
|
||||
if (!handle) return; // not a native window yet
|
||||
|
||||
const QString platform = QGuiApplication::platformName();
|
||||
if (platform.startsWith(QLatin1String("wayland")))
|
||||
applyWayland(handle, enabled);
|
||||
else if (platform == QLatin1String("xcb"))
|
||||
applyX11(handle, enabled);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
#include <cstdio>
|
||||
|
||||
#include <QApplication>
|
||||
#include <QIcon>
|
||||
#include <QSurfaceFormat>
|
||||
|
||||
#include "GlobalShortcuts.h"
|
||||
#include "MainWindow.h"
|
||||
#include "ghostty.h"
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
// Use the display's true fractional scale rather than rounding it up
|
||||
// (Wayland otherwise reports e.g. 2.0 for a 1.2x display, which scales
|
||||
// the terminal up).
|
||||
QGuiApplication::setHighDpiScaleFactorRoundingPolicy(
|
||||
Qt::HighDpiScaleFactorRoundingPolicy::PassThrough);
|
||||
|
||||
// Multiple GL surfaces compose reliably with a shared GL context.
|
||||
QApplication::setAttribute(Qt::AA_ShareOpenGLContexts);
|
||||
|
||||
// Ghostty's OpenGL renderer requires at least OpenGL 4.3 core.
|
||||
QSurfaceFormat fmt;
|
||||
fmt.setRenderableType(QSurfaceFormat::OpenGL);
|
||||
fmt.setProfile(QSurfaceFormat::CoreProfile);
|
||||
fmt.setVersion(4, 3);
|
||||
fmt.setAlphaBufferSize(8); // allow a translucent terminal background
|
||||
QSurfaceFormat::setDefaultFormat(fmt);
|
||||
|
||||
QApplication app(argc, argv);
|
||||
|
||||
// Match the installed ghastty.desktop: this becomes the Wayland app-id
|
||||
// (and X11 WM_CLASS), so the compositor associates the window with the
|
||||
// desktop entry — taskbar icon, launcher identity.
|
||||
QGuiApplication::setDesktopFileName(QStringLiteral("ghastty"));
|
||||
|
||||
// The window icon, embedded so it works even running from the build
|
||||
// tree (when ghastty.desktop / the icon theme are not yet installed).
|
||||
QGuiApplication::setWindowIcon(QIcon(QStringLiteral(":/ghastty.svg")));
|
||||
|
||||
// We keep the user's system widget style rather than forcing Fusion.
|
||||
// Some styles dim and blur translucent windows, which masks the
|
||||
// terminal's own background-opacity: Kvantum themes do this when
|
||||
// `blurring`/`reduce_window_opacity` are set. The fix belongs in the
|
||||
// style's config, not here — for Kvantum, add "ghostty" to the
|
||||
// theme's `opaque` app list (the same opt-out video players use).
|
||||
|
||||
// ghostty_init must run *after* QApplication: QApplication strips its
|
||||
// own options (e.g. -style) out of argv in place, and libghostty later
|
||||
// re-scans that array for CLI config — scanning the pre-strip array
|
||||
// would walk past its end into freed/null entries.
|
||||
if (ghostty_init(static_cast<uintptr_t>(argc), argv) != GHOSTTY_SUCCESS) {
|
||||
std::fprintf(stderr, "[ghastty] ghostty_init failed\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// The first window; further windows are opened on demand by the
|
||||
// new_window action. Each window owns itself (WA_DeleteOnClose).
|
||||
if (!MainWindow::newWindow(nullptr)) {
|
||||
std::fprintf(stderr, "[ghastty] window initialization failed\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Register global shortcuts via the XDG portal so the quick terminal
|
||||
// can be toggled while Ghostty is unfocused. Keys are assigned by the
|
||||
// desktop (KDE System Settings -> Shortcuts).
|
||||
GlobalShortcuts globalShortcuts;
|
||||
QObject::connect(&globalShortcuts, &GlobalShortcuts::activated,
|
||||
[](const QString &id) {
|
||||
if (id == QLatin1String("toggle-quick-terminal"))
|
||||
MainWindow::toggleQuickTerminal();
|
||||
else if (id == QLatin1String("toggle-visibility"))
|
||||
MainWindow::toggleVisibility();
|
||||
});
|
||||
|
||||
return app.exec();
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue