Merge pull request #13 from fuddlesworth/qt-final-extracts

qt: phases 4-7 — UndoStack / QuickTerminal / BellPlayer / XkbState
pull/12846/head
Nathan 2026-05-23 15:47:27 -05:00 committed by GitHub
commit 38eaeb29c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 823 additions and 599 deletions

View File

@ -106,15 +106,19 @@ add_executable(ghastty
src/actions/TabActions.cpp
src/actions/WindowActions.cpp
src/app/GhosttyApp.cpp
src/bell/BellPlayer.cpp
src/config/Config.cpp
src/CommandPalette.cpp
src/GhosttySurface.cpp
src/GlobalShortcuts.cpp
src/InspectorWindow.cpp
src/input/XkbState.cpp
src/MainWindow.cpp
src/OverlayScrollbar.cpp
src/quickterm/QuickTerminal.cpp
src/SearchBar.cpp
src/TabWidget.cpp
src/undo/UndoStack.cpp
src/Util.cpp
src/WindowBlur.cpp
src/XkbTracker.cpp

View File

@ -1,13 +1,13 @@
#include "GhosttySurface.h"
#include "config/Config.h"
#include "input/XkbState.h"
#include "InspectorWindow.h"
#include "MainWindow.h"
#include "OverlayScrollbar.h"
#include "SearchBar.h"
#include "TabWidget.h"
#include "Util.h"
#include "XkbTracker.h"
#include <algorithm>
#include <cmath>
@ -49,8 +49,6 @@
#include <QUrl>
#include <QWheelEvent>
#include <xkbcommon/xkbcommon.h>
GhosttySurface::GhosttySurface(ghostty_app_t app, MainWindow *owner,
ghostty_surface_t parent_surface)
: m_app(app), m_owner(owner), m_parentSurface(parent_surface) {
@ -697,203 +695,6 @@ void GhosttySurface::premultiplyFramebuffer() {
// --- input ----------------------------------------------------------
// Wraps a libxkbcommon keymap + state derived from the system's XKB
// defaults (XKB_DEFAULT_LAYOUT etc.). We need this for two things:
//
// 1. The unshifted codepoint a key would produce with no modifiers —
// libghostty's kitty encoder uses it to find a key entry for
// printable keys (without it, punctuation falls into a fallback
// that mis-encodes release events).
//
// 2. Which modifiers the layout "consumed" to produce the event's
// text — e.g. Shift+; → ":" consumes Shift. The encoder uses this
// to decide between plain text and a modifier-bearing CSI; without
// it Shift+punctuation gets emitted as a kitty CSI the shell can't
// decode (Shift+letter happens to work because A-Z survive that
// path).
//
// THREAD SAFETY: this is a process singleton accessed only from the Qt
// GUI thread (Qt key events are dispatched there, and so is libghostty's
// inputMethodEvent forwarding). consumedMods mutates m_query, so a
// second thread would race; do not call from worker threads.
class XkbState {
public:
static XkbState &instance() {
static XkbState self;
return self;
}
// Level-0 (unshifted) Unicode codepoint for `keycode`, or 0 if the
// key has no associated UTF-32 (function keys, modifiers, etc.).
//
// 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);
if (sym == XKB_KEY_NoSymbol) return 0;
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)
depressed |= (1u << m_idxShift);
if ((mods & GHOSTTY_MODS_CTRL) && m_idxCtrl != XKB_MOD_INVALID)
depressed |= (1u << m_idxCtrl);
if ((mods & GHOSTTY_MODS_ALT) && m_idxAlt != XKB_MOD_INVALID)
depressed |= (1u << m_idxAlt);
if ((mods & GHOSTTY_MODS_SUPER) && m_idxSuper != XKB_MOD_INVALID)
depressed |= (1u << m_idxSuper);
// 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, group);
int r = GHOSTTY_MODS_NONE;
if (m_idxShift != XKB_MOD_INVALID && (consumed & (1u << m_idxShift)))
r |= GHOSTTY_MODS_SHIFT;
if (m_idxCtrl != XKB_MOD_INVALID && (consumed & (1u << m_idxCtrl)))
r |= GHOSTTY_MODS_CTRL;
if (m_idxAlt != XKB_MOD_INVALID && (consumed & (1u << m_idxAlt)))
r |= GHOSTTY_MODS_ALT;
if (m_idxSuper != XKB_MOD_INVALID && (consumed & (1u << m_idxSuper)))
r |= GHOSTTY_MODS_SUPER;
return static_cast<ghostty_input_mods_e>(r);
}
private:
// 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
// and documents the ownership chain.
if (m_query) xkb_state_unref(m_query);
if (m_unshifted) xkb_state_unref(m_unshifted);
if (m_keymap) xkb_keymap_unref(m_keymap);
if (m_fallbackKeymap) xkb_keymap_unref(m_fallbackKeymap);
}
XkbState(const XkbState &) = delete;
XkbState &operator=(const XkbState &) = delete;
// 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;
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) {
if (!m_surface) return;

View File

@ -6,7 +6,6 @@
#include <functional>
#include <QApplication>
#include <QAudioOutput>
#include <QClipboard>
#include <QCursor>
#include <QCloseEvent>
@ -22,22 +21,17 @@
#include <QPainter>
#include <QPalette>
#include <QPixmap>
#include <QMediaPlayer>
#include <QMenu>
#include <QMessageBox>
#include <QEasingCurve>
#include <QPoint>
#include <QPointer>
#include <QProcess>
#include <QPropertyAnimation>
#include <QPushButton>
#include <QStandardPaths>
#include <QRect>
#include <QScreen>
#include <QShowEvent>
#include <QSplitter>
#include <QStringList>
#include <QUrl>
#include <QString>
#include <QStyleHints>
#include <QTabBar>
@ -45,15 +39,15 @@
#include <QTimer>
#include <QVariant>
#include <QVBoxLayout>
#include <QWindow>
#include <LayerShellQt/window.h>
#include "app/GhosttyApp.h"
#include "bell/BellPlayer.h"
#include "config/Config.h"
#include "CommandPalette.h"
#include "GhosttySurface.h"
#include "quickterm/QuickTerminal.h"
#include "TabWidget.h"
#include "undo/UndoStack.h"
#include "Util.h"
#include "WindowBlur.h"
@ -442,11 +436,11 @@ void MainWindow::removeSurface(GhosttySurface *surface) {
const int index = m_tabs->indexOf(parent);
// Push to undo so a shell-exited tab close is symmetric with a
// user-initiated tab close (closeTab pushes too). Skip the last
// tab — its closeEvent runs pushWindowUndo and we don't want to
// tab — its closeEvent runs undo::pushWindow and we don't want to
// double-stack. Also skip the quick terminal (which doesn't push
// to either stack by design).
if (index >= 0 && m_tabs->count() > 1 && !m_quickTerminal)
pushTabUndo(index);
undo::pushTab(m_tabs->tabText(index));
if (index >= 0) m_tabs->removeTab(index);
if (parent) parent->deleteLater(); // page; destroys the surface too
// The surface close was already confirmed; don't re-prompt on the
@ -461,9 +455,10 @@ void MainWindow::closeTab(int index) {
QWidget *page = m_tabs->widget(index);
if (!page) return;
// Snapshot the tab's title for undo before we lose the reference.
// pushTabUndo is no-op for the last tab in a window — that close
// ends up triggering pushWindowUndo via closeEvent instead.
if (m_tabs->count() > 1 && !m_quickTerminal) pushTabUndo(index);
// undo::pushTab is no-op for the last tab in a window — that close
// ends up triggering undo::pushWindow via closeEvent instead.
if (m_tabs->count() > 1 && !m_quickTerminal)
undo::pushTab(m_tabs->tabText(index));
const auto inTab = page->findChildren<GhosttySurface *>();
for (GhosttySurface *s : inTab) m_surfaces.removeOne(s);
// If the zoomed surface was in this tab, clear the stash so a later
@ -655,7 +650,11 @@ void MainWindow::closeEvent(QCloseEvent *e) {
// Snapshot for undo. We push the window's full tab list so undo
// restores all of them; closeTab paths skip the per-tab push when
// they reach the last tab so we don't double-stack the same close.
pushWindowUndo();
QStringList titles;
titles.reserve(m_tabs->count());
for (int i = 0; i < m_tabs->count(); ++i)
titles << m_tabs->tabText(i);
undo::pushWindow(titles, geometry(), m_quickTerminal);
e->accept();
}
@ -759,170 +758,13 @@ MainWindow *MainWindow::makeQuickTerminal() {
delete w;
return nullptr;
}
w->setupLayerShell();
w->animateQuickTerminalIn();
quickterm::setupLayerShell(w);
quickterm::animateIn(w);
return w;
}
// Read quick-terminal-animation-duration (seconds) and convert to ms.
static int quickTerminalAnimationMs() {
double secs = 0.2; // matches Config.zig default
config::get(&secs, "quick-terminal-animation-duration");
// Clamp to a sane range so a misconfigured 0 or negative value
// doesn't make the window appear/disappear instantly without an
// animation, and a very large value doesn't lock the user out.
if (secs <= 0.0) return 0;
return std::clamp(static_cast<int>(secs * 1000.0), 1, 1000);
}
void MainWindow::animateQuickTerminalIn() {
setWindowOpacity(0.0);
show();
raise();
activateWindow();
const int ms = quickTerminalAnimationMs();
if (ms <= 0) {
setWindowOpacity(1.0);
return;
}
// Stop any running fade so toggling rapidly doesn't stack
// animations. The animation is parented to `this` so it dies
// with the window.
if (m_quickTerminalAnim) m_quickTerminalAnim->stop();
else m_quickTerminalAnim = new QPropertyAnimation(this, "windowOpacity", this);
m_quickTerminalAnim->setDuration(ms);
m_quickTerminalAnim->setStartValue(0.0);
m_quickTerminalAnim->setEndValue(1.0);
m_quickTerminalAnim->setEasingCurve(QEasingCurve::OutCubic);
m_quickTerminalAnim->start();
}
void MainWindow::animateQuickTerminalOut() {
const int ms = quickTerminalAnimationMs();
if (ms <= 0) {
hide();
return;
}
if (m_quickTerminalAnim) m_quickTerminalAnim->stop();
else m_quickTerminalAnim = new QPropertyAnimation(this, "windowOpacity", this);
m_quickTerminalAnim->setDuration(ms);
m_quickTerminalAnim->setStartValue(windowOpacity());
m_quickTerminalAnim->setEndValue(0.0);
m_quickTerminalAnim->setEasingCurve(QEasingCurve::InCubic);
// Disconnect any previous handler before reconnecting; otherwise a
// toggle-out-then-in cycle accumulates handlers that all fire on
// the next out.
disconnect(m_quickTerminalAnim, &QPropertyAnimation::finished,
this, nullptr);
connect(m_quickTerminalAnim, &QPropertyAnimation::finished, this,
[this]() { hide(); });
m_quickTerminalAnim->start();
}
void MainWindow::setupLayerShell() {
// LayerShellQt attaches to the native window; force it into being.
winId();
QWindow *handle = windowHandle();
if (!handle) return;
LayerShellQt::Window *ls = LayerShellQt::Window::get(handle);
if (!ls) {
// The Qt frontend targets Wayland exclusively (the project
// builds against LayerShellQt for the dropdown). If we can't
// get a layer-shell handle the platform isn't supported — log
// and bail rather than silently degrading to a non-functional
// regular window.
std::fprintf(stderr,
"[ghastty] LayerShellQt::Window::get returned null; "
"the quick terminal needs a Wayland session with "
"wlr-layer-shell support (e.g. KWin, sway).\n");
return;
}
using LSW = LayerShellQt::Window;
ls->setLayer(LSW::LayerTop);
const QString ki = config::string("quick-terminal-keyboard-interactivity");
ls->setKeyboardInteractivity(
ki == QLatin1String("exclusive") ? LSW::KeyboardInteractivityExclusive
: ki == QLatin1String("none") ? LSW::KeyboardInteractivityNone
: LSW::KeyboardInteractivityOnDemand);
// quick-terminal-screen: pick which output to anchor on.
// `main` → primary screen.
// `mouse` → the screen the pointer is currently on.
// `macos-menu-bar` → macOS-only; falls through to primary on
// Linux.
// LayerShellQt 6.6+ exposes setScreen(QScreen*) on the layer-shell
// window directly; the older setScreenConfiguration is deprecated.
// Pass null to fall back to the QWindow's screen (LayerShellQt's
// documented default when neither setScreen nor
// setWantsToBeOnActiveScreen is set).
const QString screenMode = config::string("quick-terminal-screen");
QScreen *screen = nullptr;
if (screenMode == QLatin1String("mouse")) {
screen = QGuiApplication::screenAt(QCursor::pos());
} else if (screenMode == QLatin1String("main") ||
screenMode == QLatin1String("macos-menu-bar")) {
screen = QGuiApplication::primaryScreen();
}
ls->setScreen(screen);
if (!screen) screen = handle->screen();
// quick-terminal-space-behavior (`remain` / `move`) is intentionally
// not read: macOS controls whether the dropdown follows the active
// Space or pins to the one it was opened on, but Wayland's
// wlr-layer-shell has no equivalent — the compositor always renders
// the surface on the active workspace (KWin behaviour), which
// corresponds to `move`. Achieving `remain` would need a
// per-workspace pin that no mainstream compositor exposes; honour
// by no-op and document.
const QSize scr = screen ? screen->size() : QSize(1920, 1080);
// quick-terminal-size: primary is the edge-perpendicular extent.
ghostty_config_quick_terminal_size_s qsz = {};
config::get(&qsz, "quick-terminal-size");
const auto toPx = [](const ghostty_quick_terminal_size_s &s, int dim,
int fallback) -> int {
switch (s.tag) {
case GHOSTTY_QUICK_TERMINAL_SIZE_PERCENTAGE:
return static_cast<int>(s.value.percentage / 100.0f * dim);
case GHOSTTY_QUICK_TERMINAL_SIZE_PIXELS:
return static_cast<int>(s.value.pixels);
default:
return fallback;
}
};
const QString pos = config::string("quick-terminal-position");
LSW::Anchors anchors;
QSize size;
if (pos == QLatin1String("bottom")) {
anchors = LSW::Anchors(LSW::AnchorBottom) | LSW::AnchorLeft |
LSW::AnchorRight;
size = {scr.width(), toPx(qsz.primary, scr.height(), 400)};
} else if (pos == QLatin1String("left")) {
anchors = LSW::Anchors(LSW::AnchorLeft) | LSW::AnchorTop |
LSW::AnchorBottom;
size = {toPx(qsz.primary, scr.width(), 400), scr.height()};
} else if (pos == QLatin1String("right")) {
anchors = LSW::Anchors(LSW::AnchorRight) | LSW::AnchorTop |
LSW::AnchorBottom;
size = {toPx(qsz.primary, scr.width(), 400), scr.height()};
} else if (pos == QLatin1String("center")) {
anchors = LSW::Anchors(LSW::AnchorNone);
size = {toPx(qsz.primary, scr.width(), 800),
toPx(qsz.secondary, scr.height(), 400)};
} else { // top (the default)
anchors = LSW::Anchors(LSW::AnchorTop) | LSW::AnchorLeft |
LSW::AnchorRight;
size = {scr.width(), toPx(qsz.primary, scr.height(), 400)};
}
ls->setAnchors(anchors);
// The layer-shell protocol takes the size from the underlying
// wl_surface (i.e. the QWindow's size); LayerShellQt has no
// setDesiredSize on this Qt branch.
resize(size);
}
void MainWindow::animateQuickTerminalIn() { quickterm::animateIn(this); }
void MainWindow::animateQuickTerminalOut() { quickterm::animateOut(this); }
void MainWindow::changeEvent(QEvent *e) {
// quick-terminal-autohide: fade out the dropdown when it loses
@ -1180,7 +1022,7 @@ void MainWindow::ringBell(GhosttySurface *surface) {
config::bitfield("bell-features", BellAttention);
if (features & BellAttention) QApplication::alert(this);
if (features & BellSystem) QApplication::beep();
if (features & BellAudio) playBellAudio();
if (features & BellAudio && m_bellPlayer) m_bellPlayer->play();
if (!surface) return;
if (features & BellBorder) surface->flashBorder();
@ -1216,22 +1058,6 @@ void MainWindow::updateTabText(int tab) {
setWindowTitle(text + QStringLiteral(" — Ghastty"));
}
void MainWindow::playBellAudio() {
if (m_bellAudioPath.isEmpty()) return;
if (!m_bellPlayer) {
m_bellAudio = new QAudioOutput(this);
m_bellPlayer = new QMediaPlayer(this);
m_bellPlayer->setAudioOutput(m_bellAudio);
}
m_bellAudio->setVolume(m_bellAudioVolume);
// Stop first so a back-to-back bell restarts the clip from the
// beginning. Without this, calling play() on an already-playing
// QMediaPlayer is a no-op and rapid bells get silently swallowed.
m_bellPlayer->stop();
m_bellPlayer->setSource(QUrl::fromLocalFile(m_bellAudioPath));
m_bellPlayer->play();
}
// Refresh every window's chrome from the current GhosttyApp config: tab-bar
// policy, colour scheme, blur — plus window-level state that
// previously only applied at startup (window-decoration, fullscreen,
@ -1444,147 +1270,22 @@ void MainWindow::setCellSize(uint32_t w, uint32_t h) {
setSizeIncrement(0, 0); // back to pixel-precise
}
// Process-wide undo state — see MainWindow.h.
QList<MainWindow::UndoEntry> MainWindow::s_undoStack;
QList<MainWindow::UndoEntry> MainWindow::s_redoStack;
bool MainWindow::s_redoInProgress = false;
void MainWindow::undoLastClose() { undo::undoLast(); }
void MainWindow::redoLastClose() { undo::redoLast(); }
// Snapshot the tab at `index` (its tab text — last-known title) onto
// the undo stack. Called from closeTab / closeTabsByMode / right
// before the tab is removed. No-op while a redo is replaying.
void MainWindow::pushTabUndo(int index) {
if (s_redoInProgress) return;
if (index < 0 || index >= m_tabs->count()) return;
UndoEntry e;
e.kind = UndoEntry::Kind::Tab;
e.pageTitles << m_tabs->tabText(index);
s_undoStack.append(std::move(e));
if (s_undoStack.size() > kUndoCap) s_undoStack.removeFirst();
// A fresh close invalidates any pending redo: the new "future" no
// longer matches what the redo stack would re-close.
s_redoStack.clear();
// Close the active tab without prompting. Called from
// undo::redoLast for a Tab redo: the user already accepted the
// original close, so re-closing carries the same prior consent.
void MainWindow::closeCurrentTabForRedo() {
const int idx = m_tabs->currentIndex();
if (idx >= 0) closeTab(idx);
}
// Snapshot every tab in this window before it goes away. Called from
// closeAllWindows and from closeEvent for the user-driven X. No-op
// while a redo is replaying.
void MainWindow::pushWindowUndo() {
if (s_redoInProgress) return;
if (m_quickTerminal || m_tabs->count() == 0) return;
UndoEntry e;
e.kind = UndoEntry::Kind::Window;
for (int i = 0; i < m_tabs->count(); ++i)
e.pageTitles << m_tabs->tabText(i);
e.geometry = geometry();
s_undoStack.append(std::move(e));
if (s_undoStack.size() > kUndoCap) s_undoStack.removeFirst();
s_redoStack.clear();
}
// Pop the most recent undo entry and revive it. A new tab/window is
// opened that inherits cwd from the active surface (libghostty
// supplies the cwd via inherited_config), and the saved title is
// reapplied as a manual tab-title override so it persists across
// shell prompts.
void MainWindow::undoLastClose() {
if (s_undoStack.isEmpty()) return;
const UndoEntry e = s_undoStack.takeLast();
// The active window picks the new tab's parent surface for cwd
// inheritance. Skip the quick terminal — it doesn't push undo
// entries and isn't a meaningful target. Fall back to the most
// recent regular window in registration order.
auto isUndoTarget = [](MainWindow *w) {
return w && !w->m_quickTerminal;
};
MainWindow *active = qobject_cast<MainWindow *>(qApp->activeWindow());
if (!isUndoTarget(active)) {
active = nullptr;
const QList<MainWindow *> &live = GhosttyApp::instance().windows();
for (int i = live.size() - 1; i >= 0; --i) {
if (isUndoTarget(live.at(i))) {
active = live.at(i);
break;
}
}
}
GhosttySurface *parent = active
? active->surfaceAt(active->m_tabs->currentIndex())
: nullptr;
if (e.kind == UndoEntry::Kind::Tab) {
if (!active) return;
GhosttySurface *s = active->newTab(parent ? parent->surface() : nullptr);
if (s && !e.pageTitles.isEmpty())
active->setTabTitleOverride(s, e.pageTitles.first());
} else {
// Window: open a fresh window, then add additional tabs to match
// the saved tab count. We don't try to recreate the split tree
// — that would require a real session save mechanism.
MainWindow *w = MainWindow::newWindow(parent ? parent->surface() : nullptr);
if (!w) return;
if (e.geometry.isValid()) w->setGeometry(e.geometry);
// Title for the (eventually created) first tab.
if (!e.pageTitles.isEmpty()) {
const QString first = e.pageTitles.first();
QPointer<MainWindow> wp(w);
QTimer::singleShot(0, w, [wp, first]() {
if (!wp) return;
if (auto *s = wp->surfaceAt(0)) wp->setTabTitleOverride(s, first);
});
}
// Additional tabs for the rest of the saved set.
for (int i = 1; i < e.pageTitles.size(); ++i) {
const QString t = e.pageTitles.at(i);
QPointer<MainWindow> wp(w);
QTimer::singleShot(0, w, [wp, t]() {
if (!wp) return;
GhosttySurface *s =
wp->newTab(wp->surfaceAt(0) ? wp->surfaceAt(0)->surface() : nullptr);
if (s) wp->setTabTitleOverride(s, t);
});
}
}
s_redoStack.append(e);
if (s_redoStack.size() > kUndoCap) s_redoStack.removeFirst();
}
// Redo: re-close whatever undo just opened. We don't have a handle on
// the revived widgets so we close the active window's current tab
// (or the active window itself for a Window entry); pragmatic,
// matches what a user normally means by "redo close-tab".
//
// pushTabUndo / pushWindowUndo are no-ops while s_redoInProgress is
// true, so the close paths below don't:
// (a) clear s_redoStack — preserving the rest of the redo chain
// so a sequence of REDOs works.
// (b) push a fresh undo entry — a redo that re-closes shouldn't
// feed itself a new undo or the user can ping-pong undo/redo
// on a single past close indefinitely.
void MainWindow::redoLastClose() {
if (s_redoStack.isEmpty()) return;
UndoEntry e = s_redoStack.takeLast();
MainWindow *active = qobject_cast<MainWindow *>(qApp->activeWindow());
if (!active) {
const QList<MainWindow *> &live = GhosttyApp::instance().windows();
if (!live.isEmpty()) active = live.last();
}
if (!active) {
// No window to act on — restore the entry so the user can retry.
s_redoStack.append(std::move(e));
return;
}
s_redoInProgress = true;
if (e.kind == UndoEntry::Kind::Tab) {
const int idx = active->m_tabs->currentIndex();
if (idx >= 0) active->closeTab(idx);
} else {
active->m_skipCloseConfirm = true;
active->close();
}
s_redoInProgress = false;
// Close the entire window without re-prompting. Called from
// undo::redoLast for a Window redo.
void MainWindow::closeForRedo() {
m_skipCloseConfirm = true;
close();
}
void MainWindow::applyWindowConfig() {
@ -1604,16 +1305,10 @@ void MainWindow::applyWindowConfig() {
m_tabs->tabBar()->setVisible(m_tabs->count() > 1);
}
// bell-audio-path / -volume: cached on the window so playBellAudio
// doesn't re-scan the on-disk config on every bell. Refreshed on
// each applyWindowConfig (i.e. at init and on reload).
{
m_bellAudioPath = config::expandedPath("bell-audio-path");
bool volOk = false;
const double v =
config::diskValue("bell-audio-volume").toDouble(&volOk);
m_bellAudioVolume = volOk ? v : 0.5;
}
// bell-audio: BellPlayer caches the path/volume so the bell hot
// path doesn't re-scan the on-disk config on every ring.
if (!m_bellPlayer) m_bellPlayer = new BellPlayer(this);
m_bellPlayer->refreshFromConfig();
// window-title-font-family: apply to the tab bar (and the WM
// title via Qt's window-title system font is harder to override

View File

@ -1,20 +1,16 @@
#pragma once
#include <QList>
#include <QRect>
#include <QSize>
#include <QStringList>
#include <QWidget>
#include "ghostty.h"
class QAudioOutput;
class BellPlayer;
class QCloseEvent;
class QMediaPlayer;
class QShowEvent;
class QSplitter;
class TabWidget;
class QPropertyAnimation;
class QTimer;
class CommandPalette;
class GhosttySurface;
@ -76,17 +72,20 @@ public:
// need to take a dependency on app/GhosttyApp.h.
ghostty_config_t config() const;
// 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).
// UNDO / REDO close-tab/window action handlers — thin wrappers that
// drive undo::undoLast / undo::redoLast. The undo state lives in
// qt/src/undo/UndoStack; the comment there explains the lifetime
// and replay model.
static void undoLastClose();
static void redoLastClose();
// Replay-side hooks used by undo::redoLast. closeCurrentTabForRedo
// closes whichever tab is currently active (Tab redo); closeForRedo
// closes the entire window (Window redo). Both bypass the
// close-confirm prompt — the user already accepted the close once.
void closeCurrentTabForRedo();
void closeForRedo();
// PRESENT_TERMINAL: bring this window to front and focus the surface.
void presentTerminal(GhosttySurface *surface);
// GOTO_WINDOW: cycle to the previous/next window in registration order.
@ -154,6 +153,9 @@ public:
// First surface in the currently-visible tab, or nullptr. Used by
// PROMPT_TITLE app-target promotion.
GhosttySurface *currentSurface() const;
// First surface in the tab at `index`, or nullptr. Used by
// undo::undoLast to title-tag the revived tab/window.
GhosttySurface *surfaceAt(int index) const;
// Default size cached on INITIAL_SIZE for RESET_WINDOW_SIZE.
QSize defaultWindowSize() const { return m_defaultWindowSize; }
void setDefaultWindowSize(QSize s) { m_defaultWindowSize = s; }
@ -194,12 +196,9 @@ private:
void detachTab(int index);
// Move `page` (a tab and its surfaces) from `src` into this window.
void adoptTab(MainWindow *src, QWidget *page);
GhosttySurface *surfaceAt(int index) const;
int tabIndexForSurface(GhosttySurface *surface) const;
QList<GhosttySurface *> surfacesInTab(int index) const;
void playBellAudio();
// Bell `title` feature: prefix a tab's title while any surface in it
// has an unacknowledged bell.
bool tabBellMarked(int tab) const;
@ -220,19 +219,12 @@ private:
// compositor (see WindowBlur).
void applyBlur();
// Turn this window into a layer-shell dropdown anchored to a screen
// edge, per the `quick-terminal-*` config. Quick-terminal only.
void setupLayerShell();
TabWidget *m_tabs = nullptr;
QList<GhosttySurface *> m_surfaces; // every live surface in this window
bool m_firstTabPending = true; // first tab is created on show()
ghostty_surface_t m_firstTabParent = nullptr; // inherited by the 1st tab
bool m_skipCloseConfirm = false; // close already confirmed elsewhere
bool m_quickTerminal = false; // this is the dropdown quick terminal
// 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
@ -254,33 +246,8 @@ private:
// GhosttyApp::instance(). MainWindow's config() / needsPremultiply()
// accessors forward to it.
// 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();
// The undo/redo stacks live in qt/src/undo/UndoStack — see comment
// there for the lifecycle and replay semantics.
// Wakeup tick coalescing lives on GhosttyApp::m_tickPending.
@ -291,14 +258,10 @@ private:
QSplitter *m_zoomSplitter = nullptr;
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;
// Bell audio playback; lives in qt/src/bell/BellPlayer. Created
// lazily on the first applyWindowConfig pass so refreshFromConfig
// can prime its cached path/volume.
BellPlayer *m_bellPlayer = nullptr;
// The command palette; created lazily on first use.
CommandPalette *m_commandPalette = nullptr;

View File

@ -0,0 +1,34 @@
#include "BellPlayer.h"
#include <QAudioOutput>
#include <QMediaPlayer>
#include <QUrl>
#include "../config/Config.h"
BellPlayer::BellPlayer(QObject *parent) : QObject(parent) {}
BellPlayer::~BellPlayer() = default;
void BellPlayer::play() {
if (m_path.isEmpty()) return;
if (!m_player) {
m_audio = new QAudioOutput(this);
m_player = new QMediaPlayer(this);
m_player->setAudioOutput(m_audio);
}
m_audio->setVolume(m_volume);
// Stop first so a back-to-back bell restarts the clip from the
// beginning. Without this, calling play() on an already-playing
// QMediaPlayer is a no-op and rapid bells get silently swallowed.
m_player->stop();
m_player->setSource(QUrl::fromLocalFile(m_path));
m_player->play();
}
void BellPlayer::refreshFromConfig() {
m_path = config::expandedPath("bell-audio-path");
bool volOk = false;
const double v = config::diskValue("bell-audio-volume").toDouble(&volOk);
m_volume = volOk ? v : 0.5;
}

40
qt/src/bell/BellPlayer.h Normal file
View File

@ -0,0 +1,40 @@
#pragma once
#include <QObject>
#include <QString>
class QAudioOutput;
class QMediaPlayer;
// Per-window audio bell. Owns a QMediaPlayer + QAudioOutput pair and
// caches the bell-audio-path / -volume values so the bell hot path
// doesn't re-scan the on-disk config on every ring. Built lazily on
// first play() — no QMediaPlayer is allocated until a bell actually
// fires, matching the prior MainWindow behaviour.
//
// Parented to the owning window so it dies with it. Each window
// keeps its own player so KWin can route per-window audio
// independently if the user wires that up.
class BellPlayer : public QObject {
Q_OBJECT
public:
explicit BellPlayer(QObject *parent);
~BellPlayer() override;
// Play the configured clip, restarting from the beginning if a
// previous play is still in flight. No-op if no clip is
// configured. The audio output volume is whatever
// refreshFromConfig last cached.
void play();
// Re-read bell-audio-path / bell-audio-volume from the on-disk
// config and update the cache. Called from
// applyWindowConfig (init + reload).
void refreshFromConfig();
private:
QString m_path;
double m_volume = 0.5;
QMediaPlayer *m_player = nullptr;
QAudioOutput *m_audio = nullptr;
};

137
qt/src/input/XkbState.cpp Normal file
View File

@ -0,0 +1,137 @@
#include "XkbState.h"
#include <xkbcommon/xkbcommon.h>
#include "../XkbTracker.h"
XkbState &XkbState::instance() {
static XkbState self;
return self;
}
XkbState::~XkbState() {
// Run on process exit when the static is destroyed. The OS would
// reclaim regardless, but explicit teardown silences leak checkers
// and documents the ownership chain.
if (m_query) xkb_state_unref(m_query);
if (m_unshifted) xkb_state_unref(m_unshifted);
if (m_keymap) xkb_keymap_unref(m_keymap);
if (m_fallbackKeymap) xkb_keymap_unref(m_fallbackKeymap);
}
uint32_t XkbState::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);
if (sym == XKB_KEY_NoSymbol) return 0;
return xkb_keysym_to_utf32(sym);
}
ghostty_input_mods_e XkbState::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);
}
ghostty_input_mods_e XkbState::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);
}
ghostty_input_mods_e XkbState::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)
depressed |= (1u << m_idxShift);
if ((mods & GHOSTTY_MODS_CTRL) && m_idxCtrl != XKB_MOD_INVALID)
depressed |= (1u << m_idxCtrl);
if ((mods & GHOSTTY_MODS_ALT) && m_idxAlt != XKB_MOD_INVALID)
depressed |= (1u << m_idxAlt);
if ((mods & GHOSTTY_MODS_SUPER) && m_idxSuper != XKB_MOD_INVALID)
depressed |= (1u << m_idxSuper);
// Use the live group from the tracker so a layout switch (e.g.
// us↔ru) takes effect immediately.
XkbTracker *t = XkbTracker::instance();
const uint32_t group = t ? t->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, group);
int r = GHOSTTY_MODS_NONE;
if (m_idxShift != XKB_MOD_INVALID && (consumed & (1u << m_idxShift)))
r |= GHOSTTY_MODS_SHIFT;
if (m_idxCtrl != XKB_MOD_INVALID && (consumed & (1u << m_idxCtrl)))
r |= GHOSTTY_MODS_CTRL;
if (m_idxAlt != XKB_MOD_INVALID && (consumed & (1u << m_idxAlt)))
r |= GHOSTTY_MODS_ALT;
if (m_idxSuper != XKB_MOD_INVALID && (consumed & (1u << m_idxSuper)))
r |= GHOSTTY_MODS_SUPER;
return static_cast<ghostty_input_mods_e>(r);
}
void XkbState::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());
}

