Skip to content

Commit b66526a

Browse files
Merge pull request #1867 from contour-terminal/fix/initial-window-size
Fix initial terminal window size at fractional DPR
2 parents 31f451e + c25434a commit b66526a

File tree

3 files changed

+169
-26
lines changed

3 files changed

+169
-26
lines changed

metainfo.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,14 @@
113113
<li>Fixes garbled or missing characters when displaying Unicode text</li>
114114
<li>Fixes terminal shrinking/growing on tab switch when status line is displayed</li>
115115
<li>Fixes WINDOWMANIP cell-based resize (CSI 8;rows;cols t) gaining columns at non-integer content scale</li>
116+
<li>Fixes initial terminal window size being wrong at fractional DPR (e.g. 127×29 instead of configured 130×30 at 150% scaling on Wayland, #1858)</li>
116117
<li>Replaces abrupt cell blink toggle with configurable smooth pulse animation via blink_style profile setting (classic/smooth/linger)</li>
117118
<li>Adds configurable crossfade transition between primary and alternate screens via screen_transition and screen_transition_duration profile settings</li>
118119
<li>Adds animated cursor movement between grid cells with ease-out interpolation via cursor_motion_animation_duration profile setting</li>
119120
<li>Adds pixel-based smooth scrolling via smooth_scrolling profile setting, supporting trackpad pixel deltas, mouse wheel, and keyboard scroll actions</li>
120121
<li>Adds pixel-perfect builtin rendering for mathematical bracket and symbol characters (U+239B–U+23B3), including parenthesis pieces, curly bracket pieces, integral extensions, horizontal line extension, bracket sections, and summation symbols</li>
121122
<li>Adds pixel-perfect builtin rendering for shade characters (U+2591–U+2593): light shade, medium shade, and dark shade</li>
123+
<li>Adds snap-to-grid window resizing via WM size-increment hints (X11/macOS) and client-side snapping (Wayland, where xdg-shell has no size-increment hint)</li>
122124
</ul>
123125
</description>
124126
</release>

src/contour/display/TerminalDisplay.cpp

Lines changed: 162 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,7 @@ void TerminalDisplay::setSession(TerminalSession* newSession)
333333
// setup once with the renderer creation
334334
applyFontDPI();
335335
updateImplicitSize();
336-
updateMinimumSize();
336+
updateSizeConstraints();
337337
}
338338

339339
_session->attachDisplay(*this); // NB: Requires Renderer to be instanciated to retrieve grid metrics.
@@ -358,12 +358,76 @@ void TerminalDisplay::sizeChanged()
358358
// This can happen when the window is minimized, or when the window is not yet fully initialized.
359359
return;
360360

361+
// During initial display setup, the Wayland compositor may revert the window to
362+
// the stale pre-DPR-correction geometry via a configure event (e.g. in response to
363+
// showNormal()). Detect this by checking if BOTH dimensions mismatch the implicit
364+
// size — single-dimension mismatch indicates normal QML binding propagation
365+
// (which updates width and height sequentially, not atomically).
366+
if (steady_clock::now() < _initialResizeDeadline && std::abs(width() - implicitWidth()) > 0.5
367+
&& std::abs(height() - implicitHeight()) > 0.5)
368+
{
369+
displayLog()("Correcting initial window size from {}x{} to {}x{}",
370+
width(),
371+
height(),
372+
implicitWidth(),
373+
implicitHeight());
374+
post([this]() {
375+
if (auto* currentWindow = window(); currentWindow)
376+
currentWindow->resize(static_cast<int>(std::ceil(implicitWidth())),
377+
static_cast<int>(std::ceil(implicitHeight())));
378+
});
379+
return;
380+
}
381+
361382
displayLog()("Size changed to {}x{} virtual", width(), height());
362383

