Internal
Public Access
The RDP session pipeline never accounted for display scale: it
requested a desktop canvas sized in Qt logical pixels (never
multiplied by devicePixelRatio()), and FreeRDP_DesktopScaleFactor/
DeviceScaleFactor were read but never actually set anywhere. On a
HiDPI monitor this meant the remote session rendered assuming a
96 DPI / 100% display, and the resulting canvas got stretched
locally — ClearType's subpixel hinting doesn't survive that kind of
resampling, producing distorted glyph shapes and color fringing
rather than plain blur.
RdpDisplayWidget now reports physical pixel dimensions and the real
devicePixelRatio (recomputed on resize and on screen changes, e.g.
dragging the window to a different-DPI monitor). RdpSessionBackend
maps that to the nearest FreeRDP-legal scale value ({100, 140, 180},
per MS-RDPEDISP and FreeRDP's own reference client) and sets it at
both connect time and on every dynamic resize, including the
FreeRDP_MonitorOverrideFlags required for the values to actually be
honored rather than silently ignored.
While testing this against real infrastructure, found and fixed two
related (pre-existing, not caused by this change) resize issues:
- A stale-frame race where the old frame could be drawn at the wrong
scale for a moment after a resize, before a correctly-sized one
arrives — now the frame is cleared during that transition instead.
- No debounce on outgoing resize requests — every single resize event
fired an immediate request to the server, which can visibly
contribute to host-side redraw glitches during rapid layout churn
(e.g. right after connecting). Coalesced into one request per burst,
plus an explicit refresh-rect request after each resize completes
as a best-effort nudge for hosts that don't fully repaint on their
own.
A separate, deeper issue was also found during testing (the remote
guest's actual resolution sometimes not changing despite the resize
channel reporting success) and is tracked separately, not fixed here.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
335 lines
9.8 KiB
C++
335 lines
9.8 KiB
C++
#include "rdp_display_widget.h"
|
|
|
|
#include <QCursor>
|
|
#include <QEvent>
|
|
#include <QKeyEvent>
|
|
#include <QMouseEvent>
|
|
#include <QPainter>
|
|
#include <QPixmap>
|
|
#include <QResizeEvent>
|
|
#include <QTimer>
|
|
#include <QWheelEvent>
|
|
#include <QtGlobal>
|
|
|
|
namespace {
|
|
QSize sanitizeSize(const QSize& size)
|
|
{
|
|
return QSize(qMax(1, size.width()), qMax(1, size.height()));
|
|
}
|
|
|
|
qreal sanitizeDevicePixelRatio(qreal ratio)
|
|
{
|
|
if (!(ratio > 0.0)) {
|
|
return 1.0;
|
|
}
|
|
return qBound(1.0, ratio, 4.0);
|
|
}
|
|
|
|
// Windows' virtual-display driver can visibly glitch (stale composited
|
|
// content left on screen) when asked to change resolution repeatedly in
|
|
// quick succession, which naturally happens as the window's layout settles
|
|
// right after creation/connect. Coalescing bursts of resize events into one
|
|
// request avoids triggering that.
|
|
constexpr int kResizeDebounceMs = 150;
|
|
}
|
|
|
|
RdpDisplayWidget::RdpDisplayWidget(QWidget* parent)
|
|
: QWidget(parent),
|
|
m_remoteSize(1280, 720),
|
|
m_cursorMode(CursorMode::Default),
|
|
m_resizeDebounceTimer(new QTimer(this))
|
|
{
|
|
setFocusPolicy(Qt::StrongFocus);
|
|
setMouseTracking(true);
|
|
setAutoFillBackground(false);
|
|
setMinimumSize(320, 200);
|
|
|
|
m_resizeDebounceTimer->setSingleShot(true);
|
|
connect(m_resizeDebounceTimer, &QTimer::timeout, this, &RdpDisplayWidget::emitViewportGeometry);
|
|
|
|
scheduleViewportGeometryEmit();
|
|
}
|
|
|
|
void RdpDisplayWidget::setFrame(const QImage& frame)
|
|
{
|
|
if (frame.isNull()) {
|
|
return;
|
|
}
|
|
|
|
m_frame = frame;
|
|
m_remoteSize = sanitizeSize(frame.size());
|
|
update();
|
|
}
|
|
|
|
void RdpDisplayWidget::setRemoteDesktopSize(int width, int height)
|
|
{
|
|
if (width < 1 || height < 1) {
|
|
return;
|
|
}
|
|
|
|
const QSize nextSize(width, height);
|
|
if (m_remoteSize == nextSize) {
|
|
return;
|
|
}
|
|
|
|
m_remoteSize = nextSize;
|
|
// The next actual frame (via setFrame) arrives asynchronously and isn't
|
|
// guaranteed to be sized to match yet. Drawing the old frame stretched
|
|
// to a renderRect() computed from the new m_remoteSize would scale it
|
|
// by the wrong factor for the transition window, producing visibly
|
|
// distorted/duplicated-looking content. Clear it and show the existing
|
|
// "waiting for frame" placeholder until a correctly-sized frame lands.
|
|
m_frame = QImage();
|
|
update();
|
|
}
|
|
|
|
void RdpDisplayWidget::clearFrame()
|
|
{
|
|
m_frame = QImage();
|
|
update();
|
|
}
|
|
|
|
void RdpDisplayWidget::setCursorImage(const QImage& image, const QPoint& hotspot)
|
|
{
|
|
m_cursorImage = image;
|
|
m_cursorHotspot = hotspot;
|
|
m_cursorMode = CursorMode::Custom;
|
|
applyCursor();
|
|
}
|
|
|
|
void RdpDisplayWidget::setCursorHidden()
|
|
{
|
|
m_cursorMode = CursorMode::Hidden;
|
|
applyCursor();
|
|
}
|
|
|
|
void RdpDisplayWidget::setCursorDefault()
|
|
{
|
|
m_cursorMode = CursorMode::Default;
|
|
applyCursor();
|
|
}
|
|
|
|
void RdpDisplayWidget::applyCursor()
|
|
{
|
|
if (m_cursorMode == CursorMode::Hidden) {
|
|
setCursor(Qt::BlankCursor);
|
|
return;
|
|
}
|
|
|
|
if (m_cursorMode == CursorMode::Default || m_cursorImage.isNull()) {
|
|
unsetCursor();
|
|
return;
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
void RdpDisplayWidget::paintEvent(QPaintEvent* event)
|
|
{
|
|
Q_UNUSED(event);
|
|
|
|
QPainter painter(this);
|
|
painter.fillRect(rect(), QColor(QStringLiteral("#101214")));
|
|
|
|
const QRectF target = renderRect();
|
|
if (!m_frame.isNull()) {
|
|
painter.drawImage(target, m_frame);
|
|
} else {
|
|
painter.setPen(QColor(QStringLiteral("#b0bec5")));
|
|
painter.drawText(rect(),
|
|
Qt::AlignCenter,
|
|
QStringLiteral("Waiting for remote desktop frame..."));
|
|
}
|
|
}
|
|
|
|
void RdpDisplayWidget::resizeEvent(QResizeEvent* event)
|
|
{
|
|
QWidget::resizeEvent(event);
|
|
scheduleViewportGeometryEmit();
|
|
applyCursor();
|
|
}
|
|
|
|
bool RdpDisplayWidget::event(QEvent* event)
|
|
{
|
|
// Fires when this widget's effective screen changes (e.g. dragged to a
|
|
// different monitor), which is what changes devicePixelRatio(). Newer
|
|
// Qt versions add a more specific QEvent::DevicePixelRatioChange, but
|
|
// this project's Qt 6.2 floor doesn't have it.
|
|
if (event->type() == QEvent::ScreenChangeInternal) {
|
|
scheduleViewportGeometryEmit();
|
|
}
|
|
return QWidget::event(event);
|
|
}
|
|
|
|
void RdpDisplayWidget::scheduleViewportGeometryEmit()
|
|
{
|
|
// Restarting an already-running single-shot timer resets its countdown,
|
|
// so a burst of resize events collapses into one emission after things
|
|
// settle, rather than one request per event.
|
|
m_resizeDebounceTimer->start(kResizeDebounceMs);
|
|
}
|
|
|
|
void RdpDisplayWidget::emitViewportGeometry()
|
|
{
|
|
const QSize logicalSize = sanitizeSize(this->size());
|
|
const qreal ratio = sanitizeDevicePixelRatio(this->devicePixelRatioF());
|
|
const QSize physicalSize(qRound(logicalSize.width() * ratio), qRound(logicalSize.height() * ratio));
|
|
emit viewportSizeChanged(physicalSize.width(), physicalSize.height());
|
|
emit displayScaleChanged(ratio);
|
|
}
|
|
|
|
void RdpDisplayWidget::keyPressEvent(QKeyEvent* event)
|
|
{
|
|
if (event == nullptr) {
|
|
return;
|
|
}
|
|
|
|
// Auto-repeat presses must reach the remote server so it can perform
|
|
// its own typematic repeat, exactly as a physical keyboard held down
|
|
// would. Only release events filter out isAutoRepeat() (below), since
|
|
// Qt uses a synthetic release/press pair purely to normalize platform
|
|
// auto-repeat quirks -- forwarding that synthetic release would send a
|
|
// spurious key-up for a key that is still physically held.
|
|
emit keyInput(event->key(),
|
|
event->nativeScanCode(),
|
|
event->text(),
|
|
true,
|
|
static_cast<int>(event->modifiers()));
|
|
event->accept();
|
|
}
|
|
|
|
void RdpDisplayWidget::keyReleaseEvent(QKeyEvent* event)
|
|
{
|
|
if (event == nullptr || event->isAutoRepeat()) {
|
|
return;
|
|
}
|
|
|
|
emit keyInput(event->key(),
|
|
event->nativeScanCode(),
|
|
event->text(),
|
|
false,
|
|
static_cast<int>(event->modifiers()));
|
|
event->accept();
|
|
}
|
|
|
|
bool RdpDisplayWidget::focusNextPrevChild(bool next)
|
|
{
|
|
Q_UNUSED(next);
|
|
// Tab/Shift+Tab must reach keyPressEvent() and be forwarded to the
|
|
// remote session instead of moving focus to the next local widget.
|
|
return false;
|
|
}
|
|
|
|
void RdpDisplayWidget::mousePressEvent(QMouseEvent* event)
|
|
{
|
|
if (event == nullptr) {
|
|
return;
|
|
}
|
|
|
|
setFocus(Qt::MouseFocusReason);
|
|
const QPoint mapped = mapToRemote(event->position());
|
|
emit mouseButtonInput(mapped.x(), mapped.y(), static_cast<int>(event->button()), true);
|
|
event->accept();
|
|
}
|
|
|
|
void RdpDisplayWidget::mouseReleaseEvent(QMouseEvent* event)
|
|
{
|
|
if (event == nullptr) {
|
|
return;
|
|
}
|
|
|
|
const QPoint mapped = mapToRemote(event->position());
|
|
emit mouseButtonInput(mapped.x(), mapped.y(), static_cast<int>(event->button()), false);
|
|
event->accept();
|
|
}
|
|
|
|
void RdpDisplayWidget::mouseMoveEvent(QMouseEvent* event)
|
|
{
|
|
if (event == nullptr) {
|
|
return;
|
|
}
|
|
|
|
const QPoint mapped = mapToRemote(event->position());
|
|
emit mouseMoveInput(mapped.x(), mapped.y());
|
|
event->accept();
|
|
}
|
|
|
|
void RdpDisplayWidget::wheelEvent(QWheelEvent* event)
|
|
{
|
|
if (event == nullptr) {
|
|
return;
|
|
}
|
|
|
|
const QPoint mapped = mapToRemote(event->position());
|
|
const QPoint angle = event->angleDelta();
|
|
emit mouseWheelInput(mapped.x(), mapped.y(), angle.x(), angle.y());
|
|
event->accept();
|
|
}
|
|
|
|
QRectF RdpDisplayWidget::renderRect() const
|
|
{
|
|
const QSize remote = effectiveRemoteSize();
|
|
const QRectF area = rect();
|
|
if (area.isEmpty()) {
|
|
return QRectF();
|
|
}
|
|
|
|
const qreal scale = qMin(area.width() / remote.width(), area.height() / remote.height());
|
|
const qreal drawWidth = remote.width() * scale;
|
|
const qreal drawHeight = remote.height() * scale;
|
|
const qreal x = area.x() + ((area.width() - drawWidth) * 0.5);
|
|
const qreal y = area.y() + ((area.height() - drawHeight) * 0.5);
|
|
return QRectF(x, y, drawWidth, drawHeight);
|
|
}
|
|
|
|
QPoint RdpDisplayWidget::mapToRemote(const QPointF& pos) const
|
|
{
|
|
const QSize remote = effectiveRemoteSize();
|
|
const QRectF target = renderRect();
|
|
if (target.isEmpty()) {
|
|
return QPoint(0, 0);
|
|
}
|
|
|
|
const qreal clampedX = qBound(target.left(), pos.x(), target.right());
|
|
const qreal clampedY = qBound(target.top(), pos.y(), target.bottom());
|
|
|
|
const qreal normalizedX = (clampedX - target.left()) / qMax(1.0, target.width());
|
|
const qreal normalizedY = (clampedY - target.top()) / qMax(1.0, target.height());
|
|
|
|
const int remoteX = qBound(0, static_cast<int>(normalizedX * remote.width()), remote.width() - 1);
|
|
const int remoteY = qBound(0, static_cast<int>(normalizedY * remote.height()), remote.height() - 1);
|
|
return QPoint(remoteX, remoteY);
|
|
}
|
|
|
|
QSize RdpDisplayWidget::effectiveRemoteSize() const
|
|
{
|
|
if (m_remoteSize.width() > 0 && m_remoteSize.height() > 0) {
|
|
return m_remoteSize;
|
|
}
|
|
if (!m_frame.isNull()) {
|
|
return sanitizeSize(m_frame.size());
|
|
}
|
|
return QSize(1280, 720);
|
|
}
|