83
qt/src/input/XkbState.h Normal file
View File

@ -0,0 +1,83 @@
#pragma once
#include <cstdint>
#include <xkbcommon/xkbcommon.h>
#include "ghostty.h"
// Wraps a libxkbcommon keymap + state derived from the live keymap
// XkbTracker syncs via wl_keyboard (with a fallback to the system
// XKB defaults until the compositor's keymap arrives). We need this
// for two things:
//
// 1. The unshifted codepoint a key would produce with no modifiers —
// libghostty's kitty encoder uses it to find a key entry for
// printable keys (without it, punctuation falls into a fallback
// that mis-encodes release events).
//
// 2. Which modifiers the layout "consumed" to produce the event's
// text — e.g. Shift+; → ":" consumes Shift. The encoder uses
// this to decide between plain text and a modifier-bearing CSI;
// without it Shift+punctuation gets emitted as a kitty CSI the
// shell can't decode (Shift+letter happens to work because A-Z
// survive that path).
//
// THREAD SAFETY: this is a process singleton accessed only from the
// Qt GUI thread (Qt key events are dispatched there, and so is
// libghostty's inputMethodEvent forwarding). consumedMods mutates
// internal state; do not call from worker threads.
class XkbState {
public:
static XkbState &instance();
// Level-0 (unshifted) Unicode codepoint for `keycode`, or 0 if the
// key has no associated UTF-32 (function keys, modifiers, etc.).
// Honors the active layout group from the live tracker so a us+ru
// user gets the correct codepoint per active group, not always us.
uint32_t unshiftedCodepoint(uint32_t keycode) const;
// Side bits for the libghostty mods bitfield, derived from a
// keycode — pressing Right-Shift sets BOTH the unsided
// GHOSTTY_MODS_SHIFT and 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;
// Caps Lock / Num Lock state from the live wl_keyboard tracker.
ghostty_input_mods_e lockMods() const;
// Modifiers consumed by the layout to produce `keycode`'s text
// given `mods` are depressed. Returns the consumed subset
// expressed as ghostty mod bits.
ghostty_input_mods_e consumedMods(uint32_t keycode,
ghostty_input_mods_e mods) const;
XkbState(const XkbState &) = delete;
XkbState &operator=(const XkbState &) = delete;
private:
XkbState() = default;
~XkbState();
// Build / rebuild the derived states from the live keymap. Cheap
// when the keymap pointer is unchanged (one comparison + return).
void syncFromTracker() const;
// The keymap our derived states were built from. A ref taken in
// syncFromTracker (released on rebuild and in dtor) keeps the xkb
// allocator from freeing + reusing 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;
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;
};

