@@ -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
369433void 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
13441472void TerminalDisplay::setWindowFullScreen ()
13451473{
1474+ window ()->setSizeIncrement (QSize (0 , 0 ));
13461475 window ()->showFullScreen ();
13471476}
13481477
13491478void TerminalDisplay::setWindowMaximized ()
13501479{
1480+ window ()->setSizeIncrement (QSize (0 , 0 ));
13511481 window ()->showMaximized ();
13521482 _maximizedState = true ;
13531483}
13541484
13551485void 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
13801517void TerminalDisplay::toggleTitleBar ()
0 commit comments