Skip to content

Fix macOS Ghostty display scale sync#150

Merged
wey-gu merged 3 commits intonowledge-co:mainfrom
chenghuzi:huzi/macos-display-scale-sync
May 6, 2026
Merged

Fix macOS Ghostty display scale sync#150
wey-gu merged 3 commits intonowledge-co:mainfrom
chenghuzi:huzi/macos-display-scale-sync

Conversation

@chenghuzi
Copy link
Copy Markdown
Contributor

@chenghuzi chenghuzi commented May 6, 2026

Summary

  • synchronize embedded Ghostty AppKit layer contentsScale, content scale, framebuffer size, and display id together
  • register native screen/backing-property observers so cross-display moves update existing surfaces
  • add a postmortem for the Retina-to-external display geometry bug

Validation

  • cargo check -p con
  • cargo build -p con
  • CON_CHANNEL=beta scripts/macos/build-app.sh
  • codesign --verify --deep --strict /Applications/con Beta.app
  • manual test: moving an existing terminal from built-in display to 1920x1080 external display keeps stty size and visual font scale correct

Summary by CodeRabbit

Bug Fixes

  • Fixed macOS display scale synchronization when moving Ghostty windows between displays with different scaling factors
  • Improved native backing surface alignment with AppKit display properties on macOS

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/con-app/src/ghostty_view.rs (1)

1724-1740: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Invalidate the backing observer in Drop before the Arc<GhosttyTerminal> is freed.

The current Drop implementation only nils the NSView fields but does not remove the backing observer. Since terminal (line 124) is declared before nsview (line 133+), it drops first—freeing the Ghostty surface while the observer still holds a raw _surface pointer. The next screen/backing notification will call into freed memory.

While all explicit code paths (window close, tab close, prepare_window_close) call shutdown_surface() which removes the observer via detach_host_view(), the Drop implementation serves as the safety net for implicit teardown (panic unwind, GPUI entity cleanup without explicit shutdown). It should invalidate the observer before the Arc releases.