363384
auto const virtualSize = vtbackend::ImageSize { Width::cast_from(width()), Height::cast_from(height()) };
364385
auto const actualPixelSize = virtualSize * contentScale();
365386
displayLog()("Resizing view to {} virtual ({} actual).", virtualSize, actualPixelSize);
366387
applyResize(actualPixelSize, *_session, *_renderer);
388+
389+
// Client-side snap to cell-grid boundaries.
390+
// On X11/macOS, setSizeIncrement() handles this in the WM — the snap is a no-op.
391+
// On Wayland (xdg-shell has no size-increment hint), this is the only mechanism.
392+
if (!_snapPending && !isFullScreen() && window()->visibility() != QQuickWindow::Visibility::Maximized
393+
&& steady_clock::now() >= _initialResizeDeadline)
394+
{
395+
_snapPending = true;
396+
post([this]() {
397+
if (!_session || !_renderTarget || !window())
398+
{
399+
_snapPending = false;
400+
return;
401+
}
402+
if (isFullScreen() || window()->visibility() == QQuickWindow::Visibility::Maximized)
403+
{
404+
_snapPending = false;
405+
return;
406+
}
407+
408+
auto const dpr = contentScale();
409+
auto const snappedActualSize = pixelSize();
410+
auto const snappedVirtualWidth =
411+
static_cast<int>(std::ceil(static_cast<double>(unbox(snappedActualSize.width)) / dpr));
412+
auto const snappedVirtualHeight =
413+
static_cast<int>(std::ceil(static_cast<double>(unbox(snappedActualSize.height)) / dpr));
414+
415+
auto const currentWidth = window()->width();
416+
auto const currentHeight = window()->height();
417+
418+
if (snappedVirtualWidth != currentWidth || snappedVirtualHeight != currentHeight)
419+
{
420+
displayLog()("Snapping window from {}x{} to {}x{} virtual (grid-aligned, actual {})",
421+
currentWidth,
422+
currentHeight,
423+
snappedVirtualWidth,
424+
snappedVirtualHeight,
425+
snappedActualSize);
426+
window()->resize(snappedVirtualWidth, snappedVirtualHeight);
427+
}
428+
_snapPending = false;
429+
});
430+
}
367431
}
368432

369433
void TerminalDisplay::handleWindowChanged(QQuickWindow* newWindow)
@@ -473,6 +537,15 @@ void TerminalDisplay::applyFontDPI()
473537
fd.dpi = newFontDPI;
474538
_renderer->setFonts(std::move(fd));
475539

540+
// Recompute implicit/minimum size when font DPI changes (e.g., DPR correction
541+
// from 2.0 → 1.5 on KDE/Wayland with fractional scaling). The implicit size
542+
// does not require a render target, so this must happen before the guard below.
543+
if (window())
544+
{
545+
updateImplicitSize();
546+
updateSizeConstraints();
547+
}
548+
476549
if (!_renderTarget)
477550
return;
478551

@@ -595,6 +668,12 @@ void TerminalDisplay::createRenderer()
595668
Require(_session);
596669
Require(window());
597670

671+
// Catch DPR corrections that occurred between setSession() and first render
672+
// (e.g., Qt correcting from integer-ceiling DPR=2 to actual fractional DPR=1.5
673+
// on KDE/Wayland). This reloads fonts at the correct DPI before creating the
674+
// render target, ensuring correct cell metrics from the start.
675+
applyFontDPI();
676+
598677
auto const textureTileSize = gridMetrics().cellSize;
599678
auto const viewportMargin = vtrasterizer::PageMargin {}; // TODO margin
600679
auto const precalculatedViewSize = [this]() -> ImageSize {
@@ -653,9 +732,25 @@ void TerminalDisplay::createRenderer()
653732
configureScreenHooks();
654733
watchKdeDpiSetting();
655734

656-
_session->configureDisplay();
735+
// Use implicit dimensions (correct for configured terminal size at current DPR)
736+
// rather than widget width()/height() which lag behind QML binding propagation
737+
// during beforeSynchronizing (GUI thread is blocked, bindings haven't evaluated).
738+
{
739+
auto const implicitVirtualSize = ImageSize { vtbackend::Width::cast_from(implicitWidth()),
740+
vtbackend::Height::cast_from(implicitHeight()) };
741+
auto const actualPixelSize = implicitVirtualSize * contentScale();
742+
applyResize(actualPixelSize, *_session, *_renderer);
743+
}
744+
745+
// Allow sizeChanged() to correct Wayland configure reversions for 500ms.
746+
_initialResizeDeadline = steady_clock::now() + std::chrono::milliseconds(500);
657747

658-
resizeTerminalToDisplaySize();
748+
// Defer configureDisplay() until the GUI thread processes QML binding propagation
749+
// and the window is committed at the correct implicit size (e.g. 1136x600 at DPR 1.5).
750+
// Calling it synchronously here (render thread, GUI blocked) causes setWindowNormal()
751+
// → showNormal() to trigger a Wayland configure event that uses the stale pre-DPR-correction
752+
// window geometry (e.g. 1115x585 at DPR 2.0), reverting the terminal to the wrong size.
753+
post([this]() { _session->configureDisplay(); });
659754

660755
displayLog()("Implicit size: {}x{}", implicitWidth(), implicitHeight());
661756
}
@@ -1020,40 +1115,72 @@ void TerminalDisplay::updateImplicitSize()
10201115
assert(_session);
10211116
assert(window());
10221117

