mac: kill the composer height jitter — pure probes, width-guarded observer (#924)
sizeThatFits mutated the text view's frame during SwiftUI's probes (zero/unspecified widths included), forcing bogus narrow wraps whose frame changes re-entered layout through the frame observer — the mid-text jitter Paul hit. Probes are now pure measurements, and the observer only re-measures on a REAL width change (never the height changes our own binding causes).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+14
−6
ios/LChatMac/MacComposerField.swift+14 −6
diff --git a/ios/LChatMac/MacComposerField.swift b/ios/LChatMac/MacComposerField.swift| index 2ebaf50..06246fc 100644 |
| --- a/ios/LChatMac/MacComposerField.swift |
| +++ b/ios/LChatMac/MacComposerField.swift |
| @@ -88,8 +88,12 @@ struct MacComposerField: NSViewRepresentable { |
| context.coordinator.frameObserver = NotificationCenter.default.addObserver( |
| forName: NSView.frameDidChangeNotification, object: tv, queue: .main |
| ) { [weak coordinator = context.coordinator, weak tv] _ in |
| - guard let tv else { return } |
| - coordinator?.remeasure(tv) |
| + guard let coordinator, let tv else { return } |
| + // Height changes are OUR OWN doing (the binding landing) — acting |
| + // on them loops layout into jitter. Only a real width change |
| + // re-wraps the text. |
| + guard tv.frame.width != coordinator.lastMeasuredWidth else { return } |
| + coordinator.remeasure(tv) |
| } |
| DispatchQueue.main.async { context.coordinator.remeasure(tv) } |
| return scroll |
| @@ -147,20 +151,23 @@ struct MacComposerField: NSViewRepresentable { |
| func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSScrollView, context: Context) -> CGSize? { |
| guard let tv = nsView.documentView as? ComposerTextView, |
| let container = tv.textContainer, let layout = tv.layoutManager else { return nil } |
| - var width = proposal.width ?? tv.bounds.width |
| - if !width.isFinite || width <= 0 { width = max(tv.bounds.width, 100) } |
| - if tv.frame.width != width { tv.setFrameSize(NSSize(width: width, height: tv.frame.height)) } |
| + // A PURE measurement — never mutate the frame from a probe: SwiftUI |
| + // probes with zero/unspecified widths, and resizing the text view to |
| + // match forced a bogus narrow wrap whose frame change re-entered |
| + // layout through the observer (the mid-text jitter bug). |
| layout.ensureLayout(for: container) |
| let line = layout.defaultLineHeight(for: tv.baseFont) |
| let used = layout.usedRect(for: container).height |
| let h = ceil(min(max(line, used), ceil(line * 9))) |
| - if let w = proposal.width, w.isFinite, w > 0 { return CGSize(width: w, height: h) } |
| + var width = proposal.width ?? tv.bounds.width |
| + if !width.isFinite || width <= 0 { width = max(tv.bounds.width, 100) } |
| return CGSize(width: width, height: h) |
| } |
| final class Coordinator: NSObject, NSTextViewDelegate { |
| var parent: MacComposerField |
| var frameObserver: NSObjectProtocol? |
| + var lastMeasuredWidth: CGFloat = 0 |
| init(_ parent: MacComposerField) { self.parent = parent } |
| // Grow with the text up to nine lines (the old field's lineLimit(1...9)), |
| @@ -171,6 +178,7 @@ struct MacComposerField: NSViewRepresentable { |
| // (updateNSView, frame observer) stay deferred. |
| func remeasure(_ tv: ComposerTextView, sync: Bool = false) { |
| guard let container = tv.textContainer, let layout = tv.layoutManager else { return } |
| + lastMeasuredWidth = tv.frame.width |
| layout.ensureLayout(for: container) |
| let line = layout.defaultLineHeight(for: tv.baseFont) |
| let used = layout.usedRect(for: container).height |