Skip to content

Reserve scroller inset so terminal text isn't clipped on the right#3001

Open
rdsciv wants to merge 1 commit intomanaflow-ai:mainfrom
rdsciv:fix-terminal-right-edge-scrollbar-inset
Open

Reserve scroller inset so terminal text isn't clipped on the right#3001
rdsciv wants to merge 1 commit intomanaflow-ai:mainfrom
rdsciv:fix-terminal-right-edge-scrollbar-inset

Conversation

@rdsciv
Copy link
Copy Markdown

@rdsciv rdsciv commented Apr 19, 2026

Summary

Fixes #1082 and #2997. When macOS renders a persistent vertical scroller — either because Show Scroll Bars is set to Always, or because the user has a mouse attached with Automatic — the rightmost terminal column currently renders underneath the scrollbar. cmux forces the NSScrollView to overlay style to avoid a reserved legacy gutter, which means AppKit does not subtract the scroller width for us; meanwhile synchronizeGeometryAndContent was handing libghostty the full scrollview width.

This PR shrinks surfaceView.frame.width by the scroller width when NSScroller.preferredScrollerStyle == .legacy and a vertical scroller is mounted. Transient overlay scrollers that fade out keep the full terminal width — the brief overlap during a scroll is the explicit tradeoff for not losing a column on every trackpad-only session.

handlePreferredScrollerStyleChange now re-runs the full geometry pass, so the terminal resizes live when the user plugs in a mouse or flips the system preference.

  • GhosttySurfaceScrollView.verticalScrollerInsetWidth(...) — small static helper, pure function, easy to unit-test
  • synchronizeGeometryAndContent uses it to compute targetSize
  • synchronizeCoreSurface reads surfaceView.frame.width as before, which is now pre-shrunk

Note on commit structure

The CLAUDE.md regression-test policy recommends a two-commit structure (test red → fix green). That doesn't cleanly apply here because the testable seam (verticalScrollerInsetWidth) is introduced by the fix itself, so the test cannot compile at commit-1. The new tests verify observable runtime behavior of the pure helper (per the "no source-text assertions" rule) rather than shape.

Test plan

  • xcodebuild -scheme cmux-unit passes locally (new TerminalScrollerInsetTests covers all four branches of the helper)
  • Manual: with a mouse attached on macOS Tahoe, open a full-screen TUI (claude code / btop / mc) and confirm text no longer clips under the scrollbar
  • Manual: toggle System Settings → Appearance → Show scroll bars between Always and When scrolling and confirm the terminal resizes live
  • Manual: trackpad-only session keeps the full terminal width (no persistent 1-column loss)

Summary by cubic

Fixes #1082 and #2997 by reserving a right-edge inset when macOS shows a persistent vertical scrollbar so the rightmost terminal column isn’t clipped. The inset applies only for legacy scrollers; overlay scrollers keep full width, and the terminal resizes live when the style changes.

  • Bug Fixes
    • Subtract a vertical scroller inset in synchronizeGeometryAndContent, shrinking surfaceView.frame.width only when a vertical scroller is present and NSScroller.preferredScrollerStyle == .legacy.
    • Add GhosttySurfaceScrollView.verticalScrollerInsetWidth(...) (pure helper) with unit tests for absent, overlay, legacy, and negative-width cases.
    • Re-run the full geometry pass on preferred scroller style changes so width updates immediately.

Written for commit 943c221. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed vertical scrollers overlapping terminal content when using legacy scroller style.
    • Improved terminal width calculation to properly account for scroller insets.
    • Enhanced responsiveness when scroller style preferences change.
  • Tests

    • Added comprehensive test coverage for scroller inset behavior across different scenarios.

… right

When macOS renders a persistent vertical scroller (Show Scroll Bars = Always,
or Automatic with a mouse attached), cmux was still telling libghostty the
full scrollview width, so the rightmost terminal column rendered underneath
the scrollbar. cmux forces the NSScrollView to overlay style to avoid a
reserved legacy gutter, which means AppKit does not subtract the scroller
width for us.

