From 0b5454197c29e26890516e50e6bd9e4d10ca2fd1 Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Tue, 15 Sep 2026 18:13:43 -0600 Subject: [PATCH] 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 --- docs/PROGRESS.md | 16 +++++++++++++--- src/session_tab.cpp | 34 +++++++++++++++++++++++++++++++++- src/session_tab.h | 7 +++++++ src/session_window.cpp | 28 ++++++++++++++++++++++++++++ src/vnc_display_widget.cpp | 34 +++++++++++++++++++++++++++++++++- src/vnc_display_widget.h | 15 +++++++++++++++ 6 files changed, 129 insertions(+), 5 deletions(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 67c997a..ef2be33 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -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 diff --git a/src/session_tab.cpp b/src/session_tab.cpp index 01d9f66..b30bfee 100644 --- a/src/session_tab.cpp +++ b/src/session_tab.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -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(); diff --git a/src/session_tab.h b/src/session_tab.h index 037eff2..d83090a 100644 --- a/src/session_tab.h +++ b/src/session_tab.h @@ -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; diff --git a/src/session_window.cpp b/src/session_window.cpp index b3376e4..2b5be3d 100644 --- a/src/session_window.cpp +++ b/src/session_window.cpp @@ -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); } diff --git a/src/vnc_display_widget.cpp b/src/vnc_display_widget.cpp index a9cc50f..589e45a 100644 --- a/src/vnc_display_widget.cpp +++ b/src/vnc_display_widget.cpp @@ -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(); diff --git a/src/vnc_display_widget.h b/src/vnc_display_widget.h index 17241f0..af3f4bd 100644 --- a/src/vnc_display_widget.h +++ b/src/vnc_display_widget.h @@ -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