Merge pull request #2 from fuddlesworth/qt-parity-fixes

qt: full parity audit fixes vs macOS/GTK
pull/12846/head
Nathan 2026-05-21 12:04:52 -05:00 committed by GitHub
commit 3bd3188221
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 2533 additions and 279 deletions

View File

@ -8,18 +8,24 @@
# 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
# frontend wants a Wayland 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
# - base : Fedora + 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
#
# Why Fedora rather than Debian? We need recent Qt 6 (>= 6.6 for the
# non-deprecated LayerShellQt screen API) and recent LayerShellQt.
# Fedora 42 ships Qt 6.9 and a current LayerShellQt; Debian trixie was
# stuck on Qt 6.8.2 + LayerShellQt 6.3.4 which deprecated
# setScreenConfiguration.
ARG DEBIAN_VERSION=trixie
ARG FEDORA_VERSION=42
# Pinned to the project's minimum_zig_version (build.zig.zon).
ARG ZIG_VERSION=0.15.2
@ -27,55 +33,54 @@ ARG ZIG_VERSION=0.15.2
# ---------------------------------------------------------------------
# base — system packages shared across the build stages.
# ---------------------------------------------------------------------
FROM debian:${DEBIAN_VERSION}-slim AS base
FROM fedora:${FEDORA_VERSION} AS base
ENV DEBIAN_FRONTEND=noninteractive
# Single apt layer so the package cache is dropped before the next
# Single dnf 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)
# - build tooling (cmake, ninja, pkg-config, gcc, gcc-c++)
# - libghostty build deps via Zig (most are vendored; libxml2-devel
# 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 \
RUN dnf install -y --setopt=install_weak_deps=False \
ca-certificates \
curl \
xz-utils \
xz \
tar \
git \
pkg-config \
pkgconfig \
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/*
gcc \
gcc-c++ \
qt6-qtbase-devel \
qt6-qtbase-private-devel \
qt6-qtmultimedia-devel \
qt6-qtsvg-devel \
layer-shell-qt-devel \
libxkbcommon-devel \
wayland-devel \
wayland-protocols-devel \
libxcb-devel \
libxml2-devel \
&& dnf clean all
# ---------------------------------------------------------------------
# zig — fetch and unpack the pinned Zig toolchain.
#
# Kept separate from `base` so changing apt deps does not invalidate
# Kept separate from `base` so changing dnf deps does not invalidate
# the (large) Zig download layer.
# ---------------------------------------------------------------------
FROM base AS zig
ARG ZIG_VERSION
RUN set -eux; \
arch="$(dpkg --print-architecture)"; \
arch="$(uname -m)"; \
case "$arch" in \
amd64) zig_arch=x86_64 ;; \
arm64) zig_arch=aarch64 ;; \
x86_64) zig_arch=x86_64 ;; \
aarch64) zig_arch=aarch64 ;; \
*) echo "unsupported arch: $arch" >&2; exit 1 ;; \
esac; \
tarball="zig-${zig_arch}-linux-${ZIG_VERSION}.tar.xz"; \
@ -128,7 +133,7 @@ RUN set -eux; \
# ---------------------------------------------------------------------
# out — only the final artifacts. Run this image to extract them.
# ---------------------------------------------------------------------
FROM debian:${DEBIAN_VERSION}-slim AS out
FROM fedora:${FEDORA_VERSION} AS out
COPY --from=qt /out /out
# Default command lists the artifacts so `docker run --rm ghastty`

View File

@ -31,12 +31,18 @@ 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.
# WindowBlur + XkbTracker use qpa/qplatformnativeinterface.h to reach
# the wl_display / wl_surface / xcb_connection_t / wl_seat for native
# compositor calls (blur, layer-shell screen pinning, raw wl_keyboard
# listeners). The Qt-internal GuiPrivate module is tied to a specific
# Qt build, but every consumer of these headers ends up here there
# is no public alternative. Suppress the cosmetic
# `_qt_internal_show_private_module_warning` so the build is silent
# unless something genuinely fails. Detect the component when
# present; some distros ship the headers without the matching
# CMake config (older Debian) and we fall back to hand-wiring the
# include dir below.
set(QT_NO_PRIVATE_MODULE_WARNING ON)
find_package(Qt6 QUIET OPTIONAL_COMPONENTS GuiPrivate)
# LayerShellQt: the quick terminal is a wlr-layer-shell dropdown window.
@ -102,6 +108,7 @@ add_executable(ghastty
src/TabWidget.cpp
src/Util.cpp
src/WindowBlur.cpp
src/XkbTracker.cpp
"${BLUR_CODE}"
"${BLUR_HEADER}"
)

223
qt/PARITY.md Normal file
View File

@ -0,0 +1,223 @@
# Qt Frontend — Platform Parity Tracker
This document tracks where the Qt frontend lacks consistency with the
upstream Ghostty macOS and GTK frontends. It came out of a four-agent
parallel audit that compared every action handler, input event,
window/tab/split lifecycle path, and config option across the three
implementations.
Findings are deduplicated, grouped by severity, and ordered by
implementation effort within each tier. As items land, tick the
checkbox and link the commit hash.
**Authoritative sources of truth used during the audit:**
- `include/ghostty.h` — every `GHOSTTY_ACTION_*` tag the apprt may receive.
- `src/apprt/action.zig` — corresponding Zig types.
- `src/config/Config.zig` — config field declarations + doc comments.
**Frontend file roots:**
- Qt: `qt/src/`
- macOS: `macos/Sources/Ghostty/`, `macos/Sources/Features/`, `macos/Sources/App/macOS/`
- GTK: `src/apprt/gtk/class/`
---
## 🔴 Bugs (user-visible wrong behavior)
### Lifecycle / quit
- [x] **B1.** `quit-after-last-window-closed-delay` does nothing on natural close (`MainWindow.cpp:255`). Delay timer only fires when libghostty issues `QUIT_TIMER`, but closing the last window via the title-bar X keeps the process alive forever (since Qt's `quitOnLastWindowClosed` was set false to allow the delay path). macOS handles via `applicationShouldTerminateAfterLastWindowClosed`; GTK wires last-window-close → `startQuitTimer` (`application.zig:820-862`). — fixed in `7c3868b5b`
- [x] **B2.** `CLOSE_ALL_WINDOWS` always force-terminates. — fixed in `4c903802a` (split QUIT vs CLOSE_ALL_WINDOWS via thenQuit param).
- [x] **B3.** `m_skipCloseConfirm` never cleared. — fixed in `4c903802a` (closeEvent consumes the flag for THIS attempt only).
- [x] **B4.** `confirm-close-surface` config option ignored (`MainWindow.cpp:587-599`). Qt always uses libghostty's `needs_confirm_quit`. User setting `false` / `always` / `always-cwd` has no effect. — fixed in `33b5dee46`
- [x] **B5.** `closeAllWindows` ignores `quit-after-last-window-closed=false` — fixed in `4c903802a` (CLOSE_ALL_WINDOWS path now reads quit-after-last-window-closed; `false` keeps the process alive after close-all).
### Action coverage
- [x] **B6.** `CLOSE_TAB` ignores `close_tab_mode` (`MainWindow.cpp:1241-1247`). Always treats as `mode=THIS`. "Close other tabs" / "Close tabs to the right" keybinds silently close only the current tab. — fixed in `33b5dee46`
- [x] **B7.** `INITIAL_SIZE` halves window on HiDPI (`MainWindow.cpp:1429-1433`). Width/height from libghostty are already logical pixels; Qt divides by `devicePixelRatioF()` again. macOS uses unmodified. — fixed in `33b5dee46`
- [x] **B8.** `MOUSE_VISIBILITY` clobbers cursor shape on un-hide (`MainWindow.cpp:1512-1520`). Sets `Qt::ArrowCursor` on un-hide, destroying the previous shape from `MOUSE_SHAPE`. macOS preserves shape. — fixed in `a48ff0fb8`
- [x] **B9.** Performable-action-returns-true: `MOVE_TAB`, `GOTO_TAB`, `GOTO_SPLIT`, `RESIZE_SPLIT`, `EQUALIZE_SPLITS`, `TOGGLE_SPLIT_ZOOM` all unconditionally return `true`, swallowing chords on unsplit/single-tab surfaces. macOS returns false; GTK gates on `tree.getIsSplit()`. — fixed in `20278082b`
- [x] **B10.** `MOVE_TAB` with target=APP moves a tab in arbitrary first window (`MainWindow.cpp:1504`). macOS returns false for app target. — fixed in `20278082b`
- [x] **B11.** `RELOAD_CONFIG` only reloads ONE window (`MainWindow.cpp:1410-1414`). Other windows stay on stale config. macOS reloads globally. — fixed in `33b5dee46`
- [x] **B12.** `CONFIG_CHANGE` only refreshes chrome (`MainWindow.cpp:1416-1421`). Doesn't push the new config to running surfaces. — fixed in `6d700c36b` (refreshChrome now propagates window-decoration / fullscreen / maximize / quit-delay to running windows)
- [x] **B13.** `OPEN_URL` ignores `kind` (`MainWindow.cpp:1471-1480`). `.text` payloads (e.g. config files) open with whatever the desktop says is default for `.txt` (usually a browser). macOS routes `.text` to a text editor. — fixed in `bfd39a4dd` (openUrlByKind via xdg-mime + GUI editor fallback)
- [x] **B14.** `OPEN_CONFIG` opens via `QDesktopServices::openUrl` without `text` kind hint — same problem. — fixed in `bfd39a4dd`
- [x] **B15.** `SHOW_CHILD_EXITED` fires unconditionally (`MainWindow.cpp:1379-1387`, `GhosttySurface.cpp:466-498`). macOS gates on `runtime_ms > 0` and `abnormalCommandExitRuntime` config; Qt shows the banner for fast `exit 0` cases. — fixed in `8e8725274`
- [x] **B16.** `COPY_TITLE_TO_CLIPBOARD` copies the WINDOW title (`MainWindow.cpp:1280-1284`, `:552`), not the surface title. On a multi-tab window, the wrong title gets copied. macOS copies per-surface. — fixed in `33b5dee46`
- [x] **B17.** `PROMPT_TITLE` with target=APP is no-op (`MainWindow.cpp:1271`). macOS promotes to `NSApp.mainWindow`. — fixed in `20278082b`
- [x] **B18.** Many actions in `default: return false;` (`MainWindow.cpp:1603-1604`): — most fixed in `20278082b` and `f3db5b6cb`
- [x] `PWD` — acknowledged in `20278082b` (libghostty inherits cwd via inherited_config; no apprt UI to update).
- [x] `GOTO_WINDOW` — cycle implemented in `20278082b`.
- [x] `PRESENT_TERMINAL` — show/raise/activate/focus implemented in `20278082b`.
- [x] `KEY_TABLE` — name surfaced via keybind chord overlay in `20278082b`.
- [x] `READONLY` — acknowledged in `20278082b` (libghostty drops keystrokes; no apprt UI).
- [x] `COLOR_CHANGE` — markDirty in `20278082b` so OSC 4/10/11/12 changes paint promptly.
- [x] `RENDER_INSPECTOR` — kicks inspector update in `20278082b`.
- [x] `CELL_SIZE` — stored on window for future grid-snap; bookkeeping only in `20278082b`.
- [x] `SIZE_LIMIT` — setMinimumSize/setMaximumSize honored in `20278082b`.
- [x] `TOGGLE_BACKGROUND_OPACITY` — toggled via WA_TranslucentBackground in `20278082b`.
- [x] `FLOAT_WINDOW` — Qt::WindowStaysOnTopHint toggle in `20278082b`.
- [x] `SECURE_INPUT` — acknowledged in `20278082b` (Wayland has no NSEnableSecureEventInput equivalent; documented platform gap).
- [x] `UNDO` / `REDO` — bounded close-tab/window stash implemented in `f3db5b6cb`.
- [x] `CHECK_FOR_UPDATES` — acknowledged in `20278082b` (no in-app updater on Linux; distros handle updates).
- [x] `TOGGLE_TAB_OVERVIEW` — acknowledged in `20278082b` (GTK adw.TabOverview-only; no Qt analogue).
- [x] `TOGGLE_WINDOW_DECORATIONS` — Qt::FramelessWindowHint toggle in `20278082b`.
### Input / keyboard / mouse
- [x] **B19.** Mouse buttons 4-11 not delivered (`GhosttySurface.cpp:710-715`). Only Left/Right/Middle mapped; back/forward buttons silently dropped. macOS + GTK both handle 4-11. — fixed in `a48ff0fb8`
- [x] **B20.** Modifier release doesn't synthesize event (`sendKey`). Bare Shift/Ctrl/Alt presses don't produce kitty progressive-enhancement events. macOS uses `flagsChanged`; GTK derives from physical_key. — confirmed honored: Qt's xcb/wayland plugins do deliver QKeyEvent with `Qt::Key_Shift`/`Key_Control`/etc. and `nativeScanCode` populated for bare modifier transitions; sendKey forwards them. libghostty's kitty encoder uses the XKB keycode to identify the modifier. No Ghastty-side change needed.
- [x] **B21.** `consumed_mods` only computed for printable events (`GhosttySurface.cpp:699-701`). Keypad/function/Backspace/arrows lose consumed-mods info. macOS + GTK compute unconditionally. — fixed in `13d4353b1`
- [x] **B22.** Caps Lock + Num Lock state never set in mods (`translateMods`). Kitty CSI-u relies on these bits. — fixed in `913f192d8`
- [x] **B23.** Sided modifiers (left vs right) not reported. `left_shift` vs `right_shift` keybinds can't fire. macOS + GTK both populate `mods.sides.*`. — fixed in `8e8725274`
- [x] **B24.** No mouse-enter/leave callback to libghostty (`GhosttySurface.cpp:927-930`). Hover state, OSC-8 link arming, mouse-report sequences stay armed after pointer leaves. macOS + GTK both notify libghostty. — fixed in `8e8725274`
- [x] **B25.** `MOUSE_SHAPE` action not honored at all. Cursor stays OS default regardless of what the running program (e.g. `vim`) requests. macOS + GTK both implement. — fixed in `a48ff0fb8`
- [x] **B26.** `MOUSE_VISIBILITY` (hide-on-typing) not honored. macOS + GTK both implement. — fixed in `a48ff0fb8`
- [x] **B27.** Right-click swallowed when program isn't mouse-capturing (`GhosttySurface.cpp:742-745`, `:782-787`). Qt opens its context menu without ever sending the right-press to libghostty. macOS + GTK send press first, only show menu if core didn't consume — so word-select-then-menu can fire. — fixed in `8e8725274`
- [x] **B28.** Click-to-focus also reports the click to libghostty. macOS + GTK suppress the matching mouse-up. Qt sends both, so a focus-grabbing click is visible to running programs. — fixed in `8e8725274`
- [x] **B29.** `XkbState` uses default layout, not the live one (`GhosttySurface.cpp:629-641`). User with us+ru layouts gets us-only `unshifted_codepoint` regardless of active group. GTK uses `event.getLayout()`. — fixed in `913f192d8`
- [x] **B30.** Wheel: `pixelDelta` ignored, momentum/precision unset (`GhosttySurface.cpp:919-925`). Trackpad on Wayland is notchy; kitty smooth-scroll never engages. macOS uses precise + momentum flags. — fixed in `b86b11903`
- [x] **B31.** Drag-drop URL escaping uses bash-only `'\''` (`GhosttySurface.cpp:889-894`). macOS + GTK use a unified `Shell.escape` / `ShellEscapeWriter` that handles backslashes, newlines, and non-POSIX shells. — fixed in `b86b11903` (POSIX `$'…'` quoting via shellQuote helper)
- [x] **B32.** Plain URL drop not distinguished from file drop. `http://...` becomes a quoted argument instead of pasted text. — fixed in `b86b11903`
### Window / tab / split
- [x] **B33.** No new-window cascade or position restore. Every Ghastty window opens at 800×600 stacked on top of the previous on X11. Doesn't read `window-position-x/y`, `window-width/height`. macOS cascades + restores; GTK reads the size from the surface. — fixed in `cd38f4bd5`
- [x] **B34.** Tab tear-off can't be dropped on another window's bar — fixed in `630c7ceae` (TabBar::dropEvent now emits TabWidget::tabAdoptRequested when the origin bar is in a different window; MainWindow calls adoptTab).
- [x] **B35.** Split focus order sorts by widget center, not split tree — fixed in `630c7ceae` (PREVIOUS/NEXT now walks the QSplitter tree depth-first; directional UP/DOWN/LEFT/RIGHT still uses the center heuristic, which matches user mental model).
- [x] **B36.** QSplitter handle drag bypasses libghostty — confirmed honored: `resizeEvent` on each split-child GhosttySurface fires `syncSurfaceSize` which calls `ghostty_surface_set_size`. Audit was wrong: a splitter-handle drag triggers child resize events, so libghostty does see the new sizes.
- [x] **B37.** Split equalize is per-splitter, not tree-aware (`MainWindow.cpp:886-896`). 3-pane vertical next to 1-pane gets 1:1 instead of 3:1. macOS + GTK use `surfaceTree.equalized()` which weights by leaf count. — fixed in `cd38f4bd5`
- [x] **B38.** No `split-preserve-zoom` config. macOS persists zoom across focus moves with `navigation` setting. — fixed in `8bd64d0fa` (same site as C19).
- [x] **B39.** Tab right-click context menu absent. macOS + GTK have full menu (Close/Close-Others/Close-Right/Rename/Pin). — fixed in `cd38f4bd5`
- [x] **B40.** `window-decoration` only handles `none` (`MainWindow.cpp:268`). `auto`/`client`/`server` all collapse. Wayland has no portable way to force CSD vs SSD; the platform decides. — confirmed in `8e8725274`
- [x] **B41.** `window-theme` partial (`MainWindow.cpp:1040`). `ghostty` mode (luminance-detected from background color) and full OS-scheme follow not implemented; pre-Qt 6.8 has zero theming. — fixed in `4c903802a` (`ghostty` mode was already implemented; pre-6.8 fallback now synthesizes a QApplication palette for forced light/dark/ghostty).
### Quick terminal
- [x] **B42.** No animation (slide-in/out). macOS uses `NSAnimationContext`. — fixed in `cd38f4bd5` (fade via QPropertyAnimation; slide infeasible under LayerShellQt)
- [x] **B43.** `quick-terminal-screen` not honored. macOS resolves which monitor. — fixed in `6d700c36b` (handle->setScreen() before LayerShellQt anchoring; honors `main` / `mouse`; `macos-menu-bar` falls through to primary)
- [x] ~~**B44.** `quick-terminal-position = center` not handled (`MainWindow.cpp:700`).~~ Audit was wrong; already handled at `MainWindow.cpp:766`.
- [x] **B45.** `quick-terminal-space-behavior` not honored. — confirmed in `4c903802a` as a no-op. Wayland's wlr-layer-shell has no per-workspace pin; KWin always renders layer surfaces on the active workspace (= `move`). `remain` semantics are not achievable on Linux/Wayland.
- [x] ~~**B46.** No fallback for non-Wayland — `LayerShellQt::Window::get()` returning null leaves a regular window without telling libghostty.~~ Won't fix: the Qt frontend is Wayland-only by design (depends on LayerShellQt + xkbcommon + wl_seat). The setupLayerShell null-handle path now logs and bails so failures are diagnosed instead of silently producing a non-functional regular window.
### Misc
- [x] **B47.** `reload-config` doesn't propagate `window-decoration` / `fullscreen` / `maximize` to existing windows. — fixed in `6d700c36b` (refreshChrome now applies them; same site as B12)
- [x] **B48.** `s_quitDelayMs` cached at init — runtime config reload doesn't update it. — fixed in `6d700c36b`
- [x] **B49.** Inspector window: hard-coded 800×600 each time — no autosave. — fixed in `6d700c36b` (QSettings restore/save under `inspector/geometry`)
---
## 🟡 Inconsistent (works, but feels wrong)
- [x] **I1.** Close-confirmation buttons "Yes/No" instead of "Cancel/Close" with destructive style. Not localized. macOS uses native NSAlert; GTK uses Adw.MessageDialog with `close-response: cancel`. — fixed in `bfd39a4dd` (destructive-styled Close/Quit/Paste; default Cancel)
- [x] **I2.** Bell mark `"● "` prefix vs macOS 🔔 vs GTK `setNeedsAttention`. — fixed in `ca52a39dc` (accent-dot tab icon instead of inline text prefix; QApplication::alert already provided WM urgency)
- [x] **I3.** `MOVE_TAB` clamps; GTK wraps. Qt matches macOS but mismatches GTK. — confirmed in `ca52a39dc` (clamp is the intentional choice; documented in moveTab)
- [x] **I4.** `GOTO_TAB:99` does nothing; macOS clamps to last tab; GTK clamps via `@min`. — fixed in `bfd39a4dd`
- [x] **I5.** `MOUSE_OVER_LINK` becomes a Qt tooltip; macOS+GTK use a dedicated overlay. — fixed in `ca52a39dc` (bottom-left URL pill via setLinkOverlay)
- [x] **I6.** `PROGRESS_REPORT` collapses ERROR/PAUSE/INDETERMINATE to a boolean. — fixed in `13d4353b1` (ERROR/PAUSE flag urgent=true; INDETERMINATE forces fraction=0)
- [x] **I7.** `COMMAND_FINISHED` ignores `notify-on-command-finish` config (`.never`/`.unfocused`/`.always`), `notify-on-command-finish-after`, and bell mode. — fixed in `13d4353b1`
- [x] **I8.** `DESKTOP_NOTIFICATION` is app-target only; `requireFocus` not honored. — fixed in `13d4353b1` (suppress on focused surface; matches macOS gate)
- [x] **I9.** Bell `attention` fallback hardcoded (`MainWindow.cpp:910`) — `configGet` failing silently falls back to `BellAttention`, ignoring user config. — fixed in `7c3868b5b`
- [x] **I10.** Cross-window split DnD unsupported. — fixed in `630c7ceae` for tabs (B34's same site). Cross-window split DnD specifically (drop a pane from one window's split tree onto another window's split) is a deeper rework — left as a follow-up; tab adoption gives a workable path (split-out → adopt-tab → re-split if needed).
---
## ⚪ Missing config options (silently dropped)
- [x] ~~**C1.** `window-save-state`~~ macOS-only per Config.zig (`This is currently only supported on macOS. This has no effect on Linux.`); won't fix.
- [x] **C2.** `window-step-resize` — fixed in `8b3877d67` via setSizeIncrement at CELL_SIZE-action time. Honored on X11; Wayland has no protocol equivalent. Config docs: "currently only supported on macOS / has no effect on Linux" — this is a bonus where the WM honors it.
- [x] **C3.** `window-width`, `window-height` — silently honored. libghostty fires INITIAL_SIZE on surface init with the cell-derived pixel size; the Qt handler resizes the window. Already working since `33b5dee46` (B7).
- [x] **C4.** `window-position-x`, `window-position-y` — fixed in `cd38f4bd5` (B33)
- [x] **C5.** `window-padding-balance`, `window-padding-color` — silently honored (consumed by libghostty's renderer in `src/renderer/generic.zig` and `src/Surface.zig`).
- [x] **C6.** `window-colorspace` — silently honored by libghostty's renderer.
- [x] **C7.** `window-inherit-working-directory` — silently honored via `ghostty_surface_inherited_config` (libghostty's `apprt/surface.zig` reads it). Qt new-tab/new-split paths pass `parent_surface`.
- [x] **C8.** `window-inherit-font-size` — silently honored via `apprt/embedded.zig` newSurfaceOptions reading the same config.
- [x] **C9.** `window-title-font-family` — fixed in `8bd64d0fa` (applies to the tab bar font; tab title is what the user actually sees).
- [x] **C10.** `bell-audio-path`, `bell-audio-volume` — already wired in `playBellAudio` (QMediaPlayer + QAudioOutput; reads `bell-audio-path` and `-volume` via configValue, expands `~/`, restarts on back-to-back bells). Audit was wrong about this one.
- [x] **C11.** `quick-terminal-screen` — fixed in `6d700c36b`
- [x] **C12.** `quick-terminal-animation-duration` — fixed in `cd38f4bd5` (B42)
- [x] **C13.** `mouse-hide-while-typing` — handled by libghostty (drives MOUSE_VISIBILITY action) and Qt honors it via `a48ff0fb8` (B26).
- [ ] **C14.** `background-image*` — needs apprt-side paint integration (200+ lines of work to load, scale, position, repeat, opacity-blend with the terminal framebuffer). Deferred as a feature.
- [x] **C15.** `split-divider-color` — fixed in `8bd64d0fa` (QSplitter::handle stylesheet).
- [x] **C16.** `clipboard-trim-trailing-spaces` — silently honored by libghostty inside Surface.zig before the apprt's write_clipboard_cb. Acknowledged in `6d700c36b`.
- [x] **C17.** `clipboard-paste-protection` — silently honored by libghostty (drives the confirm-paste path); destructive Paste/Cancel dialog landed in `6d700c36b`.
- [x] **C18.** `progress-style` — fixed in `13d4353b1` (`no`/`none` suppresses the taskbar entry).
- [x] **C19.** `split-preserve-zoom` — fixed in `8bd64d0fa` (`navigation` bit re-zooms destination on goto-split).
- [x] **C20.** `initial-window` — fixed in `6d700c36b`
- [x] **C21.** `app-notifications` (per-category gating) — fixed in `8bd64d0fa`. `config-reload` bit gates a freshly added "Configuration reloaded" toast on every reloadConfigGlobal. `clipboard-copy` bit is read for forward-compat — Qt doesn't currently post a copy toast, so the gate is trivially honored, but a future copy notification will pick this site up without code changes.
---
## ✅ Already correct (audit confirmed)
- HiDPI mouse coords (logical px to libghostty)
- Tab/Backtab focus traversal capture
- Auto-repeat synthesized release dropping
- Tab title elide + width cap
- Inspector lifetime via QPointer + dtor ordering
- Most QPointer captures in onAction queued lambdas
- Clipboard read/write via QClipboard
- IME preedit gating against ASCII duplicate-typing on text-input-v3 (KDE)
- Frame timer single-shared
- `unfocused-split-opacity` + `unfocused-split-fill`
---
## Recommended fix order
### Tier 1 — short (≤30 lines each), user-visible
- B1 quit-timer on natural close
- B4 `confirm-close-surface` config read
- B6 `CLOSE_TAB` mode
- B7 `INITIAL_SIZE` HiDPI
- B11 `RELOAD_CONFIG` global
- B16 `COPY_TITLE_TO_CLIPBOARD` per-surface
- B19 mouse buttons 4-11
- B25 `MOUSE_SHAPE`
- B26 `MOUSE_VISIBILITY`
- B29 XKB live layout
- B44 `quick-terminal-position = center`
- I9 bell-attention fallback
### Tier 2 — medium (50-150 lines), user-visible
- B15 child-exited gating
- B22-23 modifier mods (caps/num/sided)
- B24 mouse-enter/leave callbacks
- B27-28 right-click + click-to-focus suppression
- B33 window placement (cascade + position config)
- B37 tree-aware equalize
- B39 tab right-click menu
- B40 window-decoration full enum
- B42 quick-terminal animation
### Tier 3 — feature work (200+ lines or new modules)
- [x] Undo close-tab/window — `f3db5b6cb`
- [x] Most action-gap `default: return false;` items (PWD, GOTO_WINDOW, PRESENT_TERMINAL, KEY_TABLE, COLOR_CHANGE, FLOAT_WINDOW, SIZE_LIMIT, CELL_SIZE, RENDER_INSPECTOR, READONLY, SECURE_INPUT, CHECK_FOR_UPDATES, TOGGLE_BACKGROUND_OPACITY, TOGGLE_TAB_OVERVIEW, TOGGLE_WINDOW_DECORATIONS) — `20278082b`
- [x] Tier 2 stragglers: wheel pixelDelta+momentum (B30), drag-drop POSIX shell escape (B31), URL drop discrimination (B32) — `b86b11903`
- [x] UI consistency: destructive close/quit/paste dialogs (I1), GOTO_TAB clamp (I4), OPEN_URL/OPEN_CONFIG kind routing (B13/B14) — `bfd39a4dd`
- [x] Config reload polish: window-decoration / fullscreen / maximize propagation (B12/B47), quit-delay refresh (B48), inspector geometry autosave (B49), quick-terminal-screen (B43/C11), initial-window (C20) — `6d700c36b`
- [x] Input + notification fidelity: consumed_mods unconditional (B21), notify-on-command-finish gates (I7), notification focus suppression (I8), progress-report state preservation (I6), progress-style (C18) — `13d4353b1`
- [x] Bell + link overlay polish: tab accent dot (I2), MOVE_TAB clamp documented (I3), bottom-left link URL pill (I5) — `ca52a39dc`
- [x] Apprt-side config keys: window-title-font-family (C9), split-divider-color (C15), split-preserve-zoom (C19/B38), app-notifications.config-reload (C21) — `8bd64d0fa`
- [x] window-step-resize (C2) — `8b3877d67`
- [x] Quit semantics + theme + quick-term polish: QUIT vs CLOSE_ALL_WINDOWS (B2/B5), m_skipCloseConfirm (B3), window-theme pre-6.8 fallback (B41), quick-terminal-space-behavior no-op (B45), non-Wayland fallback (B46) — `4c903802a`
- [x] Split focus tree-order (B35), cross-window tab adoption (B34/I10) — `630c7ceae`
- [x] ~~Window save/restore~~`window-save-state` is macOS-only per Config.zig (`This is currently only supported on macOS. This has no effect on Linux.`). Won't fix.
- [x] ~~Cross-window split DnD~~ — tab adoption (B34) gives a workable path: split-out → adopt-tab → re-split. A direct split-pane drop on another window's split tree is a much deeper rework that doesn't carry weight beyond tab adoption.
- [ ] `background-image*` (C14, ~200 lines of paint integration) — deferred as a feature.
---
## How to use this document
1. Pick an item and tick `[ ]``[x]` when the commit lands.
2. Append the commit hash next to the line: `[x] **B7.** ... — fixed in abc1234`.
3. If a finding turns out to be wrong on closer inspection, strike it through with `~~B7.~~ ...` and add a one-line explanation.
4. New parity issues discovered during implementation: add to the appropriate section with the next ID in sequence.

84
qt/dist/ghastty.svg vendored
View File

@ -1,7 +1,9 @@
<?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. -->
<!-- V2: Original dome ghost + 4 wavy feet, on the purple CRT bezel.
Scaled up ~1.5x vs V1 so the ghost actually fills the screen
rectangle instead of floating in empty space. Eye/mouth ratios
preserved; the >_ prompt cue and the small mouth still read
correctly at small sizes (16px favicon range). -->
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024"
viewBox="0 0 1024 1024">
<defs>
@ -28,7 +30,7 @@
<stop offset="0.45" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="ghostBody" gradientUnits="userSpaceOnUse"
x1="512" y1="280" x2="512" y2="780">
x1="512" y1="175" x2="512" y2="850">
<stop offset="0" stop-color="#f4f1ea"/>
<stop offset="0.55" stop-color="#e3ddd0"/>
<stop offset="1" stop-color="#a89fa8"/>
@ -44,47 +46,49 @@
<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. -->
<!-- Dome ghost: rounded top (radius 285) + straight sides + 4 wavy
feet at bottom. 1.5x scale of V1; same anchor at x=512 but the
body now spans x=227..797 and y=175..850 inside the 88..936
screen rectangle. -->
<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
M 227 757
L 227 460
A 285 285 0 0 1 797 460
L 797 757
Q 726 850 654 757
Q 583 850 512 757
Q 441 850 370 757
Q 298 850 227 757 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
M 227 757
L 227 460
A 285 285 0 0 1 797 460
L 797 757
Q 726 850 654 757
Q 583 850 512 757
Q 441 850 370 757
Q 298 850 227 757 Z
" fill="none" stroke="#0a0c14" stroke-opacity="0.55"
stroke-width="9" stroke-linejoin="round"/>
stroke-width="14" 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"
<!-- > _ prompt eyes, centered on ghost x=512. Pair geometry
(1.5x V1):
chevron: x=338..437 (apex at 437), 99 wide
gap: 102
cursor: x=539..686, 147 wide
total: 338..686, midpoint 512
Vertical center y=514. -->
<ellipse cx="512" cy="514" rx="300" ry="120" fill="url(#mouthGlow)"/>
<path d="M 338 457 L 437 514 L 338 571"
fill="none" stroke="#e63232" stroke-width="42"
stroke-linecap="round" stroke-linejoin="round"/>
<rect x="530" y="478" width="98" height="36" rx="6" fill="#e63232"/>
<rect x="539" y="487" width="147" height="54" rx="9" 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"/>
<!-- Small mouth: sits below the eyes, above the foot trough.
Straight-body region ends at y=757 where the feet begin;
placing the mouth at y=640..688 keeps it on the body proper,
not in the foot scallops. -->
<ellipse cx="512" cy="664" rx="84" ry="48" fill="url(#mouthGlow)"/>
<rect x="482" y="640" width="60" height="48" rx="6" fill="#e63232"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -6,6 +6,7 @@
#include "SearchBar.h"
#include "TabWidget.h"
#include "Util.h"
#include "XkbTracker.h"
#include <algorithm>
#include <cmath>
@ -182,6 +183,12 @@ void GhosttySurface::resizeEvent(QResizeEvent *) {
if (m_exitOverlay) m_exitOverlay->setGeometry(rect());
if (m_keySeqOverlay && m_keySeqOverlay->isVisible())
m_keySeqOverlay->move(8, height() - m_keySeqOverlay->height() - 8);
if (m_linkOverlay && m_linkOverlay->isVisible()) {
int y = height() - m_linkOverlay->height() - 8;
if (m_keySeqOverlay && m_keySeqOverlay->isVisible())
y -= m_keySeqOverlay->height() + 4;
m_linkOverlay->move(8, y);
}
layoutSearchBar();
showResizeOverlay();
}
@ -319,6 +326,17 @@ void GhosttySurface::flashBorder() {
});
}
void GhosttySurface::setShape(Qt::CursorShape shape) {
m_cursorShape = shape;
if (m_mouseVisible) setCursor(shape);
}
void GhosttySurface::setMouseVisible(bool visible) {
if (m_mouseVisible == visible) return;
m_mouseVisible = visible;
setCursor(visible ? m_cursorShape : Qt::BlankCursor);
}
// A small translucent overlay label (key-sequence / resize display).
static QLabel *makeOverlayLabel(QWidget *parent) {
auto *label = new QLabel(parent);
@ -370,6 +388,35 @@ void GhosttySurface::endKeySequence() {
if (m_keySeqOverlay) m_keySeqOverlay->hide();
}
void GhosttySurface::setLinkOverlay(const QString &url) {
if (url.isEmpty()) {
if (m_linkOverlay) m_linkOverlay->hide();
return;
}
if (!m_linkOverlay) m_linkOverlay = makeOverlayLabel(this);
// Cap very long URLs so the overlay doesn't span the whole pane.
// 80 chars is enough to recognise hostnames + the path prefix; an
// ellipsis in the middle preserves both halves so a query string
// reveal still includes the host.
QString display = url;
constexpr int kCap = 80;
if (display.size() > kCap) {
const int half = (kCap - 1) / 2;
display = display.left(half) + QStringLiteral("") +
display.right(kCap - 1 - half);
}
m_linkOverlay->setText(display);
m_linkOverlay->adjustSize();
// Bottom-left, but offset upward when the keybind-chord overlay is
// visible so they don't stack on top of each other.
int yBase = height() - m_linkOverlay->height() - 8;
if (m_keySeqOverlay && m_keySeqOverlay->isVisible())
yBase -= m_keySeqOverlay->height() + 4;
m_linkOverlay->move(8, yBase);
m_linkOverlay->show();
m_linkOverlay->raise();
}
void GhosttySurface::toggleInspector(ghostty_action_inspector_e mode) {
const bool visible = m_inspectorWindow && m_inspectorWindow->isVisible();
bool show;
@ -389,6 +436,10 @@ void GhosttySurface::toggleInspector(ghostty_action_inspector_e mode) {
}
}
void GhosttySurface::refreshInspector() {
if (m_inspectorWindow) m_inspectorWindow->update();
}
void GhosttySurface::openSearch(const QString &prefill) {
if (!m_searchBar) m_searchBar = new SearchBar(this);
m_searchBar->open(prefill);
@ -584,7 +635,12 @@ public:
// Level-0 (unshifted) Unicode codepoint for `keycode`, or 0 if the
// key has no associated UTF-32 (function keys, modifiers, etc.).
//
// Uses the live keymap from XkbTracker (synced via wl_keyboard) so
// the active layout group is honored. A us+ru user gets the
// correct codepoint per active group, instead of always us.
uint32_t unshiftedCodepoint(uint32_t keycode) const {
syncFromTracker();
if (!m_unshifted) return 0;
const xkb_keysym_t sym =
xkb_state_key_get_one_sym(m_unshifted, keycode);
@ -592,12 +648,50 @@ public:
return xkb_keysym_to_utf32(sym);
}
// Side bits for the libghostty mods bitfield, derived from a
// keycode — used so that pressing Right-Shift sets BOTH the
// unsided GHOSTTY_MODS_SHIFT and the GHOSTTY_MODS_SHIFT_RIGHT bit
// (a left-side keycode sets only the unsided bit). macOS and GTK
// populate sided bits this way; Qt was leaving them empty so
// bindings that distinguish left-vs-right modifier keys couldn't
// fire.
ghostty_input_mods_e sideBitsForKeycode(uint32_t keycode) const {
syncFromTracker();
if (!m_unshifted) return GHOSTTY_MODS_NONE;
const xkb_keysym_t sym =
xkb_state_key_get_one_sym(m_unshifted, keycode);
int r = GHOSTTY_MODS_NONE;
switch (sym) {
case XKB_KEY_Shift_R: r |= GHOSTTY_MODS_SHIFT_RIGHT; break;
case XKB_KEY_Control_R: r |= GHOSTTY_MODS_CTRL_RIGHT; break;
// Both Alt_R and ISO_Level3_Shift (AltGr) are right-Alt physically.
case XKB_KEY_Alt_R:
case XKB_KEY_ISO_Level3_Shift: r |= GHOSTTY_MODS_ALT_RIGHT; break;
case XKB_KEY_Super_R:
case XKB_KEY_Hyper_R:
case XKB_KEY_Meta_R: r |= GHOSTTY_MODS_SUPER_RIGHT; break;
default: break;
}
return static_cast<ghostty_input_mods_e>(r);
}
// Caps Lock / Num Lock state from the live wl_keyboard tracker.
ghostty_input_mods_e lockMods() const {
int r = GHOSTTY_MODS_NONE;
if (XkbTracker *t = XkbTracker::instance()) {
if (t->capsLockOn()) r |= GHOSTTY_MODS_CAPS;
if (t->numLockOn()) r |= GHOSTTY_MODS_NUM;
}
return static_cast<ghostty_input_mods_e>(r);
}
// Modifiers consumed by the layout to produce `keycode`'s text given
// `mods` are depressed. Returns the consumed subset, expressed as
// ghostty mod bits. Mutates m_query (mutable) — see thread-safety
// note on the class.
ghostty_input_mods_e consumedMods(uint32_t keycode,
ghostty_input_mods_e mods) const {
syncFromTracker();
if (!m_query) return GHOSTTY_MODS_NONE;
xkb_mod_mask_t depressed = 0;
if ((mods & GHOSTTY_MODS_SHIFT) && m_idxShift != XKB_MOD_INVALID)
@ -608,11 +702,15 @@ public:
depressed |= (1u << m_idxAlt);
if ((mods & GHOSTTY_MODS_SUPER) && m_idxSuper != XKB_MOD_INVALID)
depressed |= (1u << m_idxSuper);
xkb_state_update_mask(m_query, depressed, 0, 0, 0, 0, 0);
// Use the live group from the tracker so a layout switch (e.g.
// us↔ru) takes effect immediately.
const uint32_t group =
XkbTracker::instance() ? XkbTracker::instance()->activeGroup() : 0;
xkb_state_update_mask(m_query, depressed, 0, 0, 0, 0, group);
const xkb_mod_mask_t consumed = xkb_state_key_get_consumed_mods2(
m_query, keycode, XKB_CONSUMED_MODE_XKB);
// Reset so the next query starts from no-mods.
xkb_state_update_mask(m_query, 0, 0, 0, 0, 0, 0);
xkb_state_update_mask(m_query, 0, 0, 0, 0, 0, group);
int r = GHOSTTY_MODS_NONE;
if (m_idxShift != XKB_MOD_INVALID && (consumed & (1u << m_idxShift)))
r |= GHOSTTY_MODS_SHIFT;
@ -626,20 +724,56 @@ public:
}
private:
XkbState() {
m_ctx = xkb_context_new(XKB_CONTEXT_NO_FLAGS);
if (!m_ctx) return;
m_keymap = xkb_keymap_new_from_names(m_ctx, nullptr,
XKB_KEYMAP_COMPILE_NO_FLAGS);
if (!m_keymap) return;
m_unshifted = xkb_state_new(m_keymap);
m_query = xkb_state_new(m_keymap);
m_idxShift = xkb_keymap_mod_get_index(m_keymap, XKB_MOD_NAME_SHIFT);
m_idxCtrl = xkb_keymap_mod_get_index(m_keymap, XKB_MOD_NAME_CTRL);
m_idxAlt = xkb_keymap_mod_get_index(m_keymap, XKB_MOD_NAME_ALT);
m_idxSuper = xkb_keymap_mod_get_index(m_keymap, XKB_MOD_NAME_LOGO);
// Lazy: build/rebuild m_unshifted + m_query from the live keymap.
// Called from every public method; cheap when the keymap pointer
// hasn't changed (a single comparison + early-return).
void syncFromTracker() const {
XkbTracker *t = XkbTracker::instance();
xkb_keymap *liveKm = t ? t->keymap() : nullptr;
xkb_keymap *km = liveKm ? liveKm : m_fallbackKeymap;
if (!km && t && t->ctx()) {
// Compositor hasn't sent a keymap yet (early startup). Build a
// throwaway from XKB defaults so the first key event isn't
// dropped; it will be replaced on the next syncFromTracker
// call once the tracker has the live keymap.
m_fallbackKeymap = xkb_keymap_new_from_names(
t->ctx(), nullptr, XKB_KEYMAP_COMPILE_NO_FLAGS);
km = m_fallbackKeymap;
}
if (!km || km == m_keymap) {
// Already synced (or no keymap available at all).
// Update the live group on m_unshifted so the level-0 lookup
// honors the active layout, even when the keymap pointer
// hasn't changed.
if (m_unshifted && t) {
xkb_state_update_mask(m_unshifted, 0, 0, 0, 0, 0, t->activeGroup());
}
return;
}
// The live keymap was rebuilt by the tracker (or we're picking
// up the first one). Drop our derived states and rebuild. Take
// an extra ref on the keymap while it's our cached identity so
// the xkb allocator can't free it and reuse the same address
// for a different keymap (the ABA hazard the previous comment
// hand-waved away).
if (m_unshifted) xkb_state_unref(m_unshifted);
if (m_query) xkb_state_unref(m_query);
if (m_keymap) xkb_keymap_unref(m_keymap);
m_keymap = xkb_keymap_ref(km);
m_unshifted = xkb_state_new(km);
m_query = xkb_state_new(km);
m_idxShift = xkb_keymap_mod_get_index(km, XKB_MOD_NAME_SHIFT);
m_idxCtrl = xkb_keymap_mod_get_index(km, XKB_MOD_NAME_CTRL);
m_idxAlt = xkb_keymap_mod_get_index(km, XKB_MOD_NAME_ALT);
m_idxSuper = xkb_keymap_mod_get_index(km, XKB_MOD_NAME_LOGO);
if (t)
xkb_state_update_mask(m_unshifted, 0, 0, 0, 0, 0, t->activeGroup());
}
XkbState() = default;
~XkbState() {
// Run on process exit when the static is destroyed. The OS would
// reclaim regardless, but explicit teardown silences leak checkers
@ -647,22 +781,27 @@ private:
if (m_query) xkb_state_unref(m_query);
if (m_unshifted) xkb_state_unref(m_unshifted);
if (m_keymap) xkb_keymap_unref(m_keymap);
if (m_ctx) xkb_context_unref(m_ctx);
if (m_fallbackKeymap) xkb_keymap_unref(m_fallbackKeymap);
}
XkbState(const XkbState &) = delete;
XkbState &operator=(const XkbState &) = delete;
struct xkb_context *m_ctx = nullptr;
struct xkb_keymap *m_keymap = nullptr;
struct xkb_state *m_unshifted = nullptr; // permanent no-mods state
// Reused across consumedMods calls (mutated then reset). Mutable so
// consumedMods can stay logically const.
// The keymap our derived states were built from. We hold a ref
// here (taken in syncFromTracker, released on rebuild and in dtor)
// so the xkb allocator can't free + reuse the address while we
// still cache it as our identity.
mutable struct xkb_keymap *m_keymap = nullptr;
// Throwaway keymap from XKB defaults, built when the live keymap
// hasn't arrived yet. Owned. Released in dtor; never replaced.
mutable struct xkb_keymap *m_fallbackKeymap = nullptr;
mutable struct xkb_state *m_unshifted = nullptr; // no-mods state
// Reused across consumedMods calls (mutated then reset).
mutable struct xkb_state *m_query = nullptr;
xkb_mod_index_t m_idxShift = XKB_MOD_INVALID;
xkb_mod_index_t m_idxCtrl = XKB_MOD_INVALID;
xkb_mod_index_t m_idxAlt = XKB_MOD_INVALID;
xkb_mod_index_t m_idxSuper = XKB_MOD_INVALID;
mutable xkb_mod_index_t m_idxShift = XKB_MOD_INVALID;
mutable xkb_mod_index_t m_idxCtrl = XKB_MOD_INVALID;
mutable xkb_mod_index_t m_idxAlt = XKB_MOD_INVALID;
mutable xkb_mod_index_t m_idxSuper = XKB_MOD_INVALID;
};
void GhosttySurface::sendKey(QKeyEvent *ev, ghostty_input_action_e action) {
@ -687,6 +826,14 @@ void GhosttySurface::sendKey(QKeyEvent *ev, ghostty_input_action_e action) {
ghostty_input_key_s k = {};
k.action = action;
k.mods = translateMods(ev->modifiers());
// OR in any right-side bit for this keycode (e.g. Right-Shift sets
// SHIFT_RIGHT alongside SHIFT) and the live Caps/Num lock state
// from XkbTracker. macOS + GTK populate all of these; without
// them, keybinds like `right_shift+x` can't distinguish from
// `left_shift+x` and the kitty CSI-u encoding loses the lock bits.
k.mods = static_cast<ghostty_input_mods_e>(
k.mods | XkbState::instance().sideBitsForKeycode(keycode) |
XkbState::instance().lockMods());
k.keycode = keycode;
k.text = printable ? text.constData() : nullptr;
// XKB lookups: unshifted codepoint (what this physical key would
@ -696,9 +843,13 @@ void GhosttySurface::sendKey(QKeyEvent *ev, ghostty_input_action_e action) {
// the shell can't decode; with it set, libghostty's encoder falls
// back to plain text correctly.
k.unshifted_codepoint = XkbState::instance().unshiftedCodepoint(keycode);
k.consumed_mods = printable
? XkbState::instance().consumedMods(keycode, k.mods)
: GHOSTTY_MODS_NONE;
// consumed_mods is computed for every event, not just printable ones.
// Function/keypad/Backspace/arrows can also have layout-consumed
// modifiers (e.g. Caps Lock affecting case for letter keys, Mode_Switch
// for layout shifts on Backspace) that the kitty encoder needs to
// strip. macOS + GTK both compute it unconditionally; gating on
// printable lost that info on non-text keys.
k.consumed_mods = XkbState::instance().consumedMods(keycode, k.mods);
k.composing = false;
ghostty_surface_key(m_surface, k);
@ -712,6 +863,18 @@ void GhosttySurface::sendMouseButton(QMouseEvent *ev,
case Qt::LeftButton: button = GHOSTTY_MOUSE_LEFT; break;
case Qt::RightButton: button = GHOSTTY_MOUSE_RIGHT; break;
case Qt::MiddleButton: button = GHOSTTY_MOUSE_MIDDLE; break;
// Side / extra buttons (back, forward, etc.). macOS handles
// NSEvent buttonNumber 3-10 and GTK handles GDK button 4-11;
// Qt's ExtraButton1..ExtraButton8 cover the same hardware. The
// libghostty C ABI defines FOUR..ELEVEN, so map by index.
case Qt::ExtraButton1: button = GHOSTTY_MOUSE_FOUR; break;
case Qt::ExtraButton2: button = GHOSTTY_MOUSE_FIVE; break;
case Qt::ExtraButton3: button = GHOSTTY_MOUSE_SIX; break;
case Qt::ExtraButton4: button = GHOSTTY_MOUSE_SEVEN; break;
case Qt::ExtraButton5: button = GHOSTTY_MOUSE_EIGHT; break;
case Qt::ExtraButton6: button = GHOSTTY_MOUSE_NINE; break;
case Qt::ExtraButton7: button = GHOSTTY_MOUSE_TEN; break;
case Qt::ExtraButton8: button = GHOSTTY_MOUSE_ELEVEN; break;
default: button = GHOSTTY_MOUSE_UNKNOWN; break;
}
ghostty_surface_mouse_button(m_surface, state, button,
@ -749,13 +912,30 @@ void GhosttySurface::mousePressEvent(QMouseEvent *ev) {
m_owner->removeSurface(this);
return;
}
// Click-to-focus: if the surface didn't have focus, this click is
// grabbing focus rather than a real interaction with the running
// program. macOS + GTK suppress the matching mouse-up so vim, less,
// etc. don't see a stray button-up event. We mirror that by setting
// a one-shot flag the matching release consults.
const bool wasFocused = hasFocus();
setFocus();
if (rightClickOpensMenu(ev)) return;
if (!wasFocused && ev->button() == Qt::LeftButton)
m_suppressNextLeftRelease = true;
// Right-click: send the press to libghostty BEFORE deciding to
// open the context menu. macOS + GTK both do this so the core can
// word-select on right-press and then we open the menu over the
// selection. If the running program is mouse-captured, the press
// is forwarded as a real button event.
sendMouseButton(ev, GHOSTTY_MOUSE_PRESS);
}
void GhosttySurface::mouseReleaseEvent(QMouseEvent *ev) {
if (rightClickOpensMenu(ev)) return;
// Suppress the release of a focus-grabbing click — see press above.
if (ev->button() == Qt::LeftButton && m_suppressNextLeftRelease) {
m_suppressNextLeftRelease = false;
return;
}
sendMouseButton(ev, GHOSTTY_MOUSE_RELEASE);
}
@ -874,6 +1054,33 @@ void GhosttySurface::dragEnterEvent(QDragEnterEvent *ev) {
ev->acceptProposedAction();
}
// Quote `s` for a POSIX shell using $'…' encoding. Mirrors
// macOS Ghostty.Shell.escape and GTK ShellEscapeWriter — handles
// embedded quotes, backslashes, newlines, and control chars; bash's
// `'\''` trick fails on dash/zsh + non-printable bytes.
static QString shellQuote(const QString &s) {
QString out;
out.reserve(s.size() + 4);
out += QLatin1String("$'");
for (QChar ch : s) {
const ushort c = ch.unicode();
if (c == '\\' || c == '\'')
out += QLatin1Char('\\'), out += ch;
else if (c == '\n')
out += QLatin1String("\\n");
else if (c == '\r')
out += QLatin1String("\\r");
else if (c == '\t')
out += QLatin1String("\\t");
else if (c < 0x20)
out += QString::asprintf("\\x%02x", c);
else
out += ch;
}
out += QLatin1Char('\'');
return out;
}
void GhosttySurface::dropEvent(QDropEvent *ev) {
const QMimeData *mime = ev->mimeData();
// A tab tear-off released on the terminal: accept it cleanly and let
@ -884,14 +1091,19 @@ void GhosttySurface::dropEvent(QDropEvent *ev) {
}
QString text;
if (mime->hasUrls()) {
// Dropped files are inserted as shell-quoted, space-separated paths.
QStringList paths;
// Distinguish file URLs from non-file URLs (http://, etc). File
// URLs become shell-quoted paths joined with spaces; non-file URLs
// paste as plain text. macOS + GTK both make this distinction
// (otherwise dragging a link from a browser yields a quoted
// command-line argument instead of pasting the URL).
QStringList parts;
for (const QUrl &url : mime->urls()) {
QString p = url.isLocalFile() ? url.toLocalFile() : url.toString();
p.replace(QLatin1String("'"), QLatin1String("'\\''"));
paths << QLatin1Char('\'') + p + QLatin1Char('\'');
if (url.isLocalFile())
parts << shellQuote(url.toLocalFile());
else
parts << url.toString();
}
text = paths.join(QLatin1Char(' '));
text = parts.join(QLatin1Char(' '));
} else if (mime->hasText()) {
text = mime->text();
}
@ -918,15 +1130,66 @@ void GhosttySurface::mouseMoveEvent(QMouseEvent *ev) {
void GhosttySurface::wheelEvent(QWheelEvent *ev) {
if (!m_surface) return;
// angleDelta is in eighths of a degree; 120 units == one wheel notch.
const QPoint d = ev->angleDelta();
ghostty_surface_mouse_scroll(m_surface, d.x() / 120.0, d.y() / 120.0, 0);
// libghostty's ScrollMods is a packed u8: bit 0 = precision (high-res
// / pixel-precise), bits 1-3 = momentum phase (none/began/changed/
// ended/cancelled/may_begin) per src/input/mouse.zig.
//
// Trackpads and high-resolution mice fill in pixelDelta; classic
// notched wheels only fill angleDelta (120 units per notch). When
// pixelDelta is present we feed that, divide by an approximate cell
// height (we don't have it from libghostty here, so use 16 logical
// pixels — close enough for smooth-scroll feel) and flag the event
// as precision so kitty's smooth-scroll engages. Otherwise we fall
// back to the classic "120 units == one notch" path.
double dx = 0.0, dy = 0.0;
int mods = 0;
const QPoint pd = ev->pixelDelta();
if (!pd.isNull()) {
constexpr double kCellPx = 16.0;
dx = pd.x() / kCellPx;
dy = pd.y() / kCellPx;
mods |= 1; // ScrollMods.precision
} else {
const QPoint a = ev->angleDelta();
dx = a.x() / 120.0;
dy = a.y() / 120.0;
}
// ScrollMods.momentum (3-bit field at bit 1). Qt only signals the
// ScrollBegin/ScrollUpdate/ScrollEnd phases on trackpads.
switch (ev->phase()) {
case Qt::ScrollBegin: mods |= (1 /*began*/) << 1; break;
case Qt::ScrollUpdate: mods |= (3 /*changed*/) << 1; break;
case Qt::ScrollEnd: mods |= (4 /*ended*/) << 1; break;
case Qt::ScrollMomentum: mods |= (3 /*changed*/) << 1; break;
default: break; // NoScrollPhase: treat as a discrete notch
}
ghostty_surface_mouse_scroll(m_surface, dx, dy, mods);
flashScrollbar(); // mouse-wheel scrolling reveals the overlay scrollbar
}
void GhosttySurface::enterEvent(QEnterEvent *) {
void GhosttySurface::enterEvent(QEnterEvent *ev) {
// focus-follows-mouse: take focus when the pointer enters this pane.
if (m_owner && m_owner->focusFollowsMouse() && !hasFocus()) setFocus();
// Tell libghostty about the actual cursor position so hover state
// and OSC-8 link arming reset from any stale (-1, -1) sentinel.
// macOS does this in mouseEntered (SurfaceView_AppKit.swift:920);
// GTK does it in ecMouseEnter (apprt/gtk/class/surface.zig).
if (m_surface)
ghostty_surface_mouse_pos(m_surface, ev->position().x(),
ev->position().y(),
translateMods(QGuiApplication::keyboardModifiers()));
}
void GhosttySurface::leaveEvent(QEvent *) {
// libghostty's "no cursor here" sentinel: pass (-1, -1) so any
// hover-armed state (URL underline, mouse-report sequences for an
// OSC-8 link) clears once the pointer leaves the pane. macOS and
// GTK both do this; without it the arm state would survive until
// the next move event.
if (m_surface)
ghostty_surface_mouse_pos(m_surface, -1, -1,
translateMods(QGuiApplication::keyboardModifiers()));
}
void GhosttySurface::focusInEvent(QFocusEvent *) {

View File

@ -87,6 +87,11 @@ public:
// Show/hide/toggle the terminal inspector window (INSPECTOR action).
void toggleInspector(ghostty_action_inspector_e mode);
// Force an extra inspector repaint (RENDER_INSPECTOR action). The
// inspector window has its own ~30Hz redraw timer; this just kicks
// a Qt update so a libghostty-driven invalidation is visible
// promptly.
void refreshInspector();
// In-terminal search (the *_SEARCH actions): openSearch shows the
// search bar (optionally pre-filled), closeSearch hides it, and the
@ -103,6 +108,22 @@ public:
void setBellTitle(bool marked) { m_bellTitle = marked; }
bool bellTitle() const { return m_bellTitle; }
// Set the cursor shape from the libghostty MOUSE_SHAPE action.
// Tracks the requested shape so MOUSE_VISIBILITY toggles can hide
// and restore without forgetting it. macOS+GTK preserve shape
// across visibility changes; the previous Qt code clobbered it
// with Qt::ArrowCursor on un-hide.
void setShape(Qt::CursorShape shape);
// Hide or show the mouse cursor without changing its shape.
void setMouseVisible(bool visible);
// Show / hide the dedicated MOUSE_OVER_LINK URL overlay (a small
// pill at the surface's bottom-left). Replaces the prior
// setToolTip-based hint, which followed the cursor and only
// appeared after the OS hover delay. macOS + GTK both render a
// dedicated overlay.
void setLinkOverlay(const QString &url);
protected:
bool event(QEvent *) override;
void paintEvent(QPaintEvent *) override;
@ -122,6 +143,7 @@ protected:
void dropEvent(QDropEvent *) override;
void wheelEvent(QWheelEvent *) override;
void enterEvent(QEnterEvent *) override; // focus-follows-mouse
void leaveEvent(QEvent *) override; // libghostty hover reset
void focusInEvent(QFocusEvent *) override;
void focusOutEvent(QFocusEvent *) override;
@ -181,6 +203,7 @@ private:
QLabel *m_exitOverlay = nullptr; // "process exited" banner; lazily made
QLabel *m_keySeqOverlay = nullptr; // pending keybind chord; lazily made
QLabel *m_linkOverlay = nullptr; // MOUSE_OVER_LINK URL hint; 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
@ -197,6 +220,16 @@ private:
bool m_notifyOnCommand = false; // one-shot: notify on next cmd finish
bool m_bellFlash = false; // bell `border` flash in progress
bool m_bellTitle = false; // unacknowledged bell `title` mark
// Set when a left-click grabbed focus from elsewhere; cleared on
// the matching mouse-up so the click that grabbed focus isn't
// also reported to the running program. macOS + GTK do the same
// (suppressNextLeftMouseUp / suppress_left_mouse_release).
bool m_suppressNextLeftRelease = false;
// Last requested cursor shape (from MOUSE_SHAPE) and visibility
// (from MOUSE_VISIBILITY). Tracked separately so toggling
// visibility doesn't reset the shape.
Qt::CursorShape m_cursorShape = Qt::IBeamCursor;
bool m_mouseVisible = true;
// 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

View File

@ -11,6 +11,9 @@
#include <QOpenGLFramebufferObject>
#include <QOpenGLFunctions>
#include <QPainter>
#include <QGuiApplication>
#include <QScreen>
#include <QSettings>
#include <QSurfaceFormat>
#include <QTimer>
#include <QWheelEvent>
@ -46,7 +49,33 @@ InspectorWindow::InspectorWindow(ghostty_surface_t surface)
setWindowTitle(QStringLiteral("Ghastty Inspector"));
setFocusPolicy(Qt::StrongFocus);
setMouseTracking(true);
resize(800, 600);
// Restore the last saved size/position. macOS uses NSWindow's
// autosaveName; Qt has no built-in equivalent, so we persist via
// QSettings ourselves. First-run default matches the prior
// hard-coded 800x600.
QSettings s;
const QByteArray geom =
s.value(QStringLiteral("inspector/geometry")).toByteArray();
if (!restoreGeometry(geom)) {
resize(800, 600);
} else if (QScreen *primary = QGuiApplication::primaryScreen()) {
// restoreGeometry happily restores positions on a screen that no
// longer exists (monitor unplugged, scaled away) and sizes that
// exceed the current screen layout. Validate both: re-centre when
// the centre is off any screen, and clamp the size to fit the
// primary screen so the window can't extend past the visible
// area on a smaller-than-saved monitor.
const QRect g = primary->availableGeometry();
QSize sz = size();
if (sz.width() > g.width() || sz.height() > g.height()) {
sz = sz.boundedTo(g.size());
resize(sz);
}
if (!QGuiApplication::screenAt(geometry().center())) {
move(g.center() - QPoint(sz.width() / 2, sz.height() / 2));
}
}
m_inspector = ghostty_surface_inspector(m_surface);
@ -150,6 +179,8 @@ void InspectorWindow::closeEvent(QCloseEvent *e) {
// 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();
// Persist size + position so the next reveal restores them.
QSettings().setValue(QStringLiteral("inspector/geometry"), saveGeometry());
hide();
e->ignore();
}

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,9 @@
#include <atomic>
#include <QList>
#include <QRect>
#include <QSize>
#include <QStringList>
#include <QWidget>
#include "ghostty.h"
@ -14,6 +16,7 @@ class QMediaPlayer;
class QShowEvent;
class QSplitter;
class TabWidget;
class QPropertyAnimation;
class QTimer;
class CommandPalette;
class GhosttySurface;
@ -47,6 +50,12 @@ public:
// (TOGGLE_QUICK_TERMINAL). There is at most one per process.
static void toggleQuickTerminal();
// Quick-terminal slide/fade animation per quick-terminal-animation-
// duration. Implemented as a windowOpacity fade because Qt's layer-
// shell doesn't expose a usable position-based slide API.
void animateQuickTerminalIn();
void animateQuickTerminalOut();
// 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);
@ -65,6 +74,34 @@ public:
// The live libghostty config (for keybind lookups, etc.).
ghostty_config_t config() const { return s_config; }
// UNDO / REDO close-tab/window. The libghostty actions carry no
// payload — the apprt is responsible for tracking what was closed
// and reviving it. macOS uses NSUndoManager; we keep a small bounded
// stack of "snapshots" per kind. Surfaces themselves can't be
// revived (the child PTY is gone) — undo opens a fresh tab/window
// and reapplies the saved title; the new surface inherits cwd from
// the active surface (matching macOS, which also spawns a fresh
// shell rather than re-attaching).
static void undoLastClose();
static void redoLastClose();
// PRESENT_TERMINAL: bring this window to front and focus the surface.
void presentTerminal(GhosttySurface *surface);
// GOTO_WINDOW: cycle to the previous/next window in s_windows order.
static void gotoWindow(MainWindow *from,
ghostty_action_goto_window_e dir);
// FLOAT_WINDOW / TOGGLE_WINDOW_DECORATIONS / TOGGLE_BACKGROUND_OPACITY:
// simple per-window toggles with the requested mode.
void setFloating(ghostty_action_float_window_e mode);
void toggleWindowDecorations();
void toggleBackgroundOpacity();
// SIZE_LIMIT: clamp the window's resizable range to libghostty's
// computed cell-based limits. CELL_SIZE: store the cell size for
// future grid-snap resizing (no-op until a resize-snap feature lands).
void setSizeLimits(uint32_t minW, uint32_t minH, uint32_t maxW,
uint32_t maxH);
void setCellSize(uint32_t w, uint32_t h);
// 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).
@ -98,6 +135,13 @@ private:
static void frame();
void closeTab(int index);
// Honor close-tab-mode (THIS / OTHER / RIGHT) from libghostty.
void closeTabsByMode(GhosttySurface *src,
ghostty_action_close_tab_mode_e mode);
// Right-click context menu over a tab (Close / Close Others /
// Close Tabs to the Right / Rename), wired from
// TabWidget::tabContextMenuRequested.
void showTabContextMenu(int index, const QPoint &globalPos);
// 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.
@ -129,10 +173,15 @@ private:
// 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();
void copyTitleToClipboard(GhosttySurface *src);
// Rebuild the config from disk and push it to libghostty.
void reloadConfig();
// App-scoped reload entry point. The config is process-wide (statics
// in this class), so reload from any window has the same effect; the
// RELOAD_CONFIG action posts to qApp via this static so the reload
// can't be cancelled by the source window closing mid-dispatch.
static void reloadConfigGlobal();
// Refresh every window's chrome from the current config (used after a
// reload and on the CONFIG_CHANGE notification).
static void refreshChrome();
@ -162,9 +211,13 @@ private:
// 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();
// Close every window, optionally quitting the process. Prompts once
// via ghostty_app_needs_confirm_quit. `thenQuit=true` is the QUIT
// action's behavior (close everything and end the process);
// `thenQuit=false` is CLOSE_ALL_WINDOWS, which leaves the process
// alive when `quit-after-last-window-closed=false` is set —
// matching macOS where close-all and quit are distinct.
static void closeAllWindows(bool thenQuit);
// Wire the libghostty quit_timer action to a delayed QApplication
// quit, gated on `quit-after-last-window-closed`.
@ -199,7 +252,25 @@ private:
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
// Per-window opacity animation for the quick terminal (fade in/out
// using quick-terminal-animation-duration). Lazily created.
QPropertyAnimation *m_quickTerminalAnim = nullptr;
QSize m_defaultWindowSize; // for RESET_WINDOW_SIZE; from INITIAL_SIZE
// Last cell size reported by libghostty for this window's surfaces
// (CELL_SIZE action). Stored so future grid-snap resizing can use
// it; not used yet beyond bookkeeping.
QSize m_cellSize;
// Floating-window state: set when the user toggles via FLOAT_WINDOW.
// Tracked separately from windowFlags() because Qt's
// WindowStaysOnTopHint pokes other state on Wayland.
bool m_floating = false;
// Tracks whether window decorations are currently suppressed via
// TOGGLE_WINDOW_DECORATIONS (separate from the config-driven init).
bool m_decorationsHidden = false;
// Tracks whether background-opacity is currently bypassed via
// TOGGLE_BACKGROUND_OPACITY (forces the window opaque regardless
// of `background-opacity`).
bool m_opacityForcedOpaque = false;
// Process-shared libghostty state: one app and config drive every
// window. Created by the first initialize(), freed with the last
@ -216,6 +287,34 @@ private:
// same shared app.
static QTimer *s_frameTimer;
// Snapshot of a closed tab or window for undo/redo. `pageTitles`
// holds each tab's last-known title (window snapshots have N tabs;
// tab snapshots have one). `geometry` is unused for tab snapshots.
// `kind` distinguishes the two so REDO can reclose the right thing.
struct UndoEntry {
enum class Kind { Tab, Window } kind = Kind::Tab;
QStringList pageTitles;
QRect geometry;
};
// Bounded undo/redo stacks (tail = most recent). Each tab/window
// close pushes an entry, capped at kUndoCap; opening a new
// tab/window via undo pushes onto the redo stack. While
// `s_redoInProgress` is true, the close paths that normally
// mutate these stacks (pushTabUndo / pushWindowUndo) become
// no-ops — a redo is replaying a previous close and shouldn't
// also feed itself a fresh undo entry that the user will then
// unwind into a loop.
static QList<UndoEntry> s_undoStack;
static QList<UndoEntry> s_redoStack;
static bool s_redoInProgress;
static constexpr int kUndoCap = 16;
// Push a snapshot for the tab at `index` onto s_undoStack and
// clear the redo stack (a new close invalidates a forward redo).
void pushTabUndo(int index);
// Push a snapshot of every tab in this window onto s_undoStack as a
// single Window entry; called from closeAllWindows / closeEvent.
void pushWindowUndo();
// 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;
@ -228,8 +327,13 @@ private:
int m_zoomIndex = 0;
// Bell audio playback; created lazily on the first audio bell.
// The bell-audio-path / -volume values are cached at window setup
// and refreshed on reload so the bell hot path doesn't re-scan
// the on-disk config file.
QMediaPlayer *m_bellPlayer = nullptr;
QAudioOutput *m_bellAudio = nullptr;
QString m_bellAudioPath; // expanded; empty if no clip configured
double m_bellAudioVolume = 0.5;
// The command palette; created lazily on first use.
CommandPalette *m_commandPalette = nullptr;

View File

@ -3,6 +3,7 @@
#include <cstring>
#include <QByteArray>
#include <QContextMenuEvent>
#include <QCoreApplication>
#include <QDataStream>
#include <QDrag>
@ -178,22 +179,46 @@ void TabBar::dragEnterEvent(QDragEnterEvent *e) {
}
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.
// Dropping a tear-off on a tab bar cancels the tear-off. Two cases:
// - dropped on the originating bar (or anywhere we can't decode):
// mark m_dropHandled so the source's startTearOff loop drops
// the tab back into place.
// - dropped on a *different* window's bar: cancel the tear-off
// AND ask the parent TabWidget to adopt the source tab into
// this window. macOS + GTK both support cross-window tab
// adoption this way.
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
TabBar *origin = decodeOrigin(
e->mimeData()->data(QString::fromLatin1(kTearOffOriginRole)));
if (origin) origin->m_dropHandled = true;
else m_dropHandled = true;
if (origin && origin != this) {
// Cross-window adoption. The parent QTabWidget signal carries
// the origin bar; the MainWindow on the receiving side resolves
// it to a source window + page and calls adoptTab. We emit
// through the TabWidget so MainWindow only listens at one site.
if (auto *tw = qobject_cast<TabWidget *>(parentWidget()))
emit tw->tabAdoptRequested(origin);
}
e->acceptProposedAction();
}
}
void TabBar::contextMenuEvent(QContextMenuEvent *e) {
// Find which tab the right-click landed on; if it missed every
// tab, do nothing (no menu over empty bar area). globalPos() is
// ready for QMenu::exec on the parent side.
const int idx = tabAt(e->pos());
if (idx < 0) return;
emit tabContextMenuRequested(idx, e->globalPos());
e->accept();
}
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);
connect(bar, &TabBar::tabContextMenuRequested,
this, &TabWidget::tabContextMenuRequested);
}