Shrink surfaceView.frame by the scroller width when preferredScrollerStyle
is .legacy and a vertical scroller is mounted; keep the full terminal width
for transient overlay scrollers that fade out. Re-run the geometry pass on
preferredScrollerStyleDidChangeNotification so the terminal resizes live
when the user plugs in a mouse or toggles the system preference.

Fixes manaflow-ai#1082
Fixes manaflow-ai#2997
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 19, 2026

@rdsciv is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 19, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 254452e2-76fd-460d-8c81-64a060c2679e

📥 Commits

Reviewing files that changed from the base of the PR and between 3698f13 and 943c221.

📒 Files selected for processing (3)
  • GhosttyTabs.xcodeproj/project.pbxproj
  • Sources/GhosttyTerminalView.swift
  • cmuxTests/TerminalScrollerInsetTests.swift

📝 Walkthrough

Walkthrough

This change adds vertical scroller inset width calculation to prevent legacy scrollbars from overlapping terminal content. The implementation computes the reserved right-edge space based on scroller presence and style, adjusts terminal width synchronization accordingly, and introduces comprehensive unit tests for the new inset logic.

Changes

Cohort / File(s) Summary
Xcode Project Configuration
GhosttyTabs.xcodeproj/project.pbxproj
Added build file and project references for the new TerminalScrollerInsetTests.swift test file to the cmuxTests target.
Terminal View Scroller Inset Logic
Sources/GhosttyTerminalView.swift
Introduced verticalScrollerInsetWidth() static and instance methods to compute reserved space when a vertical legacy scroller is present. Modified synchronizeGeometryAndContent() to subtract this inset from scroll view bounds before sizing the surface view. Updated handlePreferredScrollerStyleChange() to perform full geometry resynchronization instead of partial tile+sync.
Unit Tests
cmuxTests/TerminalScrollerInsetTests.swift
New test suite validating verticalScrollerInsetWidth() across four scenarios: no scroller, transient overlay style, persistent legacy style (with width clamping), and negative input handling.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 A scrollbar hops into view,
No more text that's cut in two!
Inset width, geometry tight,
Terminal lines now render right! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.27% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly describes the main fix: reserving scroller inset to prevent terminal text clipping on the right edge with persistent scrollbars.
Description check ✅ Passed The description includes a comprehensive summary explaining the problem, solution, and testing approach. It follows the template with Summary and Test plan sections, and includes implementation details and manual verification steps.
Linked Issues check ✅ Passed The PR fully addresses issue #1082 by fixing terminal text clipping through scroller-aware width computation and implementing conditional inset logic for persistent legacy scrollers.
Out of Scope Changes check ✅ Passed All changes (helper function, geometry synchronization updates, test file, and Xcode configuration) are directly scoped to fixing the scroller inset issue described in the linked issues.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 19, 2026

Greptile Summary