Suggested fix
 impl Drop for GhosttyView {
     fn drop(&mut self) {
         #[cfg(target_os = "macos")]
         {
             if let Some(underlay_view) = self.native_underlay_view.take() {
                 Self::set_transition_underlay_owner_visible(
                     underlay_view,
                     self.native_transition_underlay_owner_id,
                     false,
                 );
             }
+            if let Some(nsview) = self.nsview {
+                unsafe {
+                    con_ghostty_surface_remove_backing_observer(nsview as *mut c_void);
+                }
+            }
             self.document_view = None;
             self.nsview = None;
         }
         self.host_view = None;
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/con-app/src/ghostty_view.rs` around lines 1724 - 1740, Drop currently
clears NSView/host_view after fields that own the Arc<GhosttyTerminal> are
dropped, leaving the backing observer attached while the surface is freed;
update GhosttyView::drop to explicitly invalidate/detach the backing observer
(call the existing detach_host_view() or the same logic that removes the
observer/shutdown_surface()) before the Arc<GhosttyTerminal> (terminal) is
dropped—use take() on the relevant fields (e.g., terminal, host_view, nsview,
native_underlay_view) in the correct order so detach_host_view() runs first,
then clear the remaining fields (document_view, nsview, host_view) to ensure the
observer cannot call into freed memory.
🧹 Nitpick comments (1)
crates/con-app/src/ghostty_view.rs (1)

971-987: 💤 Low value

Redundant install + sync: each sync_native_backing_properties triggers two backing syncs.

con_ghostty_surface_install_backing_observer re-runs installForView: in the trampoline; when the observer is already attached for the same (window, surface) it falls through to [self sync], which calls con_ghostty_surface_sync_backing itself. The very next line then calls con_ghostty_surface_sync_backing again. That doubles the AppKit convertSizeToBacking/convertRectToBacking round-trips and the ghostty_surface_set_* calls on every frame update path (including live resize and cross-display moves).

The observer is already installed once in ensure_initialized (lines 677–682); a redundant defensive install here isn't necessary on the layout hot path.

♻️ Proposed refactor: drop the per-layout install
         let ok = unsafe {
-            con_ghostty_surface_install_backing_observer(
-                nsview as *mut c_void,
-                terminal.raw_surface() as *mut c_void,
-            );
             con_ghostty_surface_sync_backing(
                 nsview as *mut c_void,
                 terminal.raw_surface() as *mut c_void,
                 f64::from(bounds.size.width.as_f32().max(1.0)),
                 f64::from(bounds.size.height.as_f32().max(1.0)),
                 fallback_scale,
                 &mut scale_x,
                 &mut scale_y,
                 &mut width_px,
                 &mut height_px,
             )
         };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/con-app/src/ghostty_view.rs` around lines 971 - 987, The code calls
con_ghostty_surface_install_backing_observer immediately before
con_ghostty_surface_sync_backing inside sync_native_backing_properties, causing
a redundant install that triggers an extra sync; remove the per-layout install
call so the observer is only installed once in ensure_initialized (which already
runs con_ghostty_surface_install_backing_observer for the (window, surface)
pair), leaving only the con_ghostty_surface_sync_backing invocation in
sync_native_backing_properties to avoid duplicate
convertSizeToBacking/convertRectToBacking and duplicate ghostty_surface_set_*
calls.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@crates/con-app/src/ghostty_view.rs`:
- Around line 1724-1740: Drop currently clears NSView/host_view after fields
that own the Arc<GhosttyTerminal> are dropped, leaving the backing observer
attached while the surface is freed; update GhosttyView::drop to explicitly
invalidate/detach the backing observer (call the existing detach_host_view() or
the same logic that removes the observer/shutdown_surface()) before the
Arc<GhosttyTerminal> (terminal) is dropped—use take() on the relevant fields
(e.g., terminal, host_view, nsview, native_underlay_view) in the correct order
so detach_host_view() runs first, then clear the remaining fields
(document_view, nsview, host_view) to ensure the observer cannot call into freed
memory.

---

Nitpick comments:
In `@crates/con-app/src/ghostty_view.rs`:
- Around line 971-987: The code calls
con_ghostty_surface_install_backing_observer immediately before
con_ghostty_surface_sync_backing inside sync_native_backing_properties, causing
a redundant install that triggers an extra sync; remove the per-layout install
call so the observer is only installed once in ensure_initialized (which already
runs con_ghostty_surface_install_backing_observer for the (window, surface)
pair), leaving only the con_ghostty_surface_sync_backing invocation in
sync_native_backing_properties to avoid duplicate
convertSizeToBacking/convertRectToBacking and duplicate ghostty_surface_set_*
calls.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 77c2d4b7-685b-479e-bed5-b90ab5df83f9

📥 Commits

Reviewing files that changed from the base of the PR and between 5a522d9 and c48a4cf.

📒 Files selected for processing (4)
  • crates/con-app/build.rs
  • crates/con-app/src/ghostty_view.rs
  • crates/con-app/src/objc/ghostty_surface_trampoline.m
  • postmortem/2026-05-06-macos-display-scale-sync.md

@wey-gu
Copy link
Copy Markdown
Member

wey-gu commented May 6, 2026

@codex review

@chatgpt-codex-connector
Copy link
Copy Markdown

💡 Codex Review

NSRect backing_frame = [view convertRectToBacking:frame];
double scale_x = frame.size.width > 0.0
? backing_frame.size.width / frame.size.width

P2 Badge Derive content scale from backing factor, not frame ratio

con_ghostty_surface_sync_backing computes scale_x/scale_y as convertRectToBacking(frame) / frame, but convertRectToBacking is pixel-aligned, so this ratio can vary with fractional view sizes during layout/resizing instead of staying at the true display scale. In this same function, layer.contentsScale is set from window.backingScaleFactor, so content scale and layer scale can diverge slightly; that can cause subtle cell-metric/font-size jitter and unstable row/column calculations while resizing or moving windows. Use a stable scale source (e.g., window.backingScaleFactor or converting a 1x1 unit size) for ghostty_surface_set_content_scale.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@wey-gu wey-gu force-pushed the huzi/macos-display-scale-sync branch from c48a4cf to 54ae594 Compare May 6, 2026 18:05
@wey-gu
Copy link
Copy Markdown
Member

wey-gu commented May 6, 2026

@codex review

@wey-gu wey-gu force-pushed the huzi/macos-display-scale-sync branch from 54ae594 to 30f5fc3 Compare May 6, 2026 18:08
@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@wey-gu
Copy link
Copy Markdown
Member

wey-gu commented May 6, 2026

@codex review

@wey-gu wey-gu merged commit 4578923 into nowledge-co:main May 6, 2026
3 checks passed
@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@nowledge-co nowledge-co deleted a comment from coderabbitai Bot May 6, 2026
@wey-gu
Copy link
Copy Markdown
Member

wey-gu commented May 6, 2026

Thanks a lot for the great work!

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.

2 participants