Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable {
surfaceView.retryViewportReport()
return
}
surfaceView.markViewportReportConfirmed()
surfaceView.markViewportReportConfirmed(reportID: report.id)
if let renderEpoch = effectiveGrid.renderEpoch,
let renderRevisionFloor = effectiveGrid.renderRevisionFloor {
self.verifiedReplayState.acknowledgeViewport(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ extension GhosttySurfaceView {
lastRenderLayoutViewportHeight = nil
lastRenderHasSourceLayoutViewport = false
lastAppliedContentScale = 0
resetLastAppliedContainerSize()

surfaceGeneration &+= 1
outputQueueGeneration &+= 1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,26 @@ extension GhosttySurfaceView {

/// The viewport report for the current geometry: base-font row and column
/// capacity (see `TerminalRowCapacityFit`).
///
/// `measuredFontSize` is the font the surface was rendering at when the
/// cell size was measured (captured with the geometry pass), NOT the
/// current `liveFontSize`: a zoom applied between the measurement and
/// this call would otherwise break the base-font normalization by the
/// zoom ratio and report a grid several times too small or too large.
func capacityReportGrid(
for natural: TerminalGridSize,
containerPixelWidth: CGFloat,
containerPixelHeight: CGFloat,
cellPixelWidth: CGFloat,
cellPixelHeight: CGFloat
cellPixelHeight: CGFloat,
measuredFontSize: Float32
) -> TerminalGridSize {
guard let fit = TerminalRowCapacityFit(
containerPixelHeight: containerPixelHeight,
cellPixelHeight: cellPixelHeight,
containerPixelWidth: containerPixelWidth,
cellPixelWidth: cellPixelWidth,
liveFontSize: liveFontSize
liveFontSize: measuredFontSize
), let rows = fit.capacityRows(atBaseFontSize: userBaseFontSize),
let columns = fit.capacityColumns(atBaseFontSize: userBaseFontSize) else { return natural }
return TerminalGridSize(
Expand All @@ -53,12 +60,16 @@ extension GhosttySurfaceView {
/// Re-derive the rendered font from the effective grid. The supplied width
/// is the same stable width used for the column report, so a transient
/// overlay sample cannot make a valid full-width grant look oversized.
///
/// `measuredFontSize` pairs with the supplied cell size (measured in the
/// same geometry pass); see `capacityReportGrid`.
func autoFitFontToEffectiveRows(
renderedRows: Int,
containerPixelWidth: CGFloat,
containerPixelHeight: CGFloat,
cellPixelWidth: CGFloat,
cellPixelHeight: CGFloat
cellPixelHeight: CGFloat,
measuredFontSize: Float32
) {
guard pendingFontSize == nil else { return }
guard let eff = effectiveGrid else {
Expand All @@ -73,7 +84,7 @@ extension GhosttySurfaceView {
cellPixelHeight: cellPixelHeight,
containerPixelWidth: containerPixelWidth,
cellPixelWidth: cellPixelWidth,
liveFontSize: liveFontSize
liveFontSize: measuredFontSize
), let baseRows = fit.capacityRows(atBaseFontSize: userBaseFontSize),
let baseColumns = fit.capacityColumns(atBaseFontSize: userBaseFontSize) else { return }
if eff.cols >= baseColumns && eff.rows >= baseRows {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,14 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
/// the natural grid would be unchanged afterwards, nothing would ever
/// re-report, and the letterbox gap above the terminal would be permanent.
private var viewportReportID: UInt64 = 0
/// True from the moment a natural-grid report is handed to the delegate
/// until the daemon's round-trip resolves for the NEWEST report (echo
/// confirmed, or the bounded retries are exhausted). While set, the
/// stretch-to-fill auto-fit is deferred: `effectiveGrid` is about to be
/// superseded by the grant answering this report, and fitting the
/// rendered font against the outgoing value produces a transient zoom
/// that reverts one round-trip later.
private var awaitingViewportEcho = false
/// Frames of "no zoom in progress" required before the natural grid is
/// reported to the Mac. Active zoom is already gated separately
/// (`zoomSettleFrames != nil` holds the report during a pinch), so this is
Expand Down Expand Up @@ -424,6 +432,17 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
var lastRenderRect: CGRect = .zero
var lastRenderLayoutViewportHeight: CGFloat?
var lastRenderHasSourceLayoutViewport = false
/// The container size last actually applied to libghostty via
/// `set_size`. Used to detect an unsettled SHRINK (keyboard rising) so
/// the local resize can be deferred until the grid negotiation settles
/// (see `syncSurfaceGeometry`); `.zero` until the first applied pass.
private var lastAppliedContainerSize: CGSize = .zero

/// Render-pipeline reset seam: the recreated surface has no applied
/// container size yet, so a shrink right after a reset must not defer.
func resetLastAppliedContainerSize() {
lastAppliedContainerSize = .zero
}
private var viewportCoordinator = TerminalViewportCoordinator()
private let keyboardTransitionPlanner = TerminalDockKeyboardTransitionPlanner()
private var keyboardHeightAnimation: TerminalKeyboardHeightAnimation?
Expand Down Expand Up @@ -1160,7 +1179,10 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
chromeHidden: chromeHidden,
chromeVisible: dockedToolbarShouldBeVisible && dockedToolbar?.isHidden == false,
toolbarFrame: dockedToolbar?.frame,
toolbarPresentationFrame: dockedToolbar?.layer.presentation()?.frame
toolbarPresentationFrame: dockedToolbar?.layer.presentation()?.frame,
viewportNegotiationUnsettled: keyboardHeightAnimation != nil
|| pendingViewportReport != nil
|| awaitingViewportEcho
))
}

Expand Down Expand Up @@ -2890,6 +2912,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
pendingViewportReport = nil
viewportReportSettleFrames = 0
viewportReportID &+= 1
awaitingViewportEcho = true
MobileDebugLog.anchormux("zoom.report grid=\(pending.columns)x\(pending.rows) id=\(viewportReportID)")
delegate?.ghosttySurfaceView(self, didResize: pending, reportID: viewportReportID)
}
Expand Down Expand Up @@ -3135,7 +3158,17 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
/// confirmed `applyViewSize` resets the counter. No-op once the cap is hit.
public func retryViewportReport() {
guard viewportReportRetries < Self.maxViewportReportRetries,
let pending = lastReportedSize, pending.columns > 0, pending.rows > 0 else { return }
let pending = lastReportedSize, pending.columns > 0, pending.rows > 0 else {
// Round-trip permanently failed (or nothing to retry): release
// the deferred auto-fit so the rendered font converges on the
// best-known (stale) grant instead of staying parked until some
// future confirmation that may never come.
if awaitingViewportEcho {
awaitingViewportEcho = false
setNeedsGeometrySync(reassertNaturalSize: false)
}
return
}
viewportReportRetries += 1
MobileDebugLog.anchormux(
"zoom.viewport.retry \(viewportReportRetries)/\(Self.maxViewportReportRetries) "
Expand Down Expand Up @@ -3188,6 +3221,24 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
viewportReportRetries = 0
}

/// Mark the round-trip for `reportID` as resolved. Only the NEWEST
/// report's confirmation releases the deferred stretch-to-fill auto-fit:
/// an out-of-order reply for an older report means the grant answering
/// the current capacity is still in flight.
public func markViewportReportConfirmed(reportID: UInt64) {
viewportReportRetries = 0
guard reportID == viewportReportID else { return }
if awaitingViewportEcho {
awaitingViewportEcho = false
// Run the auto-fit that was deferred while the round-trip was in
// flight. `applyViewSize` schedules a sync only when the echoed
// grid CHANGED, so an unchanged echo needs this explicit resync
// for the fit (and it re-reports nothing: reassert is false and
// the natural grid is unchanged).
setNeedsGeometrySync(reassertNaturalSize: false)
}
}

private func applyViewSize(cols: Int, rows: Int, confirmedViewportEcho: Bool) {
guard updateEffectiveGrid(cols: cols, rows: rows, confirmedViewportEcho: confirmedViewportEcho) else { return }
// Mark dirty instead of recomputing synchronously. This breaks the
Expand Down Expand Up @@ -3280,6 +3331,22 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
/// Pinned render size in points when letterboxed to an effective
/// grid; nil means fill the container.
let pinnedSize: CGSize?
/// The font size the surface was rendering at when `cellPixelSize`
/// was measured. Capacity reports and the stretch-to-fill auto-fit
/// must normalize with THIS font, not the main-actor `liveFontSize`
/// read at apply time: a zoom queued between the measurement and the
/// apply makes the pair incoherent and the base-font normalization
/// off by the zoom ratio — the phone then reports a grid several
/// times too small (or too large) and the daemon grants a bogus
/// shared PTY size (the keyboard-transition font-oscillation bug).
let measuredFontSize: Float32
/// False when this pass deferred the `set_size` (unsettled shrink):
/// the measured natural grid and render size still describe the
/// PREVIOUS layout, so the render's source-layout bookkeeping must
/// not be re-stamped with the new layout height (the stale-live
/// clamp would otherwise snap the old render to the target viewport
/// instead of letting it ride the keyboard).
let appliedResize: Bool
}

private func syncSurfaceGeometryAndWait(shouldReassertNaturalSize: Bool = true) async -> Bool {
Expand Down Expand Up @@ -3315,6 +3382,12 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
// The main thread only applies the UIKit result. This is the single
// off-main surface owner: main never calls a blocking libghostty API.
let scale = preferredScreenScale
// The font the surface will measure with. Font pushes and geometry
// passes share the serial `outputQueue`, and `liveFontSize` is
// written on the main thread at the moment the font push is
// enqueued, so capturing it here (at this pass's enqueue) pairs it
// with exactly the cell size this pass measures.
let measuredFontSize = liveFontSize
// Reserve, from the bottom up, the keyboard/safe-area inset (keyboard
// height when up, else the bottom safe area so the always-visible toolbar
// clears the home indicator), the open composer band, and the persistent
Expand Down Expand Up @@ -3347,12 +3420,38 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
let pushContentScale = abs(lastAppliedContentScale - scale) > 0.001
if pushContentScale { lastAppliedContentScale = scale }
let generation = surfaceGeneration
// While the grid negotiation is unsettled and the container SHRANK
// (keyboard rising), do NOT resize the local surface yet. An eager
// local shrink keeps the bottom of the SCREEN — trailing blank rows
// included — so the visible content collapses to the tail of the old
// screen jumped to the top ("all rows momentarily pushed up") until
// the remote reflow lands one round-trip later. Deferring the resize
// keeps the old render, which the bottom-pinned render rect slides
// up with the keyboard (prompt stays glued to the keyboard top), and
// the settle pass applies ONE resize whose result matches the
// remote's reflowed content. Width changes (rotation, split) and
// growth keep the immediate resize; the capacity report below is
// pure container/cell math, so the negotiation still starts now.
let deferShrinkResize = snapshot.viewportNegotiationUnsettled
&& lastAppliedContainerSize.height > 0
&& abs(containerW - lastAppliedContainerSize.width) < 0.5
&& containerH < lastAppliedContainerSize.height - 0.5
if !deferShrinkResize {
lastAppliedContainerSize = container
} else {
MobileDebugLog.anchormux(
"geom.deferShrink container=\(Int(containerW))x\(Int(containerH)) "
+ "applied=\(Int(lastAppliedContainerSize.width))x\(Int(lastAppliedContainerSize.height))"
)
}

outputQueue.async { [weak self] in
if pushContentScale {
ghostty_surface_set_content_scale(surface, scale, scale)
}
ghostty_surface_set_size(surface, containerPxW, containerPxH)
if !deferShrinkResize {
ghostty_surface_set_size(surface, containerPxW, containerPxH)
}
let measured = ghostty_surface_size(surface)

var cell = CGSize.zero
Expand All @@ -3364,7 +3463,10 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
}

var pinnedSize: CGSize?
if let eff, eff.cols > 0, eff.rows > 0, cell.width > 0, cell.height > 0 {
// A deferred shrink leaves the surface at its previous size, so
// the letterbox fit (which resizes the surface) is skipped too;
// the settle pass re-derives the pin against the applied size.
if let eff, !deferShrinkResize, eff.cols > 0, eff.rows > 0, cell.width > 0, cell.height > 0 {
let fillsNaturalGrid = eff.cols >= Int(measured.columns) && eff.rows >= Int(measured.rows)
let withinOneCell = (Int(measured.columns) - eff.cols) <= 1 && (Int(measured.rows) - eff.rows) <= 1
let exactGridFitsInsideNatural = eff.cols <= Int(measured.columns)
Expand Down Expand Up @@ -3395,7 +3497,9 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
cellPixelSize: cell,
naturalSize: natural,
sourceLayoutViewportHeight: snapshot.layoutViewportRect.height,
pinnedSize: pinnedSize
pinnedSize: pinnedSize,
measuredFontSize: measuredFontSize,
appliedResize: !deferShrinkResize
)
Task { @MainActor in
guard let self else {
Expand Down Expand Up @@ -3448,8 +3552,10 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
?? CGRect(origin: .zero, size: naturalRenderSize)
let snapshot = viewportSnapshot()
layoutBottomDock(using: snapshot)
lastRenderLayoutViewportHeight = result.sourceLayoutViewportHeight
lastRenderHasSourceLayoutViewport = true
if result.appliedResize {
lastRenderLayoutViewportHeight = result.sourceLayoutViewportHeight
lastRenderHasSourceLayoutViewport = true
}
let renderRect = snapshot.renderRect(
forRenderSize: measuredRenderRect.size,
clampsStaleLiveViewport: shouldClampStaleLiveViewport(using: snapshot)
Expand Down Expand Up @@ -3495,19 +3601,47 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
containerPixelWidth: reportContainerWidth * scale,
containerPixelHeight: containerH * scale,
cellPixelWidth: result.cellPixelSize.width,
cellPixelHeight: result.cellPixelSize.height
cellPixelHeight: result.cellPixelSize.height,
measuredFontSize: result.measuredFontSize
)
// Stretch-to-fill: keep the RENDERED font tracking only real row
// constraints. When the daemon grants the base-font natural grid back,
// decay to the user's base font so the full-width grid can render
// without horizontal overflow.
autoFitFontToEffectiveRows(
renderedRows: naturalSize.rows,
containerPixelWidth: reportContainerWidth * scale,
containerPixelHeight: containerH * scale,
cellPixelWidth: result.cellPixelSize.width,
cellPixelHeight: result.cellPixelSize.height
)
//
// Re-fit ONLY when the negotiation is settled: this pass's capacity
// matches the last report the daemon actually saw, no report is
// debouncing or awaiting its echo, and no keyboard transition is in
// flight. During a keyboard show/hide the container changes
// immediately while `effectiveGrid` is still the PREVIOUS grant (the
// phone itself caused the mismatch, and the corrected grant is one
// round-trip away) — fitting against that stale grant stretched the
// font toward filling the new container and then snapped back when
// the echo landed: the "text zooms in when the keyboard closes" bug.
// The settle paths (keyboard animation completion, report echo) each
// schedule another geometry sync, so exactly one fit runs on the
// settled grant.
if keyboardHeightAnimation == nil,
pendingViewportReport == nil,
!awaitingViewportEcho,
reportGrid == lastReportedSize {
autoFitFontToEffectiveRows(
renderedRows: naturalSize.rows,
containerPixelWidth: reportContainerWidth * scale,
containerPixelHeight: containerH * scale,
cellPixelWidth: result.cellPixelSize.width,
cellPixelHeight: result.cellPixelSize.height,
measuredFontSize: result.measuredFontSize
)
} else {
MobileDebugLog.anchormux(
"zoom.autofit.deferred kbAnim=\(keyboardHeightAnimation != nil ? 1 : 0) "
+ "pendingReport=\(pendingViewportReport != nil ? 1 : 0) "
+ "awaitingEcho=\(awaitingViewportEcho ? 1 : 0) "
+ "reportGrid=\(reportGrid.columns)x\(reportGrid.rows) "
+ "lastReported=\(lastReportedSize.map { "\($0.columns)x\($0.rows)" } ?? "nil")"
)
}
let effectiveMatchesNatural = effectiveGrid.map { grid in
grid.cols == naturalSize.columns && grid.rows == naturalSize.rows
} ?? true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ struct TerminalViewportCoordinator {
y: 0,
width: bounds.width,
height: liveViewportHeight
)
),
viewportNegotiationUnsettled: inputs.viewportNegotiationUnsettled
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ struct TerminalViewportInputs {
let chromeVisible: Bool
let toolbarFrame: CGRect?
let toolbarPresentationFrame: CGRect?
/// True while the shared-grid negotiation is unsettled: a keyboard
/// transition is in flight, a capacity report is debouncing, or the
/// newest report's echo has not confirmed. The render pin treats the
/// current effective grid as provisional then (see
/// `TerminalLetterboxGeometry.renderPinnedBottomEdge`).
let viewportNegotiationUnsettled: Bool
}
#endif

Loading