View File

@ -0,0 +1,213 @@
#include "QuickTerminal.h"
#include <algorithm>
#include <cstdio>
#include <QCursor>
#include <QEasingCurve>
#include <QGuiApplication>
#include <QPropertyAnimation>
#include <QScreen>
#include <QSize>
#include <QString>
#include <QStringLiteral>
#include <QWidget>
#include <QWindow>
#include <LayerShellQt/window.h>
#include "../config/Config.h"
#include "ghostty.h"
namespace quickterm {
namespace {
// Anim and toggle live on the QObject child tree of `window`, so
// they die with it. We keep the QPropertyAnimation as a dynamic
// property so callers don't need to thread it through. The "_q_"
// underscore-prefix space is reserved by Qt; any other prefix is
// fine and the dotted form keeps it visibly application-scoped.
constexpr const char *kAnimProperty = "ghastty.quickterm.anim";
// Read quick-terminal-animation-duration (seconds) and convert to ms.
// Clamps to a sane range so a misconfigured 0/negative value doesn't
// make the window appear/disappear instantly without an animation,
// and a very large value doesn't lock the user out.
int animationMs() {
double secs = 0.2; // matches Config.zig default
config::get(&secs, "quick-terminal-animation-duration");
if (secs <= 0.0) return 0;
return std::clamp(static_cast<int>(secs * 1000.0), 1, 1000);
}
// Lazily fetch (or build) the per-window opacity animation, parented
// to `window` so its lifetime tracks the widget's.
QPropertyAnimation *animFor(QWidget *window) {
auto *existing = window->property(kAnimProperty).value<QPropertyAnimation *>();
if (existing) return existing;
auto *anim = new QPropertyAnimation(window, "windowOpacity", window);
window->setProperty(kAnimProperty,
QVariant::fromValue<QPropertyAnimation *>(anim));
return anim;
}
} // namespace
void setupLayerShell(QWidget *window) {
// LayerShellQt attaches to the native window; force it into being.
window->winId();
QWindow *handle = window->windowHandle();
if (!handle) return;
LayerShellQt::Window *ls = LayerShellQt::Window::get(handle);
if (!ls) {
// The Qt frontend targets Wayland exclusively (the project
// builds against LayerShellQt for the dropdown). If we can't
// get a layer-shell handle the platform isn't supported — log
// and bail rather than silently degrading to a non-functional
// regular window.
std::fprintf(stderr,
"[ghastty] LayerShellQt::Window::get returned null; "
"the quick terminal needs a Wayland session with "
"wlr-layer-shell support (e.g. KWin, sway).\n");
return;
}
using LSW = LayerShellQt::Window;
ls->setLayer(LSW::LayerTop);
const QString ki = config::string("quick-terminal-keyboard-interactivity");
ls->setKeyboardInteractivity(
ki == QLatin1String("exclusive") ? LSW::KeyboardInteractivityExclusive
: ki == QLatin1String("none") ? LSW::KeyboardInteractivityNone
: LSW::KeyboardInteractivityOnDemand);
// quick-terminal-screen: pick which output to anchor on.
// `main` → primary screen.
// `mouse` → the screen the pointer is currently on.
// `macos-menu-bar` → macOS-only; falls through to primary on
// Linux.
// LayerShellQt 6.6+ exposes setScreen(QScreen*) on the layer-shell
// window directly; the older setScreenConfiguration is deprecated.
// Pass null to fall back to the QWindow's screen (LayerShellQt's
// documented default when neither setScreen nor
// setWantsToBeOnActiveScreen is set).
const QString screenMode = config::string("quick-terminal-screen");
QScreen *screen = nullptr;
if (screenMode == QLatin1String("mouse")) {
screen = QGuiApplication::screenAt(QCursor::pos());
} else if (screenMode == QLatin1String("main") ||
screenMode == QLatin1String("macos-menu-bar")) {
screen = QGuiApplication::primaryScreen();
}
ls->setScreen(screen);
// For sizing only — LayerShellQt already has the anchor screen above
// (or fell back to the QWindow's screen via setScreen(nullptr)). We
// need a non-null QScreen below to read its pixel dimensions.
if (!screen) screen = handle->screen();
// quick-terminal-space-behavior (`remain` / `move`) is intentionally
// not read: macOS controls whether the dropdown follows the active
// Space or pins to the one it was opened on, but Wayland's
// wlr-layer-shell has no equivalent — the compositor always renders
// the surface on the active workspace (KWin behaviour), which
// corresponds to `move`. Achieving `remain` would need a
// per-workspace pin that no mainstream compositor exposes; honour
// by no-op and document.
const QSize scr = screen ? screen->size() : QSize(1920, 1080);
// quick-terminal-size: primary is the edge-perpendicular extent.
ghostty_config_quick_terminal_size_s qsz = {};
config::get(&qsz, "quick-terminal-size");
const auto toPx = [](const ghostty_quick_terminal_size_s &s, int dim,
int fallback) -> int {
switch (s.tag) {
case GHOSTTY_QUICK_TERMINAL_SIZE_PERCENTAGE:
return static_cast<int>(s.value.percentage / 100.0f * dim);
case GHOSTTY_QUICK_TERMINAL_SIZE_PIXELS:
return static_cast<int>(s.value.pixels);
default:
return fallback;
}
};
const QString pos = config::string("quick-terminal-position");
LSW::Anchors anchors;
QSize size;
if (pos == QLatin1String("bottom")) {
anchors = LSW::Anchors(LSW::AnchorBottom) | LSW::AnchorLeft |
LSW::AnchorRight;
size = {scr.width(), toPx(qsz.primary, scr.height(), 400)};
} else if (pos == QLatin1String("left")) {
anchors = LSW::Anchors(LSW::AnchorLeft) | LSW::AnchorTop |
LSW::AnchorBottom;
size = {toPx(qsz.primary, scr.width(), 400), scr.height()};
} else if (pos == QLatin1String("right")) {
anchors = LSW::Anchors(LSW::AnchorRight) | LSW::AnchorTop |
LSW::AnchorBottom;
size = {toPx(qsz.primary, scr.width(), 400), scr.height()};
} else if (pos == QLatin1String("center")) {
anchors = LSW::Anchors(LSW::AnchorNone);
size = {toPx(qsz.primary, scr.width(), 800),
toPx(qsz.secondary, scr.height(), 400)};
} else { // top (the default)
anchors = LSW::Anchors(LSW::AnchorTop) | LSW::AnchorLeft |
LSW::AnchorRight;
size = {scr.width(), toPx(qsz.primary, scr.height(), 400)};
}
ls->setAnchors(anchors);
// The layer-shell protocol takes the size from the underlying
// wl_surface (i.e. the QWindow's size); LayerShellQt has no
// setDesiredSize on this Qt branch.
window->resize(size);
}
void animateIn(QWidget *window) {
window->setWindowOpacity(0.0);
window->show();
window->raise();
window->activateWindow();
const int ms = animationMs();
if (ms <= 0) {
window->setWindowOpacity(1.0);
return;
}
// Stop any running fade so toggling rapidly doesn't stack
// animations.
QPropertyAnimation *anim = animFor(window);
anim->stop();
// animateOut leaves a `finished -> hide()` handler attached to the
// shared animation object. If a fade-out was interrupted by this
// fade-in (rapid out/in cycle), the leftover handler would fire at
// the end of the in-fade and silently hide the just-revealed
// window — clear it before starting.
QObject::disconnect(anim, &QPropertyAnimation::finished, window, nullptr);
anim->setDuration(ms);
anim->setStartValue(0.0);
anim->setEndValue(1.0);
anim->setEasingCurve(QEasingCurve::OutCubic);
anim->start();
}
void animateOut(QWidget *window) {
const int ms = animationMs();
if (ms <= 0) {
window->hide();
return;
}
QPropertyAnimation *anim = animFor(window);
anim->stop();
anim->setDuration(ms);
anim->setStartValue(window->windowOpacity());
anim->setEndValue(0.0);
anim->setEasingCurve(QEasingCurve::InCubic);
// Disconnect any previous handler before reconnecting; otherwise a
// toggle-out-then-in cycle accumulates handlers that all fire on
// the next out.
QObject::disconnect(anim, &QPropertyAnimation::finished, window, nullptr);
QObject::connect(anim, &QPropertyAnimation::finished, window,
[window]() { window->hide(); });
anim->start();
}
} // namespace quickterm

