mirror-ghostty/qt/src/GhosttySurface.cpp

1049 lines
41 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

#include "GhosttySurface.h"
#include "InspectorWindow.h"
#include "MainWindow.h"
#include "OverlayScrollbar.h"
#include "SearchBar.h"
#include "TabWidget.h"
#include "Util.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <QByteArray>
#include <QClipboard>
#include <QContextMenuEvent>
#include <QDragEnterEvent>
#include <QDropEvent>
#include <QFocusEvent>
#include <QGuiApplication>
#include <QIcon>
#include <QInputDialog>
#include <QInputMethodEvent>
#include <QKeyEvent>
#include <QKeySequence>
#include <QLabel>
#include <QLineEdit>
#include <QMenu>
#include <QMimeData>
#include <QMouseEvent>
#include <QOffscreenSurface>
#include <QOpenGLContext>
#include <QOpenGLFramebufferObject>
#include <QOpenGLFunctions>
#include <QOpenGLShaderProgram>
#include <QOpenGLVertexArrayObject>
#include <QPainter>
#include <QResizeEvent>
#include <QSplitter>
#include <QString>
#include <QStringList>
#include <QSurfaceFormat>
#include <QTimer>
#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) {
setFocusPolicy(Qt::StrongFocus);
setMouseTracking(true); // deliver motion events for hover/link detection
setAttribute(Qt::WA_InputMethodEnabled, true); // IME composition
setAcceptDrops(true); // file / text drops
// Scrollback scrollbar: a floating overlay driven by SCROLLBAR
// actions. Dragging it runs libghostty's scroll_to_row.
m_scrollbar = new OverlayScrollbar(this);
connect(m_scrollbar, &OverlayScrollbar::scrollToRow, this,
[this](int row) {
if (!m_surface) return;
const QByteArray a =
"scroll_to_row:" + QByteArray::number(row);
ghostty_surface_binding_action(m_surface, a.constData(),
a.size());
});
// The widget paints a per-pixel-alpha QImage of the terminal; a
// translucent background lets that alpha reach the desktop.
setAttribute(Qt::WA_TranslucentBackground);
// A private OpenGL context for libghostty's renderer. It is never made
// current on a window — rendering goes to an offscreen framebuffer —
// so an unparented QOffscreenSurface is enough to satisfy makeCurrent.
m_context = new QOpenGLContext(this);
m_context->setFormat(QSurfaceFormat::defaultFormat());
if (!m_context->create()) {
std::fprintf(stderr, "[ghastty] GL context creation failed\n");
return;
}
m_offscreen = new QOffscreenSurface(nullptr, this);
m_offscreen->setFormat(m_context->format());
m_offscreen->create();
if (!makeCurrent()) {
std::fprintf(stderr, "[ghastty] makeCurrent failed\n");
return;
}
// A placeholder framebuffer; resizeEvent installs the real size.
QOpenGLFramebufferObjectFormat fmt;
fmt.setInternalTextureFormat(GL_RGBA8);
m_fbw = m_fbh = 16;
m_fbo = new QOpenGLFramebufferObject(QSize(m_fbw, m_fbh), fmt);
ghostty_surface_config_s sc =
m_parentSurface
? ghostty_surface_inherited_config(m_parentSurface,
GHOSTTY_SURFACE_CONTEXT_TAB)
: ghostty_surface_config_new();
sc.platform_tag = GHOSTTY_PLATFORM_OPENGL;
sc.platform.opengl.userdata = this;
sc.platform.opengl.get_proc_address = glGetProcAddress;
sc.platform.opengl.make_current = glMakeCurrent;
sc.platform.opengl.release_current = glReleaseCurrent;
sc.platform.opengl.present = glPresent;
sc.userdata = this;
sc.scale_factor = devicePixelRatioF();
m_surface = ghostty_surface_new(m_app, &sc);
if (!m_surface) {
std::fprintf(stderr, "[ghastty] ghostty_surface_new failed\n");
return;
}
if (m_owner->needsPremultiply()) initPremultiply();
}
GhosttySurface::~GhosttySurface() {
// The inspector window holds m_surface; destroy it before m_surface.
// QPointer auto-nulls on a destroyed QObject, so .data() is safe.
delete m_inspectorWindow.data();
// GL teardown must happen with the context current. If makeCurrent
// fails (e.g. the ctor failed before m_context could be created), we
// still free m_surface — it carries no GL state of its own — and we
// still delete the FBO and premult helpers. Deleting QOpenGL* objects
// without a current context leaks the GL-side resource but is safe
// CPU-side; that's the best we can do when the context is gone.
const bool current = makeCurrent();
if (m_surface) ghostty_surface_free(m_surface);
delete m_fbo;
delete m_premultProg;
delete m_premultVao;
if (current) m_context->doneCurrent();
}
bool GhosttySurface::makeCurrent() {
return m_context && m_offscreen && m_offscreen->isValid() &&
m_context->makeCurrent(m_offscreen);
}
// --- rendering ------------------------------------------------------
// Re-sync the framebuffer and libghostty surface to the widget's current
// size and device pixel ratio. Driven by resizeEvent and by
// DevicePixelRatioChange: on Wayland the fractional scale settles
// asynchronously, after the window has already first appeared.
void GhosttySurface::syncSurfaceSize() {
if (!m_surface) return;
// Render at the display's device-pixel resolution. devicePixelRatioF()
// is the true (possibly fractional) scale because main() selects the
// PassThrough rounding policy.
const double dpr = devicePixelRatioF();
// The terminal fills the full width; the scrollbar is a thin overlay
// floating on top, so it does not subtract from the grid. Round-to-
// nearest rather than truncate so a fractional DPR (e.g. 1.5) doesn't
// shave a pixel off the framebuffer relative to the QImage blit.
const int w = std::max(1, static_cast<int>(std::lround(width() * dpr)));
const int h = std::max(1, static_cast<int>(std::lround(height() * dpr)));
if (w == m_fbw && h == m_fbh && dpr == m_fbDpr) return;
m_fbw = w;
m_fbh = h;
m_fbDpr = dpr;
if (!makeCurrent()) return;
delete m_fbo;
QOpenGLFramebufferObjectFormat fmt;
fmt.setInternalTextureFormat(GL_RGBA8);
m_fbo = new QOpenGLFramebufferObject(QSize(w, h), fmt);
ghostty_surface_set_content_scale(m_surface, dpr, dpr);
ghostty_surface_set_size(m_surface, static_cast<uint32_t>(w),
static_cast<uint32_t>(h));
renderTerminal();
}
void GhosttySurface::resizeEvent(QResizeEvent *) {
layoutScrollbar();
syncSurfaceSize();
if (m_exitOverlay) m_exitOverlay->setGeometry(rect());
if (m_keySeqOverlay && m_keySeqOverlay->isVisible())
m_keySeqOverlay->move(8, height() - m_keySeqOverlay->height() - 8);
layoutSearchBar();
showResizeOverlay();
}
bool GhosttySurface::event(QEvent *e) {
// The device pixel ratio can change without a resize — the Wayland
// fractional scale settling after startup, or a move between monitors.
// Re-sync so the framebuffer matches and the readback is tagged with
// that same ratio; otherwise paintEvent blits the frame at the wrong
// size (the FBO was sized at one DPR, the image tagged with another).
if (e->type() == QEvent::DevicePixelRatioChange) syncSurfaceSize();
return QWidget::event(e);
}
void GhosttySurface::renderIfDirty() {
if (m_dirty.exchange(false)) renderTerminal();
}
void GhosttySurface::layoutScrollbar() {
if (!m_scrollbar) return;
// Always positioned (even while faded out) so it is placed correctly
// the moment it is revealed.
m_scrollbar->setGeometry(width() - OverlayScrollbar::kWidth, 0,
OverlayScrollbar::kWidth, height());
}
// `scrollbar = never` in the config hides the scrollbar unconditionally;
// `system` (the default) shows it whenever there is scrollback.
bool GhosttySurface::scrollbarAllowed() const {
if (!m_owner || !m_owner->config()) return true;
const char *value = nullptr;
if (configGet(m_owner->config(), &value, "scrollbar") && value)
return qstrcmp(value, "never") != 0;
return true; // unknown — default to showing
}
void GhosttySurface::updateScrollbar(uint64_t total, uint64_t offset,
uint64_t len) {
if (!m_scrollbar) return;
if (!scrollbarAllowed() || total <= len) {
m_scrollbar->setMetrics(0, 0, 0);
m_scrollbar->hide();
return;
}
m_scrollbar->setMetrics(total, offset, len);
// Overlay behaviour: reveal the scrollbar on scroll activity, but not
// for output that merely follows the bottom of the buffer.
const bool atBottom = offset + len >= total;
if (!atBottom || !m_scrollAtBottom) flashScrollbar();
m_scrollAtBottom = atBottom;
}
// Reveal the overlay scrollbar (it fades itself back out when idle).
void GhosttySurface::flashScrollbar() {
if (!m_scrollbar || !scrollbarAllowed()) return;
// Handle colour: light on a dark terminal, dark on a light one.
ghostty_config_color_s bg{};
if (m_owner && configGet(m_owner->config(), &bg, "background")) {
const double luma = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b;
m_scrollbar->setHandleColor(luma < 128.0 ? QColor(235, 235, 235)
: QColor(45, 45, 45));
}
layoutScrollbar();
m_scrollbar->reveal();
}
void GhosttySurface::renderTerminal() {
if (!m_surface || !m_fbo || !makeCurrent()) return;
// libghostty renders into its own target and blits the result to the
// currently bound framebuffer — bind ours so we get the final image.
m_fbo->bind();
m_context->functions()->glViewport(0, 0, m_fbw, m_fbh);
ghostty_surface_draw(m_surface);
premultiplyFramebuffer();
// Read the frame back as a premultiplied, top-down QImage, tagged with
// the ratio the framebuffer was sized at so paintEvent can blit it 1:1
// at its true logical size. Using the live devicePixelRatioF() here
// would mis-size the blit if the DPR changed since syncSurfaceSize ran.
// (Scaling it to the widget instead made the whole frame — images
// included — rubber-band while a resize was in flight.)
m_image = m_fbo->toImage();
m_image.setDevicePixelRatio(m_fbDpr);
m_fbo->release();
update();
}
void GhosttySurface::paintEvent(QPaintEvent *) {
if (m_image.isNull()) return;
QPainter painter(this);
// Blit the framebuffer 1:1. m_image carries the device pixel ratio, so
// the QPointF overload draws it at its true logical size: when in sync
// that exactly fills the widget, and mid-resize the content keeps its
// real size instead of stretching to the (already-resized) widget.
// CompositionMode_Source replaces the transparent widget pixels with
// the terminal image, alpha included, so its translucency is kept.
painter.setCompositionMode(QPainter::CompositionMode_Source);
painter.drawImage(QPointF(0, 0), m_image);
// Unfocused-split dimming: a translucent fill over an inactive pane.
// Only split panes (a QSplitter parent) are dimmed, matching GTK.
if (!hasFocus() && qobject_cast<QSplitter *>(parentWidget())) {
ghostty_config_t cfg = m_owner ? m_owner->config() : nullptr;
double opacity = 0.7;
configGet(cfg, &opacity, "unfocused-split-opacity");
if (opacity < 1.0) {
QColor fill(0, 0, 0); // default: dim toward black
ghostty_config_color_s c{};
if (configGet(cfg, &c, "unfocused-split-fill"))
fill = QColor(c.r, c.g, c.b);
fill.setAlphaF(1.0 - opacity);
painter.setCompositionMode(QPainter::CompositionMode_SourceOver);
painter.fillRect(rect(), fill);
}
}
// Bell `border` feature: a brief attention flash over the terminal.
if (m_bellFlash) {
painter.setCompositionMode(QPainter::CompositionMode_SourceOver);
painter.setPen(QPen(QColor(255, 96, 96, 230), 3));
painter.setBrush(Qt::NoBrush);
painter.drawRect(QRectF(rect()).adjusted(1.5, 1.5, -1.5, -1.5));
}
}
void GhosttySurface::flashBorder() {
m_bellFlash = true;
update();
QTimer::singleShot(160, this, [this]() {
m_bellFlash = false;
update();
});
}
// A small translucent overlay label (key-sequence / resize display).
static QLabel *makeOverlayLabel(QWidget *parent) {
auto *label = new QLabel(parent);
label->setAttribute(Qt::WA_TransparentForMouseEvents);
label->setStyleSheet(QStringLiteral(
"background: rgba(0,0,0,0.75); color: #f0f0f0; font-size: 13px;"
"padding: 4px 10px; border-radius: 4px;"));
label->hide();
return label;
}
// Read a string/enum config value (enums arrive as their tag name).
static QString cfgString(ghostty_config_t cfg, const char *key) {
const char *v = nullptr;
if (cfg && ghostty_config_get(cfg, &v, key, qstrlen(key)) && v)
return QString::fromUtf8(v);
return {};
}
void GhosttySurface::promptTitle(bool tabScope) {
bool ok = false;
const QString title = QInputDialog::getText(
this,
tabScope ? QStringLiteral("Change Tab Title")
: QStringLiteral("Change Title"),
QStringLiteral("Title:"), QLineEdit::Normal, QString(), &ok);
if (!ok || !m_surface) return;
// The keybind action round-trips through libghostty, which emits
// SET_TAB_TITLE / SET_TITLE back to apply it (an empty title resets).
const QByteArray act =
(tabScope ? QByteArrayLiteral("set_tab_title:")
: QByteArrayLiteral("set_surface_title:")) +
title.toUtf8();
ghostty_surface_binding_action(m_surface, act.constData(), act.size());
}
void GhosttySurface::pushKeySequence(const QString &chord) {
m_keySeq.append(chord);
if (!m_keySeqOverlay) m_keySeqOverlay = makeOverlayLabel(this);
m_keySeqOverlay->setText(m_keySeq.join(QStringLiteral(" ")));
m_keySeqOverlay->adjustSize();
m_keySeqOverlay->move(8, height() - m_keySeqOverlay->height() - 8);
m_keySeqOverlay->show();
m_keySeqOverlay->raise();
}
void GhosttySurface::endKeySequence() {
m_keySeq.clear();
if (m_keySeqOverlay) m_keySeqOverlay->hide();
}
void GhosttySurface::toggleInspector(ghostty_action_inspector_e mode) {
const bool visible = m_inspectorWindow && m_inspectorWindow->isVisible();
bool show;
switch (mode) {
case GHOSTTY_INSPECTOR_SHOW: show = true; break;
case GHOSTTY_INSPECTOR_HIDE: show = false; break;
default: show = !visible; break; // GHOSTTY_INSPECTOR_TOGGLE
}
if (show) {
if (!m_inspectorWindow)
m_inspectorWindow = new InspectorWindow(m_surface);
m_inspectorWindow->show();
m_inspectorWindow->raise();
m_inspectorWindow->activateWindow();
} else if (m_inspectorWindow) {
m_inspectorWindow->hide();
}
}
void GhosttySurface::openSearch(const QString &prefill) {
if (!m_searchBar) m_searchBar = new SearchBar(this);
m_searchBar->open(prefill);
layoutSearchBar();
}
void GhosttySurface::closeSearch() {
if (m_searchBar) m_searchBar->hide();
}
void GhosttySurface::setSearchTotal(int total) {
if (m_searchBar) m_searchBar->setTotal(total);
}
void GhosttySurface::setSearchSelected(int selected) {
if (m_searchBar) m_searchBar->setSelected(selected);
}
void GhosttySurface::layoutSearchBar() {
if (!m_searchBar || !m_searchBar->isVisible()) return;
m_searchBar->adjustSize();
// Top-right, kept clear of the overlay scrollbar's strip.
m_searchBar->move(
width() - m_searchBar->width() - OverlayScrollbar::kWidth - 8, 8);
}
void GhosttySurface::showResizeOverlay() {
if (!m_surface || !m_owner) return;
const ghostty_surface_size_s sz = ghostty_surface_size(m_surface);
ghostty_config_t cfg = m_owner->config();
const QString mode = cfgString(cfg, "resize-overlay");
if (mode == QLatin1String("never")) return;
// Reset the hide timer on EVERY resize event, not just on grid-size
// boundaries. Without this, slow window drags only triggered the
// overlay when the grid happened to step (e.g. crossing a cell
// height), so the overlay flashed for ~750ms then disappeared even
// though the user was still dragging. Reading the duration here
// also picks up any config reload during a resize.
unsigned long long durNs = 0;
configGet(cfg, &durNs, "resize-overlay-duration");
const int durMs = durNs ? static_cast<int>(durNs / 1000000ULL) : 750;
if (!m_resizeHideTimer) {
m_resizeHideTimer = new QTimer(this);
m_resizeHideTimer->setSingleShot(true);
connect(m_resizeHideTimer, &QTimer::timeout, this, [this]() {
if (m_resizeOverlay) m_resizeOverlay->hide();
});
}
m_resizeHideTimer->start(durMs);
// The overlay TEXT only changes when the grid steps. Suppress the
// "after-first" mode's first-show, then track each grid step.
const bool gridChanged =
sz.columns != m_lastCols || sz.rows != m_lastRows;
if (gridChanged) {
const bool first = !m_firstGridSeen;
m_lastCols = sz.columns;
m_lastRows = sz.rows;
m_firstGridSeen = true;
if (mode == QLatin1String("after-first") && first) return;
} else if (m_resizeOverlay && m_resizeOverlay->isVisible()) {
// Overlay already visible with the right text; just reposition
// for the new widget size and we're done.
if (!m_resizeOverlay) return;
repositionResizeOverlay();
return;
} else if (!m_firstGridSeen) {
// Haven't seen a grid step yet AND no overlay is currently up.
// Don't show stale 0×0 text.
return;
}
if (!m_resizeOverlay) m_resizeOverlay = makeOverlayLabel(this);
m_resizeOverlay->setText(
QStringLiteral("%1 × %2").arg(sz.columns).arg(sz.rows));
m_resizeOverlay->adjustSize();
repositionResizeOverlay();
m_resizeOverlay->show();
m_resizeOverlay->raise();
}
void GhosttySurface::repositionResizeOverlay() {
if (!m_resizeOverlay || !m_owner) return;
ghostty_config_t cfg = m_owner->config();
const QString pos = cfgString(cfg, "resize-overlay-position");
const int m = 8;
int x = (width() - m_resizeOverlay->width()) / 2;
int y = (height() - m_resizeOverlay->height()) / 2;
if (pos.contains(QLatin1String("left"))) x = m;
else if (pos.contains(QLatin1String("right")))
x = width() - m_resizeOverlay->width() - m;
if (pos.contains(QLatin1String("top"))) y = m;
else if (pos.contains(QLatin1String("bottom")))
y = height() - m_resizeOverlay->height() - m;
m_resizeOverlay->move(x, y);
}
void GhosttySurface::showChildExited(int exitCode) {
if (m_exitOverlay) return; // already shown
// Defer the banner briefly. A normal `exit` closes the surface within
// a frame or two (libghostty calls close() right after this action),
// and we don't want the banner to flash in that case. The QObject-
// context singleShot is cancelled if the surface is destroyed first,
// so the banner only appears for surfaces that actually persist (an
// abnormal exit, or `wait-after-command`).
QTimer::singleShot(120, this, [this, exitCode]() { buildExitOverlay(exitCode); });
}
void GhosttySurface::buildExitOverlay(int exitCode) {
if (m_exitOverlay) return;
// A translucent banner over the terminal. It is transparent to mouse
// events so a click lands on this widget and dismisses it (see
// mousePressEvent / keyPressEvent).
m_exitOverlay = new QLabel(this);
m_exitOverlay->setAlignment(Qt::AlignCenter);
m_exitOverlay->setWordWrap(true);
m_exitOverlay->setAttribute(Qt::WA_TransparentForMouseEvents);
m_exitOverlay->setStyleSheet(QStringLiteral(
"background: rgba(0,0,0,0.65); color: #e0e0e0; font-size: 14px;"));
const QString code = exitCode >= 0
? QStringLiteral(" (code %1)").arg(exitCode)
: QString();
m_exitOverlay->setText(QStringLiteral(
"Process exited%1\nPress any key or click to close").arg(code));
m_exitOverlay->setGeometry(rect());
m_exitOverlay->show();
m_exitOverlay->raise();
}
// libghostty's renderer outputs premultiplied alpha — except a custom
// shader runs as a final Shadertoy-style pass and those conventionally
// emit *straight* alpha (RGB not scaled by alpha). QPainter and the
// compositor expect premultiplied, so a straight framebuffer renders the
// terminal color at full strength and reads as opaque. Fix it by
// premultiplying the framebuffer in place before reading it back.
//
// This runs only when a custom shader is configured: without one the
// renderer's output is already premultiplied and a second pass would
// wrongly darken the background.
void GhosttySurface::initPremultiply() {
m_premultVao = new QOpenGLVertexArrayObject(this);
m_premultVao->create();
m_premultProg = new QOpenGLShaderProgram(this);
// A single oversized triangle covering the viewport; positions are
// derived from gl_VertexID so no vertex buffer is needed.
m_premultProg->addShaderFromSourceCode(QOpenGLShader::Vertex,
R"(#version 330 core
void main() {
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
})");
// The fragment color is irrelevant: the blend below uses a source
// factor of zero, so only the destination framebuffer and its alpha
// matter.
m_premultProg->addShaderFromSourceCode(QOpenGLShader::Fragment,
R"(#version 330 core
out vec4 fragColor;
void main() { fragColor = vec4(1.0); }
)");
m_premultProg->link();
}
void GhosttySurface::premultiplyFramebuffer() {
if (!m_premultProg || !m_premultProg->isLinked()) return;
auto *f = m_context->functions();
// result.rgb = src.rgb*0 + dst.rgb*dst.a ; alpha left untouched by the
// color mask. This multiplies every pixel's RGB by its own alpha.
f->glViewport(0, 0, m_fbw, m_fbh);
f->glDisable(GL_SCISSOR_TEST);
f->glDisable(GL_DEPTH_TEST);
f->glEnable(GL_BLEND);
f->glBlendFuncSeparate(GL_ZERO, GL_DST_ALPHA, GL_ZERO, GL_ONE);
f->glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_FALSE);
m_premultVao->bind();
m_premultProg->bind();
f->glDrawArrays(GL_TRIANGLES, 0, 3);
m_premultProg->release();
m_premultVao->release();
f->glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
f->glDisable(GL_BLEND);
}
// --- input ----------------------------------------------------------
// Wraps a libxkbcommon keymap + state derived from the system's XKB
// defaults (XKB_DEFAULT_LAYOUT etc.). We need this for two things:
//
// 1. The unshifted codepoint a key would produce with no modifiers —
// libghostty's kitty encoder uses it to find a key entry for
// printable keys (without it, punctuation falls into a fallback
// that mis-encodes release events).
//
// 2. Which modifiers the layout "consumed" to produce the event's
// text — e.g. Shift+; → ":" consumes Shift. The encoder uses this
// to decide between plain text and a modifier-bearing CSI; without
// it Shift+punctuation gets emitted as a kitty CSI the shell can't
// decode (Shift+letter happens to work because A-Z survive that
// path).
//
// THREAD SAFETY: this is a process singleton accessed only from the Qt
// GUI thread (Qt key events are dispatched there, and so is libghostty's
// inputMethodEvent forwarding). consumedMods mutates m_query, so a
// second thread would race; do not call from worker threads.
class XkbState {
public:
static XkbState &instance() {
static XkbState self;
return self;
}
// Level-0 (unshifted) Unicode codepoint for `keycode`, or 0 if the
// key has no associated UTF-32 (function keys, modifiers, etc.).
uint32_t unshiftedCodepoint(uint32_t keycode) const {
if (!m_unshifted) return 0;
const xkb_keysym_t sym =
xkb_state_key_get_one_sym(m_unshifted, keycode);
if (sym == XKB_KEY_NoSymbol) return 0;
return xkb_keysym_to_utf32(sym);
}
// Modifiers consumed by the layout to produce `keycode`'s text given
// `mods` are depressed. Returns the consumed subset, expressed as
// ghostty mod bits. Mutates m_query (mutable) — see thread-safety
// note on the class.
ghostty_input_mods_e consumedMods(uint32_t keycode,
ghostty_input_mods_e mods) const {
if (!m_query) return GHOSTTY_MODS_NONE;
xkb_mod_mask_t depressed = 0;
if ((mods & GHOSTTY_MODS_SHIFT) && m_idxShift != XKB_MOD_INVALID)
depressed |= (1u << m_idxShift);
if ((mods & GHOSTTY_MODS_CTRL) && m_idxCtrl != XKB_MOD_INVALID)
depressed |= (1u << m_idxCtrl);
if ((mods & GHOSTTY_MODS_ALT) && m_idxAlt != XKB_MOD_INVALID)
depressed |= (1u << m_idxAlt);
if ((mods & GHOSTTY_MODS_SUPER) && m_idxSuper != XKB_MOD_INVALID)
depressed |= (1u << m_idxSuper);
xkb_state_update_mask(m_query, depressed, 0, 0, 0, 0, 0);
const xkb_mod_mask_t consumed = xkb_state_key_get_consumed_mods2(
m_query, keycode, XKB_CONSUMED_MODE_XKB);
// Reset so the next query starts from no-mods.
xkb_state_update_mask(m_query, 0, 0, 0, 0, 0, 0);
int r = GHOSTTY_MODS_NONE;
if (m_idxShift != XKB_MOD_INVALID && (consumed & (1u << m_idxShift)))
r |= GHOSTTY_MODS_SHIFT;
if (m_idxCtrl != XKB_MOD_INVALID && (consumed & (1u << m_idxCtrl)))
r |= GHOSTTY_MODS_CTRL;
if (m_idxAlt != XKB_MOD_INVALID && (consumed & (1u << m_idxAlt)))
r |= GHOSTTY_MODS_ALT;
if (m_idxSuper != XKB_MOD_INVALID && (consumed & (1u << m_idxSuper)))
r |= GHOSTTY_MODS_SUPER;
return static_cast<ghostty_input_mods_e>(r);
}
private:
XkbState() {
m_ctx = xkb_context_new(XKB_CONTEXT_NO_FLAGS);
if (!m_ctx) return;
m_keymap = xkb_keymap_new_from_names(m_ctx, nullptr,
XKB_KEYMAP_COMPILE_NO_FLAGS);
if (!m_keymap) return;
m_unshifted = xkb_state_new(m_keymap);
m_query = xkb_state_new(m_keymap);
m_idxShift = xkb_keymap_mod_get_index(m_keymap, XKB_MOD_NAME_SHIFT);
m_idxCtrl = xkb_keymap_mod_get_index(m_keymap, XKB_MOD_NAME_CTRL);
m_idxAlt = xkb_keymap_mod_get_index(m_keymap, XKB_MOD_NAME_ALT);
m_idxSuper = xkb_keymap_mod_get_index(m_keymap, XKB_MOD_NAME_LOGO);
}
~XkbState() {
// Run on process exit when the static is destroyed. The OS would
// reclaim regardless, but explicit teardown silences leak checkers
// and documents the ownership chain.
if (m_query) xkb_state_unref(m_query);
if (m_unshifted) xkb_state_unref(m_unshifted);
if (m_keymap) xkb_keymap_unref(m_keymap);
if (m_ctx) xkb_context_unref(m_ctx);
}
XkbState(const XkbState &) = delete;
XkbState &operator=(const XkbState &) = delete;
struct xkb_context *m_ctx = nullptr;
struct xkb_keymap *m_keymap = nullptr;
struct xkb_state *m_unshifted = nullptr; // permanent no-mods state
// Reused across consumedMods calls (mutated then reset). Mutable so
// consumedMods can stay logically const.
mutable struct xkb_state *m_query = nullptr;
xkb_mod_index_t m_idxShift = XKB_MOD_INVALID;
xkb_mod_index_t m_idxCtrl = XKB_MOD_INVALID;
xkb_mod_index_t m_idxAlt = XKB_MOD_INVALID;
xkb_mod_index_t m_idxSuper = XKB_MOD_INVALID;
};
void GhosttySurface::sendKey(QKeyEvent *ev, ghostty_input_action_e action) {
if (!m_surface) return;
// Forward committed text only for printable input; control characters
// and special keys (Enter, Tab, arrows, Ctrl+letter, ...) are encoded
// by libghostty from the physical keycode + modifiers.
// The QByteArray below is stack-local; ghostty_surface_key is
// synchronous and copies any text it needs internally, so the buffer
// only has to live across this call.
const QByteArray text = ev->text().toUtf8();
const bool printable =
!text.isEmpty() &&
static_cast<unsigned char>(text.front()) >= 0x20 &&
static_cast<unsigned char>(text.front()) != 0x7f;
// On xcb nativeScanCode() is the X11/XKB keycode; the Wayland plugin
// likewise reports the XKB keycode, which is libghostty's Linux native.
const uint32_t keycode = ev->nativeScanCode();
ghostty_input_key_s k = {};
k.action = action;
k.mods = translateMods(ev->modifiers());
k.keycode = keycode;
k.text = printable ? text.constData() : nullptr;
// XKB lookups: unshifted codepoint (what this physical key would
// produce with no mods, e.g. ';' for the Shift+; → ':' event) and the
// modifiers the layout consumed to produce the event's text. Without
// consumed_mods, Shift+punctuation is emitted as a kitty CSI sequence
// the shell can't decode; with it set, libghostty's encoder falls
// back to plain text correctly.
k.unshifted_codepoint = XkbState::instance().unshiftedCodepoint(keycode);
k.consumed_mods = printable
? XkbState::instance().consumedMods(keycode, k.mods)
: GHOSTTY_MODS_NONE;
k.composing = false;
ghostty_surface_key(m_surface, k);
}
void GhosttySurface::sendMouseButton(QMouseEvent *ev,
ghostty_input_mouse_state_e state) {
if (!m_surface) return;
ghostty_input_mouse_button_e button;
switch (ev->button()) {
case Qt::LeftButton: button = GHOSTTY_MOUSE_LEFT; break;
case Qt::RightButton: button = GHOSTTY_MOUSE_RIGHT; break;
case Qt::MiddleButton: button = GHOSTTY_MOUSE_MIDDLE; break;
default: button = GHOSTTY_MOUSE_UNKNOWN; break;
}
ghostty_surface_mouse_button(m_surface, state, button,
translateMods(ev->modifiers()));
}
void GhosttySurface::keyPressEvent(QKeyEvent *ev) {
// While the child-exited overlay is up, any key dismisses it (closes
// the pane) instead of reaching the dead terminal.
if (m_exitOverlay) {
m_owner->removeSurface(this);
return;
}
sendKey(ev,
ev->isAutoRepeat() ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS);
}
void GhosttySurface::keyReleaseEvent(QKeyEvent *ev) {
// Qt synthesizes a release before each auto-repeat press; drop those.
if (ev->isAutoRepeat()) return;
sendKey(ev, GHOSTTY_ACTION_RELEASE);
}
// A right-click opens the context menu (contextMenuEvent) unless the
// running program is capturing the mouse, in which case it gets the
// click. Returns true if the click was for the menu and should not be
// forwarded to the terminal.
bool GhosttySurface::rightClickOpensMenu(QMouseEvent *ev) const {
return ev->button() == Qt::RightButton && m_surface &&
!ghostty_surface_mouse_captured(m_surface);
}
void GhosttySurface::mousePressEvent(QMouseEvent *ev) {
if (m_exitOverlay) {
m_owner->removeSurface(this);
return;
}
setFocus();
if (rightClickOpensMenu(ev)) return;
sendMouseButton(ev, GHOSTTY_MOUSE_PRESS);
}
void GhosttySurface::mouseReleaseEvent(QMouseEvent *ev) {
if (rightClickOpensMenu(ev)) return;
sendMouseButton(ev, GHOSTTY_MOUSE_RELEASE);
}
// The keybind bound to `action` in the live config, as a QKeySequence
// for a context-menu hint. Empty if unbound or not displayable
// (CATCH_ALL, an unmapped physical key, etc.).
QKeySequence GhosttySurface::shortcutFor(const char *action) const {
if (!m_owner || !m_owner->config()) return {};
const ghostty_input_trigger_s t =
ghostty_config_trigger(m_owner->config(), action, qstrlen(action));
const QString key = triggerKeyName(t);
if (key.isEmpty()) return {};
QString seq;
if (t.mods & GHOSTTY_MODS_CTRL) seq += QStringLiteral("Ctrl+");
if (t.mods & GHOSTTY_MODS_ALT) seq += QStringLiteral("Alt+");
if (t.mods & GHOSTTY_MODS_SHIFT) seq += QStringLiteral("Shift+");
// QKeySequence parses Meta+ as the Super/Logo key on Linux.
if (t.mods & GHOSTTY_MODS_SUPER) seq += QStringLiteral("Meta+");
return QKeySequence(seq + key);
}
void GhosttySurface::contextMenuEvent(QContextMenuEvent *ev) {
// Let a mouse-capturing program have the right-click; also suppress
// the menu while the child-exited overlay is up.
if (!m_surface || m_exitOverlay ||
ghostty_surface_mouse_captured(m_surface))
return;
QMenu menu(this);
// Each item carries its libghostty keybind-action string in data();
// exec() returns the chosen action and we run it once, below. Icons
// come from the system theme; the shortcut hint from the live config.
const auto add = [this](QMenu *into, const char *label, const char *icon,
const char *action, bool enabled) {
QAction *a = into->addAction(QString::fromUtf8(label));
a->setData(QString::fromUtf8(action));
a->setEnabled(enabled);
if (QIcon themed = QIcon::fromTheme(QString::fromUtf8(icon));
!themed.isNull())
a->setIcon(themed);
if (QKeySequence sc = shortcutFor(action); !sc.isEmpty())
a->setShortcut(sc);
};
add(&menu, "Copy", "edit-copy", "copy_to_clipboard",
ghostty_surface_has_selection(m_surface));
add(&menu, "Paste", "edit-paste", "paste_from_clipboard",
!QGuiApplication::clipboard()->text().isEmpty());
add(&menu, "Select All", "edit-select-all", "select_all", true);
add(&menu, "Find…", "edit-find", "start_search", true);
add(&menu, "Notify on Next Command Finish",
"preferences-desktop-notification", "@notify-command", true);
menu.addSeparator();
add(&menu, "Clear", "edit-clear-all", "clear_screen", true);
add(&menu, "Reset", "view-refresh", "reset", true);
menu.addSeparator();
QMenu *split = menu.addMenu(
QIcon::fromTheme(QStringLiteral("view-split-left-right")),
QStringLiteral("Split"));
add(split, "Change Title…", "document-edit", "prompt_surface_title", true);
add(split, "Split Right", "view-split-left-right", "new_split:right", true);
add(split, "Split Down", "view-split-top-bottom", "new_split:down", true);
add(split, "Split Left", "view-split-left-right", "new_split:left", true);
add(split, "Split Up", "view-split-top-bottom", "new_split:up", true);
QMenu *tab = menu.addMenu(QIcon::fromTheme(QStringLiteral("tab-new")),
QStringLiteral("Tab"));
add(tab, "Change Tab Title…", "document-edit", "prompt_tab_title", true);
add(tab, "New Tab", "tab-new", "new_tab", true);
add(tab, "Close Tab", "tab-close", "close_tab", true);
QMenu *window = menu.addMenu(QIcon::fromTheme(QStringLiteral("window-new")),
QStringLiteral("Window"));
add(window, "New Window", "window-new", "new_window", true);
add(window, "Close Window", "window-close", "close_window", true);
menu.addSeparator();
QMenu *config = menu.addMenu(QIcon::fromTheme(QStringLiteral("configure")),
QStringLiteral("Config"));
add(config, "Open Config", "document-open", "open_config", true);
add(config, "Reload Config", "view-refresh", "reload_config", true);
QAction *chosen = menu.exec(ev->globalPos());
if (!chosen || !m_surface) return;
const QString data = chosen->data().toString();
// Arm the one-shot "command finished" notification (no keybind action).
if (data == QLatin1String("@notify-command")) {
armCommandNotify();
return;
}
// The title items have no apprt-side prompt in libghostty: collect the
// text here and apply it via promptTitle (the set_*_title keybind).
if (data == QLatin1String("prompt_surface_title") ||
data == QLatin1String("prompt_tab_title")) {
promptTitle(data == QLatin1String("prompt_tab_title"));
return;
}
const QByteArray action = data.toUtf8();
ghostty_surface_binding_action(m_surface, action.constData(),
action.size());
}
void GhosttySurface::dragEnterEvent(QDragEnterEvent *ev) {
// Accept a tab tear-off drag too — not to handle it, but so Qt does
// not paint a "forbidden" cursor while a torn-off tab hovers the
// terminal. The tear-off still completes as a new window (only a tab
// bar's drop cancels it).
if (ev->mimeData()->hasUrls() || ev->mimeData()->hasText() ||
ev->mimeData()->hasFormat(QString::fromLatin1(kGhosttyTabMime)))
ev->acceptProposedAction();
}
void GhosttySurface::dropEvent(QDropEvent *ev) {
const QMimeData *mime = ev->mimeData();
// A tab tear-off released on the terminal: accept it cleanly and let
// the tear-off code turn it into a new window.
if (mime->hasFormat(QString::fromLatin1(kGhosttyTabMime))) {
ev->acceptProposedAction();
return;
}
QString text;
if (mime->hasUrls()) {
// Dropped files are inserted as shell-quoted, space-separated paths.
QStringList paths;
for (const QUrl &url : mime->urls()) {
QString p = url.isLocalFile() ? url.toLocalFile() : url.toString();
p.replace(QLatin1String("'"), QLatin1String("'\\''"));
paths << QLatin1Char('\'') + p + QLatin1Char('\'');
}
text = paths.join(QLatin1Char(' '));
} else if (mime->hasText()) {
text = mime->text();
}
if (text.isEmpty()) return;
commitText(text);
ev->acceptProposedAction();
}
void GhosttySurface::mouseMoveEvent(QMouseEvent *ev) {
if (!m_surface) return;
// ghostty_surface_mouse_pos wants unscaled (logical) coordinates — it
// applies the content scale itself. Passing device pixels double-scales
// the position and drifts the selection on HiDPI displays.
ghostty_surface_mouse_pos(m_surface, ev->position().x(),
ev->position().y(),
translateMods(ev->modifiers()));
// Reveal the overlay scrollbar when the pointer reaches the right
// edge. While it is visible the scrollbar widget grabs the strip
// itself; this only fires once it has faded out and been hidden.
if (ev->position().x() >= width() - OverlayScrollbar::kWidth)
flashScrollbar();
}
void GhosttySurface::wheelEvent(QWheelEvent *ev) {
if (!m_surface) return;
// angleDelta is in eighths of a degree; 120 units == one wheel notch.
const QPoint d = ev->angleDelta();
ghostty_surface_mouse_scroll(m_surface, d.x() / 120.0, d.y() / 120.0, 0);
flashScrollbar(); // mouse-wheel scrolling reveals the overlay scrollbar
}
void GhosttySurface::enterEvent(QEnterEvent *) {
// focus-follows-mouse: take focus when the pointer enters this pane.
if (m_owner && m_owner->focusFollowsMouse() && !hasFocus()) setFocus();
}
void GhosttySurface::focusInEvent(QFocusEvent *) {
if (m_surface) ghostty_surface_set_focus(m_surface, true);
update(); // repaint without the unfocused-split dim
}
void GhosttySurface::focusOutEvent(QFocusEvent *) {
if (m_surface) ghostty_surface_set_focus(m_surface, false);
update(); // repaint with the unfocused-split dim (if a split pane)
}
// Insert a string of committed text (an IME commit) as terminal input.
void GhosttySurface::commitText(const QString &text) {
if (!m_surface || text.isEmpty()) return;
const QByteArray utf8 = text.toUtf8();
ghostty_input_key_s k = {};
k.action = GHOSTTY_ACTION_PRESS;
k.mods = GHOSTTY_MODS_NONE;
k.consumed_mods = GHOSTTY_MODS_NONE;
k.keycode = 0;
k.text = utf8.constData();
k.unshifted_codepoint = 0;
k.composing = false;
ghostty_surface_key(m_surface, k);
}
void GhosttySurface::inputMethodEvent(QInputMethodEvent *ev) {
if (m_surface) {
const QString preeditStr = ev->preeditString();
const QString commitStr = ev->commitString();
// Forward the in-progress composition for inline display, then any
// finalized text. A well-behaved IME sends an empty preedit string
// alongside the commit, so this order matches GTK: clear, then commit.
const QByteArray preedit = preeditStr.toUtf8();
ghostty_surface_preedit(
m_surface, preedit.isEmpty() ? nullptr : preedit.constData(),
static_cast<uintptr_t>(preedit.size()));
// Only commit when the text is the result of real IME composition —
// either the preceding event left us in preedit, or this event has
// active preedit alongside the commit. On Wayland's text-input-v3
// (KDE Plasma 6 with no IME), the compositor sends a commit for
// every plain ASCII character it also delivers as a key event;
// forwarding both here would double every keystroke (the visible
// symptom: ":" in nvim arriving as "::").
if (!commitStr.isEmpty() && (m_hadPreedit || !preeditStr.isEmpty()))
commitText(commitStr);
m_hadPreedit = !preeditStr.isEmpty();
}
ev->accept();
}
QVariant GhosttySurface::inputMethodQuery(Qt::InputMethodQuery query) const {
switch (query) {
case Qt::ImEnabled:
return true;
case Qt::ImCursorRectangle: {
// Anchor the IME candidate window at the terminal cursor.
// libghostty reports the cursor in device pixels; the IME wants
// logical widget coordinates, so divide by the surface's DPR.
if (!m_surface) return QRect();
const ghostty_surface_cursor_position_s c =
ghostty_surface_cursor_position(m_surface);
// m_fbDpr defaults to 1.0 and only ever takes positive values
// from syncSurfaceSize, so dividing is always safe.
return QRect(static_cast<int>(c.x / m_fbDpr),
static_cast<int>(c.y / m_fbDpr),
std::max(1, static_cast<int>(c.width / m_fbDpr)),
std::max(1, static_cast<int>(c.height / m_fbDpr)));
}
default:
return QWidget::inputMethodQuery(query);
}
}
// --- libghostty GL platform callbacks --------------------------------
void *GhosttySurface::glGetProcAddress(void *, const char *name) {
QOpenGLContext *ctx = QOpenGLContext::currentContext();
return ctx ? reinterpret_cast<void *>(ctx->getProcAddress(name)) : nullptr;
}
void GhosttySurface::glMakeCurrent(void *ud) {
static_cast<GhosttySurface *>(ud)->makeCurrent();
}
void GhosttySurface::glReleaseCurrent(void *) {
// No-op: renderTerminal makes the context current around each frame.
}
void GhosttySurface::glPresent(void *) {
// No-op: the frame is read back from the framebuffer, not swapped.
}