Add VNC-only scale-to-fit vs actual-size display toggle

VNC has no equivalent of RDP's MS-RDPEDISP to request a different
resolution from the guest, so a high-resolution remote desktop
previously always got shrunk to fit the window, making text
illegible. Adds a per-tab "Display Mode" choice (tab-bar right-click)
between the existing scale-to-fit behavior and a new actual-size mode
that renders the framebuffer at its native pixel size inside a
QScrollArea. Reuses VncDisplayWidget's existing scale-to-fit render
math unchanged -- it degenerates to an exact 1:1 mapping once the
widget is fixed to the remote's own size. Persisted like the terminal
theme preference.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 18:13:43 -06:00
co-authored by Claude Sonnet 5
parent 6da9dc6ca5
commit 0b5454197c
6 changed files with 129 additions and 5 deletions
+13 -3
View File
@@ -133,6 +133,15 @@ Delivered:
- Verified live against a real, independently implemented VNC server
(TightVNC on Windows): connect with VNC Authentication, correct
framebuffer dimensions and pixel data, clean disconnect, reconnect
- Per-tab VNC-only display mode toggle (tab-bar right-click ->
`Display Mode`): `Scale to Fit` (default, matches RDP's behavior) or
`Actual Size (Scrollbars)` -- renders the remote framebuffer at its
native pixel size inside a `QScrollArea` so text isn't shrunk, at the
cost of needing to scroll to see the whole screen. Reuses
`VncDisplayWidget::renderRect()`'s existing scale-to-fit math unchanged:
it degenerates to an exact 1:1 mapping once the widget's own bounds are
fixed to the remote's size, so no separate rendering path was needed.
Persisted across sessions like the terminal theme preference.
Known gaps (explicit scope decisions, not oversights -- see issue #3 for
follow-up tracking):
@@ -141,9 +150,10 @@ follow-up tracking):
server -- only standard VNC Authentication (type 2) and no-auth (type 1)
- Raw + CopyRect encodings only -- no Hextile/ZRLE/Tight compression, so
bandwidth usage is higher over slow links than a full VNC client
- No dynamic resize (connects at the server's native resolution, scaled to
fit locally -- the same way `RdpDisplayWidget` already renders
regardless of server resolution, so not a UX regression vs. RDP)
- No dynamic resize (connects at the server's native resolution; the
`Scale to Fit`/`Actual Size` toggle changes how that fixed resolution is
displayed locally, not what resolution is requested from the guest --
VNC has no equivalent of RDP's MS-RDPEDISP for that)
- No remote cursor shape sync (local default cursor only)
- No clipboard sync
+33 -1
View File
@@ -24,6 +24,7 @@
#include <QComboBox>
#include <QProcessEnvironment>
#include <QPushButton>
#include <QScrollArea>
#include <QThread>
#include <QTimer>
#include <QToolButton>
@@ -81,6 +82,7 @@ SessionTab::SessionTab(const Profile& profile,
m_sshTerminal(nullptr),
m_rdpDisplay(nullptr),
m_vncDisplay(nullptr),
m_vncScrollArea(nullptr),
m_terminalOutput(nullptr),
m_eventLog(nullptr),
m_toggleEventsButton(nullptr),
@@ -102,6 +104,10 @@ SessionTab::SessionTab(const Profile& profile,
setupUi();
if (m_vncDisplay != nullptr) {
m_vncDisplay->setScaleToFit(preferences.vncScaleToFit);
}
if (m_useKodoTermForSsh) {
connect(m_sshTerminal,
&KodoTerm::finished,
@@ -457,6 +463,28 @@ bool SessionTab::supportsZoom() const
return m_useKodoTermForSsh || m_terminalOutput != nullptr;
}
bool SessionTab::supportsVncScaleToggle() const
{
return m_vncDisplay != nullptr;
}
void SessionTab::setVncScaleToFit(bool scaleToFit)
{
if (m_vncDisplay == nullptr || m_vncDisplay->scaleToFit() == scaleToFit) {
return;
}
m_vncDisplay->setScaleToFit(scaleToFit);
appendEvent(scaleToFit ? QStringLiteral("Display mode set to scale to fit.")
: QStringLiteral("Display mode set to actual size."));
emit vncScaleModeChanged(scaleToFit);
}
bool SessionTab::vncScaleToFit() const
{
return m_vncDisplay != nullptr ? m_vncDisplay->scaleToFit() : true;
}
void SessionTab::zoomIn()
{
if (m_useKodoTermForSsh && m_sshTerminal != nullptr) {
@@ -731,7 +759,11 @@ void SessionTab::setupUi()
rootLayout->addWidget(m_rdpDisplay, 1);
} else if (m_profile.protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0) {
m_vncDisplay = new VncDisplayWidget(this);
rootLayout->addWidget(m_vncDisplay, 1);
m_vncScrollArea = new QScrollArea(this);
m_vncScrollArea->setWidget(m_vncDisplay);
m_vncScrollArea->setWidgetResizable(true);
m_vncScrollArea->setFrameShape(QFrame::NoFrame);
rootLayout->addWidget(m_vncScrollArea, 1);
} else {
m_terminalOutput = new TerminalView(this);
QFont fallbackFont = defaultTerminalFont();
+7
View File
@@ -23,6 +23,7 @@ class QLineEdit;
class QComboBox;
class QLabel;
class QPushButton;
class QScrollArea;
class KodoTerm;
struct SessionUiPreferences
@@ -30,6 +31,7 @@ struct SessionUiPreferences
QString terminalThemeName = QStringLiteral("Dark");
bool eventsPanelExpanded = false;
int terminalFontPointSize = 0;
bool vncScaleToFit = true;
};
class SessionTab : public QWidget
@@ -62,6 +64,9 @@ public:
void clearEvents();
void copyEvents() const;
void exportEventsToFile();
bool supportsVncScaleToggle() const;
void setVncScaleToFit(bool scaleToFit);
bool vncScaleToFit() const;
signals:
void tabTitleChanged(const QString& title);
@@ -69,6 +74,7 @@ signals:
void terminalThemeChanged(const QString& themeName);
void terminalFontSizeChanged(int pointSize);
void eventsPanelVisibilityChanged(bool expanded);
void vncScaleModeChanged(bool scaleToFit);
void requestConnect(const SessionConnectOptions& options);
void requestDisconnect();
void requestReconnect(const SessionConnectOptions& options);
@@ -111,6 +117,7 @@ private:
KodoTerm* m_sshTerminal;
RdpDisplayWidget* m_rdpDisplay;
VncDisplayWidget* m_vncDisplay;
QScrollArea* m_vncScrollArea;
TerminalView* m_terminalOutput;
QPlainTextEdit* m_eventLog;
QToolButton* m_toggleEventsButton;
+28
View File
@@ -120,6 +120,20 @@ SessionWindow::SessionWindow(QWidget* parent)
setFontSizeAction = menu.addAction(QStringLiteral("Set Font Size..."));
}
QAction* scaleToFitAction = nullptr;
QAction* actualSizeAction = nullptr;
if (tab->supportsVncScaleToggle()) {
menu.addSeparator();
QMenu* displayMenu = menu.addMenu(QStringLiteral("Display Mode"));
scaleToFitAction = displayMenu->addAction(QStringLiteral("Scale to Fit"));
scaleToFitAction->setCheckable(true);
scaleToFitAction->setChecked(tab->vncScaleToFit());
actualSizeAction = displayMenu->addAction(
QStringLiteral("Actual Size (Scrollbars)"));
actualSizeAction->setCheckable(true);
actualSizeAction->setChecked(!tab->vncScaleToFit());
}
QAction* chosen = menu.exec(m_tabs->tabBar()->mapToGlobal(pos));
if (chosen == disconnectAction) {
tab->disconnectSession();
@@ -156,6 +170,10 @@ SessionWindow::SessionWindow(QWidget* parent)
if (accepted) {
tab->setTerminalFontPointSize(newSize);
}
} else if (scaleToFitAction != nullptr && chosen == scaleToFitAction) {
tab->setVncScaleToFit(true);
} else if (actualSizeAction != nullptr && chosen == actualSizeAction) {
tab->setVncScaleToFit(false);
} else {
for (QAction* themeAction : themeActions) {
if (chosen == themeAction) {
@@ -288,6 +306,13 @@ void SessionWindow::addSessionTab(const Profile& profile)
m_preferences.eventsPanelExpanded = expanded;
saveUiPreferences();
});
connect(tab,
&SessionTab::vncScaleModeChanged,
this,
[this](bool scaleToFit) {
m_preferences.vncScaleToFit = scaleToFit;
saveUiPreferences();
});
}
void SessionWindow::updateTabTitle(SessionTab* tab, const QString& title)
@@ -319,6 +344,8 @@ void SessionWindow::loadUiPreferences()
settings.value(QStringLiteral("session/eventsPanelExpanded"), false).toBool();
m_preferences.terminalFontPointSize =
settings.value(QStringLiteral("session/terminalFontPointSize"), 0).toInt();
m_preferences.vncScaleToFit =
settings.value(QStringLiteral("session/vncScaleToFit"), true).toBool();
}
void SessionWindow::saveUiPreferences() const
@@ -330,4 +357,5 @@ void SessionWindow::saveUiPreferences() const
m_preferences.eventsPanelExpanded);
settings.setValue(QStringLiteral("session/terminalFontPointSize"),
m_preferences.terminalFontPointSize);
settings.setValue(QStringLiteral("session/vncScaleToFit"), m_preferences.vncScaleToFit);
}
+33 -1
View File
@@ -27,7 +27,10 @@ constexpr int kResizeDebounceMs = 150;
}
VncDisplayWidget::VncDisplayWidget(QWidget* parent)
: QWidget(parent), m_remoteSize(1280, 720), m_resizeDebounceTimer(new QTimer(this))
: QWidget(parent),
m_remoteSize(1280, 720),
m_resizeDebounceTimer(new QTimer(this)),
m_scaleToFit(true)
{
setFocusPolicy(Qt::StrongFocus);
setMouseTracking(true);
@@ -48,6 +51,7 @@ void VncDisplayWidget::setFrame(const QImage& frame)
m_frame = frame;
m_remoteSize = sanitizeSize(frame.size());
applySizeConstraint();
update();
}
@@ -68,9 +72,37 @@ void VncDisplayWidget::setRemoteDesktopSize(int width, int height)
// size, so drop the stale one rather than stretch it by the wrong
// factor until a correctly-sized frame lands.
m_frame = QImage();
applySizeConstraint();
update();
}
void VncDisplayWidget::setScaleToFit(bool scaleToFit)
{
if (m_scaleToFit == scaleToFit) {
return;
}
m_scaleToFit = scaleToFit;
applySizeConstraint();
update();
}
void VncDisplayWidget::applySizeConstraint()
{
if (m_scaleToFit) {
// Let the widget follow whatever it's placed in again (e.g. a
// QScrollArea in resizable mode, or a plain layout).
setMinimumSize(320, 200);
setMaximumSize(QWIDGETSIZE_MAX, QWIDGETSIZE_MAX);
} else {
// Fixed at the remote's actual pixel size. renderRect()'s
// scale-to-fit math naturally degenerates to an unscaled 1:1
// mapping once the widget's own bounds already equal the remote
// size, so no separate "actual size" rendering path is needed --
// this is the only thing that differs between the two modes.
setFixedSize(effectiveRemoteSize());
}
}
void VncDisplayWidget::clearFrame()
{
m_frame = QImage();
+15
View File
@@ -25,6 +25,19 @@ public:
void setRemoteDesktopSize(int width, int height);
void clearFrame();
// true (default): scale the whole remote screen to fit the widget,
// like RdpDisplayWidget. false: render at the remote's actual pixel
// size -- meant to be placed inside a QScrollArea, whose scrollbars
// then let the user pan around a screen larger than the window
// instead of shrinking small text to illegibility. VNC has no
// equivalent of RDP's MS-RDPEDISP to request a different resolution
// from the guest, so this is the only way to see it at native size.
void setScaleToFit(bool scaleToFit);
bool scaleToFit() const
{
return m_scaleToFit;
}
signals:
void keyInput(int key, quint32 nativeScanCode, const QString& text, bool pressed, int modifiers);
void mouseMoveInput(int x, int y);
@@ -49,12 +62,14 @@ private:
QImage m_frame;
QSize m_remoteSize;
QTimer* m_resizeDebounceTimer;
bool m_scaleToFit;
QRectF renderRect() const;
QPoint mapToRemote(const QPointF& pos) const;
QSize effectiveRemoteSize() const;
void emitViewportGeometry();
void scheduleViewportGeometryEmit();
void applySizeConstraint();
};
#endif