View File

@ -0,0 +1,34 @@
#pragma once
class QWidget;
// Free functions that drive the dropdown quick-terminal window: a
// wlr-layer-shell anchored surface, faded in/out via windowOpacity.
// `window` is the QWidget hosting the layer-shell surface
// (MainWindow with m_quickTerminal == true).
//
// The animation is parented to `window`'s child tree, so it dies
// with the window.
namespace quickterm {
// Configure the layer-shell anchor, screen, keyboard interactivity,
// and size from the `quick-terminal-*` config keys. Logs and bails
// when LayerShellQt isn't available — the Qt frontend is Wayland-
// only, so a missing layer-shell surface is a runtime configuration
// error, not a portability fallback.
void setupLayerShell(QWidget *window);
// Fade the window in: opacity 0 → 1 over
// `quick-terminal-animation-duration` seconds. show()/raise()/
// activateWindow() are called up front so the user gets the focus
// during the fade. A duration of 0 collapses to an immediate
// setWindowOpacity(1.0).
void animateIn(QWidget *window);
// Fade the window out and hide() on completion. Disconnects any
// previous `finished` handler before reconnecting so a rapid
// in/out/in cycle doesn't pile up handlers that all fire on the
// next `out`.
void animateOut(QWidget *window);
} // namespace quickterm