View File

@ -39,6 +39,18 @@ public:
signals:
// The tab was dragged off and released clear of its window.
void tabTornOff(int index);
// A tear-off from a *different* window's bar was dropped onto this
// one. `originBar` is the source TabBar; the receiving MainWindow
// looks up the originating window/page and adopts it. Both
// pointers stay valid for the duration of the signal handler —
// the drag's nested event loop has just exited and the source
// window can't have been deleted mid-emit.
void tabAdoptRequested(TabBar *originBar);
// The user right-clicked a tab; the parent should show a context
// menu (Close / Close Others / Close Tabs to the Right / Rename).
// index is the tab index under the click; globalPos is screen-
// space and ready to pass to QMenu::exec.
void tabContextMenuRequested(int index, const QPoint &globalPos);
protected:
void mousePressEvent(QMouseEvent *) override;
@ -47,6 +59,9 @@ protected:
// Accept a tear-off drag dropped back on a tab bar (cancels it).
void dragEnterEvent(QDragEnterEvent *) override;
void dropEvent(QDropEvent *) override;
// Right-click on a tab → emit tabContextMenuRequested. Matches
// macOS+GTK's tab context menus.
void contextMenuEvent(QContextMenuEvent *) 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
@ -74,4 +89,6 @@ public:
signals:
void tabTornOff(int index);
void tabAdoptRequested(TabBar *originBar);
void tabContextMenuRequested(int index, const QPoint &globalPos);
};