1023-
auto const totalPageSize = _session->terminal().pageSize() + _session->terminal().statusLineHeight();
1024-
auto const dpr = contentScale(); // DPR = Device Pixel Ratio
1118+
auto const totalPageSize = _session->terminal().totalPageSize();
1119+
auto const dpr = contentScale();
10251120

10261121
auto const actualGridCellSize = _renderer->cellSize();
1027-
auto const actualToVirtualFactor = 1.0 / dpr;
1028-
1029-
auto const virtualMargins = _session->profile().margins.value();
1030-
auto const virtualCellSize = actualGridCellSize * actualToVirtualFactor;
1031-
1032-
auto const requiredSize = computeRequiredSize(virtualMargins, virtualCellSize, totalPageSize);
1033-
1034-
displayLog()("Implicit display size set to {} (margins: {}, cellSize: {}, contentScale: {}, pageSize: {}",
1035-
requiredSize,
1036-
virtualMargins,
1037-
virtualCellSize,
1122+
auto const scaledMargins = applyContentScale(_session->profile().margins.value(), dpr);
1123+
1124+
// Compute the required size in actual pixels using exact integer arithmetic,
1125+
// then convert to virtual pixels. This avoids compounding ceil-per-cell rounding errors
1126+
// that would otherwise cause extra pixels and wrong column/line counts at fractional DPR.
1127+
auto const actualRequiredSize = computeRequiredSize(scaledMargins, actualGridCellSize, totalPageSize);
1128+
1129+
auto const virtualWidth = std::ceil(static_cast<double>(unbox(actualRequiredSize.width)) / dpr);
1130+
auto const virtualHeight = std::ceil(static_cast<double>(unbox(actualRequiredSize.height)) / dpr);
1131+
1132+
displayLog()("Implicit display size set to {}x{} (actualRequired: {}, cellSize: {}, contentScale: {}, "
1133+
"pageSize: {})",
1134+
virtualWidth,
1135+
virtualHeight,
1136+
actualRequiredSize,
1137+
actualGridCellSize,
10381138
dpr,
10391139
totalPageSize);
10401140

1041-
setImplicitWidth(unbox<qreal>(requiredSize.width));
1042-
setImplicitHeight(unbox<qreal>(requiredSize.height));
1141+
setImplicitWidth(virtualWidth);
1142+
setImplicitHeight(virtualHeight);
10431143
}
10441144

1045-
void TerminalDisplay::updateMinimumSize()
1145+
void TerminalDisplay::updateSizeConstraints()
10461146
{
10471147
Require(window());
10481148
Require(_renderer);
10491149
assert(_session);
10501150

1051-
auto constexpr MinimumTotalPageSize = PageSize { LineCount(5), ColumnCount(10) };
1052-
auto const minimumSize = computeRequiredSize(_session->profile().margins.value(),
1053-
_renderer->cellSize() * (1.0 / contentScale()),
1054-
MinimumTotalPageSize);
1151+
auto const dpr = contentScale();
1152+
auto const actualCellSize = _renderer->cellSize();
1153+
auto const margins = _session->profile().margins.value();
10551154

1155+
// Minimum size (existing logic, unchanged)
1156+
auto constexpr MinimumTotalPageSize = PageSize { LineCount(5), ColumnCount(10) };
1157+
auto const minimumSize = computeRequiredSize(margins, actualCellSize * (1.0 / dpr), MinimumTotalPageSize);
10561158
window()->setMinimumSize(QSize(unbox<int>(minimumSize.width), unbox<int>(minimumSize.height)));
1159+
1160+
// Base size: the margin area not participating in the increment grid.
1161+
// Margins from config are in virtual pixels, applied on both sides.
1162+
auto const baseWidth = static_cast<int>(2 * unbox(margins.horizontal));
1163+
auto const baseHeight = static_cast<int>(2 * unbox(margins.vertical));
1164+
window()->setBaseSize(QSize(baseWidth, baseHeight));
1165+
1166+
// Size increment: virtual cell size.
1167+
// ceil ensures the increment always covers the full cell at fractional DPR.
1168+
auto const virtualCellWidth =
1169+
static_cast<int>(std::ceil(static_cast<double>(unbox(actualCellSize.width)) / dpr));
1170+
auto const virtualCellHeight =
1171+
static_cast<int>(std::ceil(static_cast<double>(unbox(actualCellSize.height)) / dpr));
1172+
window()->setSizeIncrement(QSize(virtualCellWidth, virtualCellHeight));
1173+
1174+
displayLog()("Size constraints: minSize={}x{}, baseSize={}x{}, sizeIncrement={}x{} "
1175+
"(cellSize={}, dpr={})",
1176+
unbox<int>(minimumSize.width),
1177+
unbox<int>(minimumSize.height),
1178+
baseWidth,
1179+
baseHeight,
1180+
virtualCellWidth,
1181+
virtualCellHeight,
1182+
actualCellSize,
1183+
dpr);
10571184
}
10581185
// }}}
10591186