166
qt/src/undo/UndoStack.cpp Normal file
View File

@ -0,0 +1,166 @@
#include "UndoStack.h"
#include <QApplication>
#include <QList>
#include <QPointer>
#include <QTimer>
#include "../app/GhosttyApp.h"
#include "../GhosttySurface.h"
#include "../MainWindow.h"
namespace undo {
namespace {
// Bounded undo / redo stacks (tail = most recent). A push past the cap
// drops the oldest entry. The redo stack is cleared by every fresh
// close — a new "future" no longer matches what redo would re-close.
constexpr int kCap = 16;
QList<Entry> &undoStack() {
static QList<Entry> s;
return s;
}
QList<Entry> &redoStack() {
static QList<Entry> s;
return s;
}
// True while undo::redoLast is replaying. push* is gated on this so a
// redo that re-closes doesn't:
// (a) clear the redo stack (the rest of the redo chain stays
// playable), and
// (b) push a fresh undo entry (otherwise the user can ping-pong
// undo/redo on a single past close indefinitely).
bool g_redoInProgress = false;
// Pick the active window for an undo target. Skips the quick terminal
// (it doesn't push undo entries, so re-opening into it isn't
// meaningful). Falls back to the most recent registered regular
// window. Returns nullptr if no regular window exists.
MainWindow *activeUndoTarget() {
auto isUndoTarget = [](MainWindow *w) {
return w && !w->isQuickTerminal();
};
MainWindow *active = qobject_cast<MainWindow *>(qApp->activeWindow());
if (isUndoTarget(active)) return active;
const QList<MainWindow *> &live = GhosttyApp::instance().windows();
for (int i = live.size() - 1; i >= 0; --i) {
if (isUndoTarget(live.at(i))) return live.at(i);
}
return nullptr;
}
// Pick the window the user is currently looking at for a redo. Unlike
// undo, redo doesn't filter the quick terminal — REDO without an
// active regular window leaves the entry in place (caller restores).
MainWindow *activeRedoTarget() {
MainWindow *active = qobject_cast<MainWindow *>(qApp->activeWindow());
if (active) return active;
const QList<MainWindow *> &live = GhosttyApp::instance().windows();
return live.isEmpty() ? nullptr : live.last();
}
void pushUndo(Entry e) {
QList<Entry> &s = undoStack();
s.append(std::move(e));
if (s.size() > kCap) s.removeFirst();
// A fresh close invalidates any pending redo: the future the redo
// stack would replay no longer matches the world.
redoStack().clear();
}
} // namespace
void pushTab(const QString &tabText) {
if (g_redoInProgress) return;
Entry e;
e.kind = Entry::Kind::Tab;
e.pageTitles << tabText;
pushUndo(std::move(e));
}
void pushWindow(const QStringList &tabTitles, const QRect &geometry,
bool quickTerminal) {
if (g_redoInProgress) return;
if (quickTerminal || tabTitles.isEmpty()) return;
Entry e;
e.kind = Entry::Kind::Window;
e.pageTitles = tabTitles;
e.geometry = geometry;
pushUndo(std::move(e));
}
void undoLast() {
QList<Entry> &s = undoStack();
if (s.isEmpty()) return;
const Entry e = s.takeLast();
MainWindow *active = activeUndoTarget();
GhosttySurface *parent = active ? active->currentSurface() : nullptr;
if (e.kind == Entry::Kind::Tab) {
if (!active) return; // dropping the entry: no target to revive into
GhosttySurface *fresh =
active->newTab(parent ? parent->surface() : nullptr);
if (fresh && !e.pageTitles.isEmpty())
active->setTabTitleOverride(fresh, e.pageTitles.first());
} else {
// Window: spawn a fresh window, then queue extra tabs to match
// the saved tab count. We don't try to recreate the split tree
// — that would need a real session save mechanism.
MainWindow *w =
MainWindow::newWindow(parent ? parent->surface() : nullptr);
if (!w) return;
if (e.geometry.isValid()) w->setGeometry(e.geometry);
if (!e.pageTitles.isEmpty()) {
const QString first = e.pageTitles.first();
QPointer<MainWindow> wp(w);
QTimer::singleShot(0, w, [wp, first]() {
if (!wp) return;
if (auto *fresh = wp->surfaceAt(0))
wp->setTabTitleOverride(fresh, first);
});
}
for (int i = 1; i < e.pageTitles.size(); ++i) {
const QString t = e.pageTitles.at(i);
QPointer<MainWindow> wp(w);
QTimer::singleShot(0, w, [wp, t]() {
if (!wp) return;
GhosttySurface *first = wp->surfaceAt(0);
GhosttySurface *fresh =
wp->newTab(first ? first->surface() : nullptr);
if (fresh) wp->setTabTitleOverride(fresh, t);
});
}
}
QList<Entry> &r = redoStack();
r.append(e);
if (r.size() > kCap) r.removeFirst();
}
void redoLast() {
QList<Entry> &r = redoStack();
if (r.isEmpty()) return;
Entry e = r.takeLast();
MainWindow *active = activeRedoTarget();
if (!active) {
// No window to act on — restore the entry so the user can retry.
r.append(std::move(e));
return;
}
g_redoInProgress = true;
if (e.kind == Entry::Kind::Tab) {
active->closeCurrentTabForRedo();
} else {
active->closeForRedo();
}
g_redoInProgress = false;
}
} // namespace undo

