Merge pull request #2 from fuddlesworth/qt-parity-fixes
qt: full parity audit fixes vs macOS/GTKpull/12846/head
commit
3bd3188221
67
Dockerfile
67
Dockerfile
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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 |
|
|
@ -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 *) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue