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 - Verified live against a real, independently implemented VNC server
(TightVNC on Windows): connect with VNC Authentication, correct (TightVNC on Windows): connect with VNC Authentication, correct
framebuffer dimensions and pixel data, clean disconnect, reconnect 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 Known gaps (explicit scope decisions, not oversights -- see issue #3 for
follow-up tracking): follow-up tracking):
@@ -141,9 +150,10 @@ follow-up tracking):
server -- only standard VNC Authentication (type 2) and no-auth (type 1) server -- only standard VNC Authentication (type 2) and no-auth (type 1)
- Raw + CopyRect encodings only -- no Hextile/ZRLE/Tight compression, so - Raw + CopyRect encodings only -- no Hextile/ZRLE/Tight compression, so
bandwidth usage is higher over slow links than a full VNC client bandwidth usage is higher over slow links than a full VNC client
- No dynamic resize (connects at the server's native resolution, scaled to - No dynamic resize (connects at the server's native resolution; the
fit locally -- the same way `RdpDisplayWidget` already renders `Scale to Fit`/`Actual Size` toggle changes how that fixed resolution is
regardless of server resolution, so not a UX regression vs. RDP) 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 remote cursor shape sync (local default cursor only)
- No clipboard sync - No clipboard sync
+33 -1
View File
@@ -24,6 +24,7 @@
#include <QComboBox> #include <QComboBox>
#include <QProcessEnvironment> #include <QProcessEnvironment>
#include <QPushButton> #include <QPushButton>
#include <QScrollArea>
#include <QThread> #include <QThread>
#include <QTimer> #include <QTimer>
#include <QToolButton> #include <QToolButton>
@@ -81,6 +82,7 @@ SessionTab::SessionTab(const Profile& profile,
m_sshTerminal(nullptr), m_sshTerminal(nullptr),
m_rdpDisplay(nullptr), m_rdpDisplay(nullptr),
m_vncDisplay(nullptr), m_vncDisplay(nullptr),
m_vncScrollArea(nullptr),
m_terminalOutput(nullptr), m_terminalOutput(nullptr),
m_eventLog(nullptr), m_eventLog(nullptr),
m_toggleEventsButton(nullptr), m_toggleEventsButton(nullptr),
@@ -102,6 +104,10 @@ SessionTab::SessionTab(const Profile& profile,
setupUi(); setupUi();
if (m_vncDisplay != nullptr) {
m_vncDisplay->setScaleToFit(preferences.vncScaleToFit);
}
if (m_useKodoTermForSsh) { if (m_useKodoTermForSsh) {
connect(m_sshTerminal, connect(m_sshTerminal,
&KodoTerm::finished, &KodoTerm::finished,
@@ -457,6 +463,28 @@ bool SessionTab::supportsZoom() const
return m_useKodoTermForSsh || m_terminalOutput != nullptr; 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() void SessionTab::zoomIn()
{ {
if (m_useKodoTermForSsh && m_sshTerminal != nullptr) { if (m_useKodoTermForSsh && m_sshTerminal != nullptr) {
@@ -731,7 +759,11 @@ void SessionTab::setupUi()
rootLayout->addWidget(m_rdpDisplay, 1); rootLayout->addWidget(m_rdpDisplay, 1);
} else if (m_profile.protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0) { } else if (m_profile.protocol.compare(QStringLiteral("VNC"), Qt::CaseInsensitive) == 0) {
m_vncDisplay = new VncDisplayWidget(this); 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 { } else {
m_terminalOutput = new TerminalView(this); m_terminalOutput = new TerminalView(this);
QFont fallbackFont = defaultTerminalFont(); QFont fallbackFont = defaultTerminalFont();
+7
View File
@@ -23,6 +23,7 @@ class QLineEdit;
class QComboBox; class QComboBox;
class QLabel; class QLabel;
class QPushButton; class QPushButton;
class QScrollArea;
class KodoTerm; class KodoTerm;
struct SessionUiPreferences struct SessionUiPreferences
@@ -30,6 +31,7 @@ struct SessionUiPreferences
QString terminalThemeName = QStringLiteral("Dark"); QString terminalThemeName = QStringLiteral("Dark");
bool eventsPanelExpanded = false; bool eventsPanelExpanded = false;
int terminalFontPointSize = 0; int terminalFontPointSize = 0;
bool vncScaleToFit = true;
}; };
class SessionTab : public QWidget class SessionTab : public QWidget
@@ -62,6 +64,9 @@ public:
void clearEvents(); void clearEvents();
void copyEvents() const; void copyEvents() const;
void exportEventsToFile(); void exportEventsToFile();
bool supportsVncScaleToggle() const;
void setVncScaleToFit(bool scaleToFit);
bool vncScaleToFit() const;
signals: signals:
void tabTitleChanged(const QString& title); void tabTitleChanged(const QString& title);
@@ -69,6 +74,7 @@ signals:
void terminalThemeChanged(const QString& themeName); void terminalThemeChanged(const QString& themeName);
void terminalFontSizeChanged(int pointSize); void terminalFontSizeChanged(int pointSize);
void eventsPanelVisibilityChanged(bool expanded); void eventsPanelVisibilityChanged(bool expanded);
void vncScaleModeChanged(bool scaleToFit);
void requestConnect(const SessionConnectOptions& options); void requestConnect(const SessionConnectOptions& options);
void requestDisconnect(); void requestDisconnect();
void requestReconnect(const SessionConnectOptions& options); void requestReconnect(const SessionConnectOptions& options);
@@ -111,6 +117,7 @@ private:
KodoTerm* m_sshTerminal; KodoTerm* m_sshTerminal;
RdpDisplayWidget* m_rdpDisplay; RdpDisplayWidget* m_rdpDisplay;
VncDisplayWidget* m_vncDisplay; VncDisplayWidget* m_vncDisplay;
QScrollArea* m_vncScrollArea;
TerminalView* m_terminalOutput; TerminalView* m_terminalOutput;
QPlainTextEdit* m_eventLog; QPlainTextEdit* m_eventLog;
QToolButton* m_toggleEventsButton; QToolButton* m_toggleEventsButton;
+28
View File
@@ -120,6 +120,20 @@ SessionWindow::SessionWindow(QWidget* parent)
setFontSizeAction = menu.addAction(QStringLiteral("Set Font Size...")); 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)); QAction* chosen = menu.exec(m_tabs->tabBar()->mapToGlobal(pos));
if (chosen == disconnectAction) { if (chosen == disconnectAction) {
tab->disconnectSession(); tab->disconnectSession();
@@ -156,6 +170,10 @@ SessionWindow::SessionWindow(QWidget* parent)
if (accepted) { if (accepted) {
tab->setTerminalFontPointSize(newSize); tab->setTerminalFontPointSize(newSize);
} }
} else if (scaleToFitAction != nullptr && chosen == scaleToFitAction) {
tab->setVncScaleToFit(true);
} else if (actualSizeAction != nullptr && chosen == actualSizeAction) {
tab->setVncScaleToFit(false);
} else { } else {
for (QAction* themeAction : themeActions) { for (QAction* themeAction : themeActions) {
if (chosen == themeAction) { if (chosen == themeAction) {
@@ -288,6 +306,13 @@ void SessionWindow::addSessionTab(const Profile& profile)
m_preferences.eventsPanelExpanded = expanded; m_preferences.eventsPanelExpanded = expanded;
saveUiPreferences(); saveUiPreferences();
}); });
connect(tab,
&SessionTab::vncScaleModeChanged,
this,
[this](bool scaleToFit) {
m_preferences.vncScaleToFit = scaleToFit;
saveUiPreferences();
});
} }
void SessionWindow::updateTabTitle(SessionTab* tab, const QString& title) void SessionWindow::updateTabTitle(SessionTab* tab, const QString& title)
@@ -319,6 +344,8 @@ void SessionWindow::loadUiPreferences()
settings.value(QStringLiteral("session/eventsPanelExpanded"), false).toBool(); settings.value(QStringLiteral("session/eventsPanelExpanded"), false).toBool();
m_preferences.terminalFontPointSize = m_preferences.terminalFontPointSize =
settings.value(QStringLiteral("session/terminalFontPointSize"), 0).toInt(); settings.value(QStringLiteral("session/terminalFontPointSize"), 0).toInt();
m_preferences.vncScaleToFit =
settings.value(QStringLiteral("session/vncScaleToFit"), true).toBool();
} }
void SessionWindow::saveUiPreferences() const void SessionWindow::saveUiPreferences() const
@@ -330,4 +357,5 @@ void SessionWindow::saveUiPreferences() const
m_preferences.eventsPanelExpanded); m_preferences.eventsPanelExpanded);
settings.setValue(QStringLiteral("session/terminalFontPointSize"), settings.setValue(QStringLiteral("session/terminalFontPointSize"),
m_preferences.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) 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); setFocusPolicy(Qt::StrongFocus);
setMouseTracking(true); setMouseTracking(true);
@@ -48,6 +51,7 @@ void VncDisplayWidget::setFrame(const QImage& frame)
m_frame = frame; m_frame = frame;
m_remoteSize = sanitizeSize(frame.size()); m_remoteSize = sanitizeSize(frame.size());
applySizeConstraint();
update(); 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 // size, so drop the stale one rather than stretch it by the wrong
// factor until a correctly-sized frame lands. // factor until a correctly-sized frame lands.
m_frame = QImage(); m_frame = QImage();
applySizeConstraint();
update(); 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() void VncDisplayWidget::clearFrame()
{ {
m_frame = QImage(); m_frame = QImage();
+15
View File
@@ -25,6 +25,19 @@ public:
void setRemoteDesktopSize(int width, int height); void setRemoteDesktopSize(int width, int height);
void clearFrame(); 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: signals:
void keyInput(int key, quint32 nativeScanCode, const QString& text, bool pressed, int modifiers); void keyInput(int key, quint32 nativeScanCode, const QString& text, bool pressed, int modifiers);
void mouseMoveInput(int x, int y); void mouseMoveInput(int x, int y);
@@ -49,12 +62,14 @@ private:
QImage m_frame; QImage m_frame;
QSize m_remoteSize; QSize m_remoteSize;
QTimer* m_resizeDebounceTimer; QTimer* m_resizeDebounceTimer;
bool m_scaleToFit;
QRectF renderRect() const; QRectF renderRect() const;
QPoint mapToRemote(const QPointF& pos) const; QPoint mapToRemote(const QPointF& pos) const;
QSize effectiveRemoteSize() const; QSize effectiveRemoteSize() const;
void emitViewportGeometry(); void emitViewportGeometry();
void scheduleViewportGeometryEmit(); void scheduleViewportGeometryEmit();
void applySizeConstraint();
}; };
#endif #endif