54
qt/src/undo/UndoStack.h Normal file
View File

@ -0,0 +1,54 @@
#pragma once
#include <QList>
#include <QRect>
#include <QString>
#include <QStringList>
class MainWindow;
// Process-wide undo / redo of closed tabs and windows.
//
// libghostty's UNDO / REDO actions carry no payload — the apprt
// remembers what was closed and revives it. Surfaces themselves can't
// be revived (the child PTY is gone), so 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).
//
// State lives in this file's anonymous namespace; callers see only
// the four push / replay functions. push* are no-ops while a redo is
// replaying so the redo path doesn't feed itself.
namespace undo {
// Snapshot of one closed tab or window. Window snapshots carry every
// tab's last-known title and the window's geometry; tab snapshots
// carry one title and an unused geometry.
struct Entry {
enum class Kind { Tab, Window } kind = Kind::Tab;
QStringList pageTitles;
QRect geometry;
};
// Snapshot a closed tab — its last-known display text — onto the
// undo stack. Callers MUST exclude quick-terminal and last-tab
// closes (the latter routes through pushWindow via closeEvent).
void pushTab(const QString &tabText);
// Snapshot every tab's title plus the window's geometry as a single
// Window entry. Excluded for the quick terminal and for empty
// windows.
void pushWindow(const QStringList &tabTitles, const QRect &geometry,
bool quickTerminal);
// Pop the most recent entry and revive it: open a fresh tab or
// window, set the saved title(s) as a manual override, and push the
// entry onto the redo stack. No-op if the stack is empty.
void undoLast();
// Pop the most recent redo entry and re-close the active window's
// current tab (Tab entries) or the active window itself (Window
// entries). No-op if the stack is empty or no active window exists.
void redoLast();
} // namespace undo