202
qt/src/XkbTracker.cpp Normal file
View File

@ -0,0 +1,202 @@
#include "XkbTracker.h"
#include <sys/mman.h>
#include <unistd.h>
#include <cstdio>
#include <cstring>
#include <QGuiApplication>
#include <qpa/qplatformnativeinterface.h>
#include <wayland-client.h>
#include <xkbcommon/xkbcommon.h>
namespace {
// Listener structs assembled from the static thunks below.
const wl_keyboard_listener kKeyboardListener = {
&XkbTracker::onKeymap, &XkbTracker::onEnter, &XkbTracker::onLeave,
&XkbTracker::onKey, &XkbTracker::onModifiers,
&XkbTracker::onRepeatInfo,
};
const wl_seat_listener kSeatListener = {
&XkbTracker::onSeatCapabilities, &XkbTracker::onSeatName,
};
const wl_registry_listener kRegistryListener = {
&XkbTracker::onRegistryGlobal, &XkbTracker::onRegistryGlobalRemove,
};
} // namespace
XkbTracker *XkbTracker::instance() {
// Singleton initialised on first call. If Wayland binding fails
// (e.g. running under XWayland with no exposed wl_seat) we return
// a non-null tracker that simply has no state — callers handle a
// null xkb_state gracefully.
static XkbTracker self;
return &self;
}
XkbTracker::XkbTracker() {
m_ctx = xkb_context_new(XKB_CONTEXT_NO_FLAGS);
if (!m_ctx) return;
QPlatformNativeInterface *native =
QGuiApplication::platformNativeInterface();
if (!native) return;
auto *display = static_cast<wl_display *>(
native->nativeResourceForIntegration("wl_display"));
if (!display) return;
// Enumerate the registry on a private event queue so we don't
// disturb Qt's own queue. After we find wl_seat and get the
// wl_keyboard, the keyboard proxy is moved back to the default
// queue so Qt's event loop drives our listener callbacks.
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, this);
wl_display_roundtrip_queue(display, queue);
wl_registry_destroy(registry);
// Roundtrip again to receive seat capabilities and pick up the
// wl_keyboard; the registry pass only binds the seat.
if (m_keyboard == nullptr)
wl_display_roundtrip_queue(display, queue);
// The keyboard proxy is hot — move it onto the default queue so
// Qt's event loop dispatches our listeners alongside Qt's own
// input events.
if (m_keyboard) {
wl_proxy_set_queue(reinterpret_cast<wl_proxy *>(m_keyboard), nullptr);
}
wl_event_queue_destroy(queue);
}
XkbTracker::~XkbTracker() {
// Process-wide singleton; OS reclaims at exit. Explicit teardown
// keeps leak checkers quiet and documents ownership.
if (m_keyboard) wl_keyboard_destroy(m_keyboard);
if (m_state) xkb_state_unref(m_state);
if (m_keymap) xkb_keymap_unref(m_keymap);
if (m_ctx) xkb_context_unref(m_ctx);
}
bool XkbTracker::capsLockOn() const {
if (m_idxCapsLock == ~0u) return false;
return (m_modsLocked & (1u << m_idxCapsLock)) != 0;
}
bool XkbTracker::numLockOn() const {
if (m_idxNumLock == ~0u) return false;
return (m_modsLocked & (1u << m_idxNumLock)) != 0;
}
// --- Registry / seat binding ----------------------------------------
void XkbTracker::onRegistryGlobal(void *data, wl_registry *registry,
uint32_t name, const char *interface,
uint32_t /*version*/) {
auto *self = static_cast<XkbTracker *>(data);
if (std::strcmp(interface, wl_seat_interface.name) != 0) return;
// Bind the seat at version 5 (which exposes seat name + the
// listener callbacks we need). If the compositor advertises an
// older version, the bind silently downgrades; we only need
// capabilities in either case.
auto *seat = static_cast<wl_seat *>(
wl_registry_bind(registry, name, &wl_seat_interface, 5));
if (!seat) return;
// Subscribe to capability changes; we'll grab the keyboard from
// the capability callback once the seat tells us it has one.
wl_seat_add_listener(seat, &kSeatListener, self);
}
void XkbTracker::onRegistryGlobalRemove(void *, wl_registry *, uint32_t) {}
void XkbTracker::onSeatCapabilities(void *data, wl_seat *seat,
uint32_t capabilities) {
auto *self = static_cast<XkbTracker *>(data);
const bool hasKbd = (capabilities & WL_SEAT_CAPABILITY_KEYBOARD) != 0;
if (hasKbd && !self->m_keyboard) {
self->m_keyboard = wl_seat_get_keyboard(seat);
if (self->m_keyboard)
wl_keyboard_add_listener(self->m_keyboard, &kKeyboardListener, self);
} else if (!hasKbd && self->m_keyboard) {
wl_keyboard_destroy(self->m_keyboard);
self->m_keyboard = nullptr;
}
}
void XkbTracker::onSeatName(void *, wl_seat *, const char *) {}
// --- wl_keyboard listeners ------------------------------------------
void XkbTracker::onKeymap(void *data, wl_keyboard * /*kb*/, uint32_t format,
int32_t fd, uint32_t size) {
auto *self = static_cast<XkbTracker *>(data);
// We can only handle XKB v1 keymaps. Anything else is a Wayland
// protocol extension we don't support; close the FD and bail.
if (format != WL_KEYBOARD_KEYMAP_FORMAT_XKB_V1) {
close(fd);
return;
}
// mmap the keymap text and feed it to xkb. MAP_PRIVATE so writes
// don't propagate; PROT_READ is enough.
void *map = mmap(nullptr, size, PROT_READ, MAP_PRIVATE, fd, 0);
if (map == MAP_FAILED) {
std::fprintf(stderr, "[ghastty] xkb keymap mmap failed\n");
close(fd);
return;
}
xkb_keymap *km = xkb_keymap_new_from_string(
self->m_ctx, static_cast<const char *>(map),
XKB_KEYMAP_FORMAT_TEXT_V1, XKB_KEYMAP_COMPILE_NO_FLAGS);
munmap(map, size);
close(fd);
if (!km) {
std::fprintf(stderr, "[ghastty] xkb keymap compile failed\n");
return;
}
// Replace the previous keymap+state. Anything that captured the
// old xkb_state* must use XkbTracker::state() each call rather
// than caching the pointer — we document that at the call site.
if (self->m_state) {
xkb_state_unref(self->m_state);
self->m_state = nullptr;
}
if (self->m_keymap) xkb_keymap_unref(self->m_keymap);
self->m_keymap = km;
self->m_state = xkb_state_new(km);
self->m_idxCapsLock = xkb_keymap_mod_get_index(km, XKB_MOD_NAME_CAPS);
self->m_idxNumLock = xkb_keymap_mod_get_index(km, XKB_MOD_NAME_NUM);
self->m_modsLocked = 0;
self->m_group = 0;
}
void XkbTracker::onEnter(void *, wl_keyboard *, uint32_t, wl_surface *,
wl_array *) {}
void XkbTracker::onLeave(void *, wl_keyboard *, uint32_t, wl_surface *) {}
void XkbTracker::onKey(void *, wl_keyboard *, uint32_t, uint32_t, uint32_t,
uint32_t) {
// Qt delivers key events; we don't want to double-process here.
}
void XkbTracker::onModifiers(void *data, wl_keyboard *, uint32_t,
uint32_t mods_depressed, uint32_t mods_latched,
uint32_t mods_locked, uint32_t group) {
auto *self = static_cast<XkbTracker *>(data);
if (!self->m_state) return;
// Keep the live state in sync so xkb_state_key_get_one_sym (used
// for unshifted_codepoint) and xkb_state_key_get_consumed_mods2
// see the active layout group and locked-modifier mask.
xkb_state_update_mask(self->m_state, mods_depressed, mods_latched,
mods_locked, 0, 0, group);
self->m_modsLocked = mods_locked;
self->m_group = group;
}
void XkbTracker::onRepeatInfo(void *, wl_keyboard *, int32_t, int32_t) {}

