Add VNC remote cursor shape sync

Implements RFB's Cursor pseudo-encoding (RFC 6143 SS7.8.2, type -239):
a FramebufferUpdate rectangle carrying a cursor shape instead of
screen content (x/y are the hotspot, not position; width/height are
the cursor image size), decoded into an ARGB32 QImage using the
rectangle's RGB pixel data plus its opacity bitmask, then never
painted into the framebuffer. A 0x0 rectangle means "hide the
cursor" per spec.

VncDisplayWidget gains RdpDisplayWidget's setCursorImage/Hidden/
Default() + applyCursor() shape, reusing its own renderRect()/
effectiveRemoteSize() so cursor scaling works correctly in both the
scale-to-fit and actual-size display modes with no special-casing.
VNC never emits cursorReset() (RFB's Cursor pseudo-encoding has no
"reset to system default" signal, unlike RDP's SetDefault callback) --
setCursorDefault() exists for symmetry but is unused today.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 20:34:20 -06:00
co-authored by Claude Sonnet 5
parent 3dd894407a
commit 9dd1af21d6
6 changed files with 286 additions and 6 deletions
+6
View File
@@ -274,6 +274,8 @@ SessionTab::SessionTab(const Profile& profile,
[this](const QImage& image, const QPoint& hotspot) {
if (m_rdpDisplay != nullptr) {
m_rdpDisplay->setCursorImage(image, hotspot);
} else if (m_vncDisplay != nullptr) {
m_vncDisplay->setCursorImage(image, hotspot);
}
},
Qt::QueuedConnection);
@@ -283,6 +285,8 @@ SessionTab::SessionTab(const Profile& profile,
[this]() {
if (m_rdpDisplay != nullptr) {
m_rdpDisplay->setCursorHidden();
} else if (m_vncDisplay != nullptr) {
m_vncDisplay->setCursorHidden();
}
},
Qt::QueuedConnection);
@@ -292,6 +296,8 @@ SessionTab::SessionTab(const Profile& profile,
[this]() {
if (m_rdpDisplay != nullptr) {
m_rdpDisplay->setCursorDefault();
} else if (m_vncDisplay != nullptr) {
m_vncDisplay->setCursorDefault();
}
},
Qt::QueuedConnection);
+67 -1
View File
@@ -1,9 +1,11 @@
#include "vnc_display_widget.h"
#include <QCursor>
#include <QEvent>
#include <QKeyEvent>
#include <QMouseEvent>
#include <QPainter>
#include <QPixmap>
#include <QResizeEvent>
#include <QTimer>
#include <QWheelEvent>
@@ -30,7 +32,8 @@ VncDisplayWidget::VncDisplayWidget(QWidget* parent)
: QWidget(parent),
m_remoteSize(1280, 720),
m_resizeDebounceTimer(new QTimer(this)),
m_scaleToFit(true)
m_scaleToFit(true),
m_cursorMode(CursorMode::Default)
{
setFocusPolicy(Qt::StrongFocus);
setMouseTracking(true);
@@ -83,6 +86,7 @@ void VncDisplayWidget::setScaleToFit(bool scaleToFit)
}
m_scaleToFit = scaleToFit;
applySizeConstraint();
applyCursor();
update();
}
@@ -131,6 +135,7 @@ void VncDisplayWidget::resizeEvent(QResizeEvent* event)
{
QWidget::resizeEvent(event);
scheduleViewportGeometryEmit();
applyCursor();
}
bool VncDisplayWidget::event(QEvent* event)
@@ -282,3 +287,64 @@ QSize VncDisplayWidget::effectiveRemoteSize() const
}
return QSize(1280, 720);
}
void VncDisplayWidget::setCursorImage(const QImage& image, const QPoint& hotspot)
{
m_cursorImage = image;
m_cursorHotspot = hotspot;
m_cursorMode = CursorMode::Custom;
applyCursor();
}
void VncDisplayWidget::setCursorHidden()
{
m_cursorMode = CursorMode::Hidden;
applyCursor();
}
void VncDisplayWidget::setCursorDefault()
{
m_cursorMode = CursorMode::Default;
applyCursor();
}
void VncDisplayWidget::applyCursor()
{
if (m_cursorMode == CursorMode::Hidden) {
setCursor(Qt::BlankCursor);
return;
}
if (m_cursorMode == CursorMode::Default || m_cursorImage.isNull()) {
unsetCursor();
return;
}
// renderRect()/effectiveRemoteSize() already account for both display
// modes: in actual-size mode the scale factor naturally comes out to
// 1.0 (see applySizeConstraint()'s comment), so no special-casing is
// needed here beyond reusing the same geometry helpers RDP's version
// uses for its single (always scale-to-fit) mode.
const QSize remote = effectiveRemoteSize();
const QRectF target = renderRect();
if (remote.isEmpty() || target.isEmpty()) {
setCursor(QCursor(QPixmap::fromImage(m_cursorImage),
m_cursorHotspot.x(),
m_cursorHotspot.y()));
return;
}
const qreal scale = target.width() / remote.width();
QImage scaledImage = m_cursorImage;
if (!qFuzzyCompare(scale, 1.0)) {
scaledImage = m_cursorImage.scaled(
qMax(1, qRound(m_cursorImage.width() * scale)),
qMax(1, qRound(m_cursorImage.height() * scale)),
Qt::IgnoreAspectRatio,
Qt::SmoothTransformation);
}
const int hotX = qBound(0, qRound(m_cursorHotspot.x() * scale), scaledImage.width());
const int hotY = qBound(0, qRound(m_cursorHotspot.y() * scale), scaledImage.height());
setCursor(QCursor(QPixmap::fromImage(scaledImage), hotX, hotY));
}
+21 -2
View File
@@ -2,6 +2,7 @@
#define ORBITHUB_VNC_DISPLAY_WIDGET_H
#include <QImage>
#include <QPoint>
#include <QWidget>
class QKeyEvent;
@@ -12,8 +13,7 @@ class QTimer;
class QWheelEvent;
// Renders a VNC framebuffer and forwards local input, scaled-to-fit --
// same shape as RdpDisplayWidget, minus cursor-shape sync (out of scope for
// the initial VNC implementation; the system cursor is left alone).
// same shape as RdpDisplayWidget, including remote cursor-shape sync.
class VncDisplayWidget : public QWidget
{
Q_OBJECT
@@ -38,6 +38,15 @@ public:
return m_scaleToFit;
}
// Mirrors RdpDisplayWidget's cursor handling. VNC's Cursor pseudo-
// encoding never signals "reset to default" the way RDP's SetDefault
// callback does -- it only ever supplies a shape or hides the cursor --
// so setCursorDefault() exists for symmetry/future use but VNC sessions
// never call it today.
void setCursorImage(const QImage& image, const QPoint& hotspot);
void setCursorHidden();
void setCursorDefault();
signals:
void keyInput(int key, quint32 nativeScanCode, const QString& text, bool pressed, int modifiers);
void mouseMoveInput(int x, int y);
@@ -59,10 +68,19 @@ protected:
bool focusNextPrevChild(bool next) override;
private:
enum class CursorMode {
Default,
Hidden,
Custom,
};
QImage m_frame;
QSize m_remoteSize;
QTimer* m_resizeDebounceTimer;
bool m_scaleToFit;
QImage m_cursorImage;
QPoint m_cursorHotspot;
CursorMode m_cursorMode;
QRectF renderRect() const;
QPoint mapToRemote(const QPointF& pos) const;
@@ -70,6 +88,7 @@ private:
void emitViewportGeometry();
void scheduleViewportGeometryEmit();
void applySizeConstraint();
void applyCursor();
};
#endif
+64 -1
View File
@@ -32,8 +32,13 @@ constexpr quint8 kServerMsgServerCutText = 3;
// of truth for what we tell the server we can decode via SetEncodings.
constexpr qint32 kEncRaw = 0;
constexpr qint32 kEncCopyRect = 1;
// RFC 6143 SS7.8.2 "Cursor pseudo-encoding": not a real screen-content
// encoding -- a rectangle with this type carries a cursor shape update
// instead (hotspot in x/y, image dims in width/height), never painted into
// the framebuffer.
constexpr qint32 kEncCursor = -239;
constexpr std::array<qint32, 2> kAnnouncedEncodings = { kEncRaw, kEncCopyRect };
constexpr std::array<qint32, 3> kAnnouncedEncodings = { kEncRaw, kEncCopyRect, kEncCursor };
quint16 readU16BE(const QByteArray& buf, int offset)
{
@@ -320,6 +325,7 @@ void VncSessionBackend::onSocketDisconnected()
&& m_rfbState != RfbState::WaitingRectangleHeader
&& m_rfbState != RfbState::WaitingRawPixelData
&& m_rfbState != RfbState::WaitingCopyRectSource
&& m_rfbState != RfbState::WaitingCursorPixelData
&& m_rfbState != RfbState::WaitingSetColourMapHeader
&& m_rfbState != RfbState::WaitingSetColourMapData
&& m_rfbState != RfbState::WaitingServerCutTextHeader
@@ -810,6 +816,9 @@ void VncSessionBackend::processReceiveBuffer()
case kEncCopyRect:
m_rfbState = RfbState::WaitingCopyRectSource;
break;
case kEncCursor:
m_rfbState = RfbState::WaitingCursorPixelData;
break;
default: {
// SetEncodings (see kAnnouncedEncodings) is entirely
// client-controlled, so a spec-compliant server will never
@@ -878,6 +887,60 @@ void VncSessionBackend::processReceiveBuffer()
break;
}
case RfbState::WaitingCursorPixelData: {
// Cursor pseudo-encoding (RFC 6143 SS7.8.2): x/y in the already-
// parsed rectangle header are the hotspot, not screen position;
// width/height are the cursor image's own dimensions. Never
// painted into m_framebuffer. Payload is width*height pixels in
// our negotiated 32bpp format, followed by a row-padded,
// MSB-first-per-byte opacity bitmask.
const int width = m_currentRectangle.width;
const int height = m_currentRectangle.height;
const qint64 maskRowBytes = (static_cast<qint64>(width) + 7) / 8;
const qint64 pixelBytes = static_cast<qint64>(width) * height * 4;
const qint64 maskBytes = maskRowBytes * height;
const qint64 totalBytes = pixelBytes + maskBytes;
if (totalBytes < 0 || totalBytes > std::numeric_limits<int>::max()) {
failConnection(QStringLiteral("The VNC server sent an implausibly large cursor image."),
QStringLiteral("Cursor rectangle %1x%2").arg(width).arg(height));
return;
}
if (!haveBytes(static_cast<int>(totalBytes))) {
return;
}
// A 0x0 cursor rectangle is the spec's way of saying "hide the
// cursor"; treat any other degenerate (zero-area) size the same
// way rather than trying to build an empty QImage.
if (width <= 0 || height <= 0) {
emit cursorHidden();
} else {
QImage cursorImage(width, height, QImage::Format_ARGB32);
const auto* pixelData = reinterpret_cast<const uchar*>(m_recvBuffer.constData());
const uchar* maskData = pixelData + pixelBytes;
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
const uchar* px = pixelData + ((static_cast<qint64>(y) * width + x) * 4);
// Matches our negotiated SetPixelFormat: little-
// endian 32bpp, R at shift 16 / G at 8 / B at 0 --
// byte order B,G,R,pad.
const uchar b = px[0];
const uchar g = px[1];
const uchar r = px[2];
const uchar maskByte = maskData[y * maskRowBytes + (x / 8)];
const bool opaque = (maskByte & (0x80 >> (x % 8))) != 0;
cursorImage.setPixel(x, y, qRgba(r, g, b, opaque ? 255 : 0));
}
}
emit cursorImageChanged(cursorImage,
QPoint(m_currentRectangle.x, m_currentRectangle.y));
}
m_recvBuffer.remove(0, static_cast<int>(totalBytes));
onRectangleFinished();
break;
}
case RfbState::WaitingSetColourMapHeader: {
if (!haveBytes(5)) {
return;
+4 -2
View File
@@ -21,8 +21,9 @@ class QTcpSocket;
// Scope (see plan / issue #3 for the full rationale): standard VNC
// Authentication (security type 2) and no-auth (type 1) only -- not
// Apple's Screen Sharing scheme (type 30). Raw + CopyRect encodings only.
// No dynamic resize, no remote cursor shape sync. Clipboard sync (Latin-1
// only, per RFB's ServerCutText/ClientCutText) is supported.
// No dynamic resize. Clipboard sync (Latin-1 only, per RFB's
// ServerCutText/ClientCutText) and remote cursor shape sync (the Cursor
// pseudo-encoding) are supported.
class VncSessionBackend : public SessionBackend
{
Q_OBJECT
@@ -81,6 +82,7 @@ private:
WaitingRectangleHeader,
WaitingRawPixelData,
WaitingCopyRectSource,
WaitingCursorPixelData,
WaitingSetColourMapHeader,
WaitingSetColourMapData,
WaitingServerCutTextHeader,