qt: paint the resize overlay directly, not as a child QLabel

The earlier "extend the timer on every resizeEvent" patch did not
actually fix the flash, because the flash wasn't a timer issue —
it was a paint-ordering race between the parent surface and the
child QLabel.

GhosttySurface::paintEvent draws the terminal FBO with
QPainter::CompositionMode_Source, which fully replaces destination
pixels in the painted rect. With a child QLabel sitting on top, Qt
composites the child's paint into the same backing store, but only
after the child's own paintEvent runs — and during a continuous
resize the parent's frequent update() calls outpaced the child's
paint, so the backing store was flushed once with the parent's blit
already done but the child not yet repainted. That single-frame
gap is the visible flash.

Fix the root cause: drop the child QLabel entirely and paint the
"cols × rows" plate inside GhosttySurface::paintEvent itself, in the
same QPainter pass as the terminal blit. Now the overlay is atomic
with the surface beneath it — no child-widget timing race possible.

State is reduced to (m_resizeOverlayText, m_resizeOverlayUntilMs):
showResizeOverlay updates both, calls update(); paintEvent calls
paintResizeOverlay() which draws the plate when current time is
before the deadline. A QTimer schedules a single update() at the
deadline so the overlay disappears even if no further resize events
arrive. cfgString is forward-declared because paintResizeOverlay
uses it before its definition further down in the file.

Co-Authored-By: claude-flow <ruv@ruv.net>
pull/12846/head
ntomsic 2026-05-20 16:45:53 -05:00
parent 15b2b060e7
commit af567feb83
2 changed files with 89 additions and 54 deletions

View File

