eleven / messenger Public

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>
Paul Campbell 6b208765ad7422036b4cce0d76b865a928cd483e parent 3a73297
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