Fixes the long-standing clip-under-scroller bug (#1082, #2997) by introducing GhosttySurfaceScrollView.verticalScrollerInsetWidth(...) — a pure static helper that returns the legacy scroller width when the system preference is .legacy and a vertical scroller is mounted, and 0 otherwise. synchronizeGeometryAndContent now subtracts this inset from targetSize.width before setting surfaceView.frame, and handlePreferredScrollerStyleChange is promoted to call the full geometry pass so the terminal resizes live when the user plugs in a mouse or toggles Show Scroll Bars.

Confidence Score: 5/5

Safe to merge; all remaining findings are P2 style suggestions that do not affect correctness.

The core fix is a small, well-tested, pure function. The geometry integration is straightforward and the live-resize path through handlePreferredScrollerStyleChange is correct. No P0/P1 findings.

No files require special attention.

Important Files Changed

Filename Overview
Sources/GhosttyTerminalView.swift Adds verticalScrollerInsetWidth static helper and integrates it into synchronizeGeometryAndContent to narrow surfaceView by the legacy scroller width; handlePreferredScrollerStyleChange now calls the full geometry pass. Logic is correct; one minor redundant API call in the private wrapper.
cmuxTests/TerminalScrollerInsetTests.swift New unit tests cover the four logical branches of the static helper; one test (testClampsNegativeScrollerWidthToZero) exercises a guard that Apple's API can never trigger in practice, borderline against CLAUDE.md test quality policy.
GhosttyTabs.xcodeproj/project.pbxproj Adds TerminalScrollerInsetTests.swift to the unit-test target in all three required locations (PBXBuildFile, PBXFileReference, PBXGroup). Routine Xcode project bookkeeping, no issues.

Sequence Diagram

sequenceDiagram
    participant Sys as macOS (NSScroller)
    participant Handler as handlePreferredScrollerStyleChange
    participant Geom as synchronizeGeometryAndContent
    participant Helper as verticalScrollerInsetWidth()
    participant Surface as surfaceView.frame
    participant Core as synchronizeCoreSurface → libghostty

    Sys->>Handler: preferredScrollerStyleDidChange notification
    Handler->>Geom: synchronizeGeometryAndContent()
    Geom->>Helper: verticalScrollerInsetWidth()
    Note over Helper: preferredScrollerStyle == .legacy<br/>AND hasVerticalScroller?
    alt Legacy + scroller mounted
        Helper-->>Geom: NSScroller.scrollerWidth (e.g. 15pt)
    else Overlay or no scroller
        Helper-->>Geom: 0
    end
    Geom->>Surface: setFrame(width: scrollView.bounds.width − inset)
    Geom->>Core: synchronizeCoreSurface()
    Core-->>Sys: libghostty resizes terminal columns
Loading

Reviews (1): Last reviewed commit: "Reserve vertical scroller inset so termi..." | Re-trigger Greptile

Comment on lines +51 to +58
func testClampsNegativeScrollerWidthToZero() {
let inset = GhosttySurfaceScrollView.verticalScrollerInsetWidth(
hasVerticalScroller: true,
preferredScrollerStyle: .legacy,
scrollerWidth: -4
)
XCTAssertEqual(inset, 0)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Defensive guard against impossible input

testClampsNegativeScrollerWidthToZero exercises a max(0, ...) guard that Apple's NSScroller.scrollerWidth(for:scrollerStyle:) can never trigger — the API is documented to return a non-negative CGFloat. Per the CLAUDE.md test quality policy, tests should verify observable runtime behavior through executable paths, not defensive implementation detail. Consider replacing this case with a test that observes a meaningful boundary condition (e.g. scrollerWidth == 0 with a legacy scroller mounted, confirming the inset is 0 and the surface retains its full width), which would remain useful if scrollerWidth semantics ever change.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +9493 to +9501
private func verticalScrollerInsetWidth() -> CGFloat {
let style = NSScroller.preferredScrollerStyle
let width = NSScroller.scrollerWidth(for: .regular, scrollerStyle: style)
return Self.verticalScrollerInsetWidth(
hasVerticalScroller: scrollView.hasVerticalScroller,
preferredScrollerStyle: style,
scrollerWidth: width
)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Redundant scrollerWidth query for overlay style

NSScroller.scrollerWidth(for: .regular, scrollerStyle: style) is called unconditionally, but the result is only used when style == .legacy. When style == .overlay the static helper returns 0 before reading scrollerWidth, so the scrollerWidth call is dead work. Consider querying the width only when needed:

Suggested change
private func verticalScrollerInsetWidth() -> CGFloat {
let style = NSScroller.preferredScrollerStyle
let width = NSScroller.scrollerWidth(for: .regular, scrollerStyle: style)
return Self.verticalScrollerInsetWidth(
hasVerticalScroller: scrollView.hasVerticalScroller,
preferredScrollerStyle: style,
scrollerWidth: width
)
}
private func verticalScrollerInsetWidth() -> CGFloat {
let style = NSScroller.preferredScrollerStyle
guard style == .legacy else { return 0 }
guard scrollView.hasVerticalScroller else { return 0 }
let width = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy)
return max(0, width)
}

This also removes the indirection through the static helper for the hot-path call and makes the guard order match the static helper's logic explicitly.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 3 files

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Terminal lines truncated at right edge instead of wrapping

1 participant