@@ -1301,6 +1428,7 @@ void TerminalDisplay::setFonts(vtrasterizer::FontDescriptions fontDescriptions)
13011428
{
13021429
// resize widget (same pixels, but adjusted terminal rows/columns and margin)
13031430
applyResize(pixelSize(), *_session, *_renderer);
1431+
updateSizeConstraints(); // cell size changed
13041432
// logDisplayInfo();
13051433
}
13061434
}
@@ -1315,7 +1443,7 @@ bool TerminalDisplay::setFontSize(text::font_size newFontSize)
13151443
return false;
13161444

13171445
resizeTerminalToDisplaySize();
1318-
updateMinimumSize();
1446+
updateSizeConstraints();
13191447
// logDisplayInfo();
13201448
return true;
13211449
}
@@ -1343,18 +1471,20 @@ void TerminalDisplay::setMouseCursorShape(MouseCursorShape newCursorShape)
13431471

13441472
void TerminalDisplay::setWindowFullScreen()
13451473
{
1474+
window()->setSizeIncrement(QSize(0, 0));
13461475
window()->showFullScreen();
13471476
}
13481477

13491478
void TerminalDisplay::setWindowMaximized()
13501479
{
1480+
window()->setSizeIncrement(QSize(0, 0));
13511481
window()->showMaximized();
13521482
_maximizedState = true;
13531483
}
13541484

13551485
void TerminalDisplay::setWindowNormal()
13561486
{
1357-
updateMinimumSize();
1487+
updateSizeConstraints();
13581488
window()->showNormal();
13591489
_maximizedState = false;
13601490
}
@@ -1369,12 +1499,19 @@ void TerminalDisplay::toggleFullScreen()
13691499
if (!isFullScreen())
13701500
{
13711501
_maximizedState = window()->visibility() == QQuickWindow::Visibility::Maximized;
1502+
window()->setSizeIncrement(QSize(0, 0));
13721503
window()->showFullScreen();
13731504
}
13741505
else if (_maximizedState)
1506+
{
1507+
window()->setSizeIncrement(QSize(0, 0));
13751508
window()->showMaximized();
1509+
}
13761510
else
1511+
{
1512+
updateSizeConstraints();
13771513
window()->showNormal();
1514+
}
13781515
}
13791516

13801517
void TerminalDisplay::toggleTitleBar()

src/contour/display/TerminalDisplay.h

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,9 @@ class TerminalDisplay: public QQuickItem
227227
void watchKdeDpiSetting();
228228
[[nodiscard]] float uptime() const noexcept;
229229

230-
void updateMinimumSize();
230+
/// Updates all window size constraints: minimum size, base size, and size increment.
231+
/// Configures the window manager to constrain user resizes to exact cell boundaries.
232+
void updateSizeConstraints();
231233

232234
// Updates the recommended size in (virtual pixels) based on:
233235
// - the grid cell size (based on the current font size and DPI),
@@ -261,6 +263,7 @@ class TerminalDisplay: public QQuickItem
261263
std::string _programPath;
262264
TerminalSession* _session = nullptr;
263265
std::chrono::steady_clock::time_point _startTime;
266+
std::chrono::steady_clock::time_point _initialResizeDeadline {};
264267
text::DPI _lastFontDPI;
265268
#if !defined(__APPLE__) && !defined(_WIN32)
266269
mutable std::optional<double> _lastReportedContentScale;
@@ -269,6 +272,7 @@ class TerminalDisplay: public QQuickItem
269272
bool _renderingPressure = false;
270273
display::OpenGLRenderer* _renderTarget = nullptr;
271274
bool _maximizedState = false;
275+
bool _snapPending = false; ///< Guards against redundant snap-to-grid post() calls.
272276
bool _sessionChanged = false;
273277
// update() timer used to animate the blinking cursor.
274278
QTimer _updateTimer;

0 commit comments

Comments
 (0)