@ -14,9 +14,11 @@
#include <QByteArray>
#include <QClipboard>
#include <QContextMenuEvent>
#include <QDateTime>
#include <QDragEnterEvent>
#include <QDropEvent>
#include <QFocusEvent>
#include <QFontMetrics>
#include <QGuiApplication>
#include <QIcon>
#include <QInputDialog>
@ -308,6 +310,52 @@ void GhosttySurface::paintEvent(QPaintEvent *) {
painter.setBrush(Qt::NoBrush);
painter.drawRect(QRectF(rect()).adjusted(1.5, 1.5, -1.5, -1.5));
}
// Resize overlay (rendered here, not as a child widget, so it
// can't race the Source-mode blit above mid-resize).
paintResizeOverlay(painter);
}
// Forward decl: cfgString is defined further down (alongside other
// per-config-key helpers). We need it here for the resize-overlay
// paint path.
static QString cfgString(ghostty_config_t cfg, const char *key);
void GhosttySurface::paintResizeOverlay(QPainter &painter) {
if (m_resizeOverlayText.isEmpty()) return;
if (QDateTime::currentMSecsSinceEpoch() >= m_resizeOverlayUntilMs) return;
if (!m_owner) return;
ghostty_config_t cfg = m_owner->config();
const QString posCfg = cfgString(cfg, "resize-overlay-position");
// Layout the text in a rounded-rect plate, sized from the text's
// bounding rect plus padding.
painter.setCompositionMode(QPainter::CompositionMode_SourceOver);
QFont f = painter.font();
f.setPixelSize(13);
painter.setFont(f);
const QFontMetrics fm(f);
const QRect textRect = fm.boundingRect(m_resizeOverlayText);
const int padX = 10, padY = 4;
const int w = textRect.width() + padX * 2;
const int h = textRect.height() + padY * 2;
const int m = 8;
int x = (width() - w) / 2;
int y = (height() - h) / 2;
if (posCfg.contains(QLatin1String("left"))) x = m;
else if (posCfg.contains(QLatin1String("right"))) x = width() - w - m;
if (posCfg.contains(QLatin1String("top"))) y = m;
else if (posCfg.contains(QLatin1String("bottom"))) y = height() - h - m;
const QRectF plate(x, y, w, h);
painter.setRenderHint(QPainter::Antialiasing, true);
painter.setPen(Qt::NoPen);
painter.setBrush(QColor(0, 0, 0, 191)); // 0.75 alpha
painter.drawRoundedRect(plate, 4, 4);
painter.setPen(QColor(0xf0, 0xf0, 0xf0));
painter.drawText(plate, Qt::AlignCenter, m_resizeOverlayText);
}
void GhosttySurface::flashBorder() {
@ -422,26 +470,8 @@ void GhosttySurface::showResizeOverlay() {
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.
// The "after-first" mode hides the overlay until the grid has
// stepped at least once after the surface was created.
const bool gridChanged =
sz.columns != m_lastCols || sz.rows != m_lastRows;
if (gridChanged) {
@ -449,42 +479,39 @@ void GhosttySurface::showResizeOverlay() {
m_lastCols = sz.columns;
m_lastRows = sz.rows;
m_firstGridSeen = true;
m_resizeOverlayText =
QStringLiteral("%1 × %2").arg(sz.columns).arg(sz.rows);
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.
} else if (m_resizeOverlayText.isEmpty()) {
// No grid step has happened yet AND no overlay text is cached —
// nothing to display.
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();
}
// Push the hide deadline forward on every resizeEvent so the
// overlay stays visible until the user actually stops resizing.
// Without this, slow drags between cell-boundary crossings would
// let the timer fire mid-drag and flash the overlay off-on-off.
unsigned long long durNs = 0;
configGet(cfg, &durNs, "resize-overlay-duration");
const int durMs = durNs ? static_cast<int>(durNs / 1000000ULL) : 750;
m_resizeOverlayUntilMs =
QDateTime::currentMSecsSinceEpoch() + durMs;
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);
// Schedule a paint at the deadline so the overlay disappears even
// when no further resize events are arriving.
if (!m_resizeHideTimer) {
m_resizeHideTimer = new QTimer(this);
m_resizeHideTimer->setSingleShot(true);
connect(m_resizeHideTimer, &QTimer::timeout, this,
[this]() { update(); });
}
m_resizeHideTimer->start(durMs);
// Repaint now so the overlay appears (or its text updates) on the
// next frame. paintEvent reads m_resizeOverlayText and the
// deadline; nothing else changes about the surface contents.
update();
}
void GhosttySurface::showChildExited(int exitCode) {

View File

@ -25,6 +25,7 @@ class QOpenGLContext;
class QOpenGLFramebufferObject;
class QOpenGLShaderProgram;
class QOpenGLVertexArrayObject;
class QPainter;
class OverlayScrollbar;
// One Ghostty terminal pane.
@ -139,7 +140,10 @@ private:
void flashScrollbar(); // reveal the overlay scrollbar, arm hide
void buildExitOverlay(int exitCode);
void showResizeOverlay(); // transient grid-size overlay on resize
void repositionResizeOverlay(); // re-place overlay for current widget size
// Paint the resize overlay (if visible) directly via the parent's
// QPainter — done inside paintEvent so the overlay is atomic with
// the terminal blit beneath it.
void paintResizeOverlay(QPainter &painter);
void layoutSearchBar(); // position the search bar at the top edge
void sendKey(QKeyEvent *, ghostty_input_action_e action);
void commitText(const QString &text);
@ -183,8 +187,12 @@ private:
QLabel *m_exitOverlay = nullptr; // "process exited" banner; lazily made
QLabel *m_keySeqOverlay = nullptr; // pending keybind chord; lazily made
QStringList m_keySeq; // accumulated pending chords
QLabel *m_resizeOverlay = nullptr; // transient "cols x rows"; lazily made
QTimer *m_resizeHideTimer = nullptr; // auto-hides m_resizeOverlay
// Resize overlay is painted directly inside paintEvent (not a child
// QLabel) so it can't race the parent's CompositionMode_Source blit
// mid-resize. Deadline-based: visible while now < m_resizeOverlayUntil.
QString m_resizeOverlayText;
qint64 m_resizeOverlayUntilMs = 0; // monotonic ms since epoch
QTimer *m_resizeHideTimer = nullptr; // schedules a paint at hide-time
bool m_firstGridSeen = false; // for `resize-overlay = after-first`
int m_lastCols = 0; // last grid size, to detect changes
int m_lastRows = 0;