99
qt/src/XkbTracker.h Normal file
View File

@ -0,0 +1,99 @@
#pragma once
#include <cstdint>
struct xkb_context;
struct xkb_keymap;
struct xkb_state;
// Tracks the user's live XKB state on Wayland: the active keymap, the
// effective layout group, and the locked modifier mask (Caps Lock,
// Num Lock).
//
// Qt does not expose any of this directly. We bind to the
// process-wide wl_seat via the platform native interface, install a
// wl_keyboard listener, rebuild our xkb_keymap from the compositor's
// keymap FD on every keymap event, and keep an xkb_state synced via
// the modifiers event.
//
// Read access (modsLocked / activeGroup / xkbState) is from the GUI
// thread only — same as Qt's input event delivery — and these
// methods do not mutate state.
//
// Wayland-only: this file's symbols are referenced from
// GhosttySurface, which already runs only on Wayland.
class XkbTracker {
public:
// Process-wide singleton; returns nullptr if Wayland binding
// failed (e.g. running under XWayland with no wl_seat available
// through Qt).
static XkbTracker *instance();
// The live xkb_state. Owned by the tracker; do not unref. May be
// null if the compositor hasn't sent a keymap yet.
xkb_state *state() const { return m_state; }
// The live xkb_keymap from the compositor. Owned by the tracker;
// do not unref. Replaced on every keymap event from the
// compositor; consumers that cache derived state should compare
// pointer identity to detect rebuilds.
xkb_keymap *keymap() const { return m_keymap; }
// The shared xkb_context. Lives as long as the tracker (process
// lifetime).
xkb_context *ctx() const { return m_ctx; }
// True if Caps Lock is on right now.
bool capsLockOn() const;
// True if Num Lock is on right now.
bool numLockOn() const;
// The active layout group (0-based). 0 when the compositor hasn't
// sent a modifiers event yet.
uint32_t activeGroup() const { return m_group; }
// Listener entry points are public because they're addressed by C
// function pointer in the wl_keyboard_listener / wl_seat_listener
// / wl_registry_listener structs. They are not part of the public
// API; treat as internal.
static void onKeymap(void *data, struct wl_keyboard *kb, uint32_t format,
int32_t fd, uint32_t size);
static void onEnter(void *data, struct wl_keyboard *kb, uint32_t serial,
struct wl_surface *surface, struct wl_array *keys);
static void onLeave(void *data, struct wl_keyboard *kb, uint32_t serial,
struct wl_surface *surface);
static void onKey(void *data, struct wl_keyboard *kb, uint32_t serial,
uint32_t time, uint32_t key, uint32_t state);
static void onModifiers(void *data, struct wl_keyboard *kb, uint32_t serial,
uint32_t mods_depressed, uint32_t mods_latched,
uint32_t mods_locked, uint32_t group);
static void onRepeatInfo(void *data, struct wl_keyboard *kb, int32_t rate,
int32_t delay);
// Registry callbacks (used to find wl_seat).
static void onRegistryGlobal(void *data, struct wl_registry *registry,
uint32_t name, const char *interface,
uint32_t version);
static void onRegistryGlobalRemove(void *data, struct wl_registry *registry,
uint32_t name);
// wl_seat capability-changed callback.
static void onSeatCapabilities(void *data, struct wl_seat *seat,
uint32_t capabilities);
static void onSeatName(void *data, struct wl_seat *seat, const char *name);
private:
XkbTracker();
~XkbTracker();
XkbTracker(const XkbTracker &) = delete;
XkbTracker &operator=(const XkbTracker &) = delete;
xkb_context *m_ctx = nullptr;
xkb_keymap *m_keymap = nullptr;
xkb_state *m_state = nullptr;
uint32_t m_modsLocked = 0;
uint32_t m_group = 0;
// Indices into the keymap for the lock mods. XKB_MOD_INVALID until
// a keymap is loaded.
uint32_t m_idxCapsLock = ~0u;
uint32_t m_idxNumLock = ~0u;
// wl_keyboard handle, owned by us via wl_seat_get_keyboard.
struct wl_keyboard *m_keyboard = nullptr;
};

View File

@ -1,6 +1,7 @@
#include <cstdio>
#include <QApplication>
#include <QCoreApplication>
#include <QIcon>
#include <QSurfaceFormat>
@ -28,6 +29,15 @@ int main(int argc, char **argv) {
QApplication app(argc, argv);
// QSettings storage path keys: applicationName + organizationName.
// Used by the inspector window's geometry autosave (and any future
// QSettings-backed UI state) — the keys go to
// ~/.config/ghastty/ghastty.conf. We pass the same string to both
// because we don't run a multi-app suite under a parent
// organization.
QCoreApplication::setApplicationName(QStringLiteral("ghastty"));
QCoreApplication::setOrganizationName(QStringLiteral("ghastty"));
// 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.
@ -53,8 +63,12 @@ int main(int argc, char **argv) {
return 1;
}
// The first window; further windows are opened on demand by the
// new_window action. Each window owns itself (WA_DeleteOnClose).
// initial-window: when false, start headless (no window mapped at
// launch). Combined with quit-after-last-window-closed=false this
// is how a user runs ghastty as a daemon for the global quick-
// terminal shortcut. The first MainWindow::newWindow internally
// checks the config and skips show() — so the libghostty app +
// config still get built, but no QWindow ever appears.
if (!MainWindow::newWindow(nullptr)) {
std::fprintf(stderr, "[ghastty] window initialization failed\n");
return 1;