Fix the composer scroller flash, edit focus, and Shift+Return

- The composer only enables its scroller once the text is taller than the
  seven-line max, so it no longer flashes on every growth step.
- The composer re-asserts first responder only when canStealFocus is true;
  it is off while a modal field or the sync overlay has the keyboard, so
  typing in the edit dialog, search and sync fields no longer bounces back.
- The edit dialog is a ComposerView now: Return saves, Shift+Return inserts
  a newline, Esc cancels, and paste uses the same path as the composer.
- Shift+Return never actually worked: returning false from doCommandBy does
  not fall through to the default newline insertion, so the event died. The
  newline is inserted explicitly and the Shift state comes from the keyDown.
This commit is contained in:
2026-10-23 22:54:04 +08:00
parent d665d493af
commit a75d6fdf41
3 changed files with 74 additions and 17 deletions
+31 -6
View File
@@ -16,6 +16,10 @@ struct ComposerView: NSViewRepresentable {
@Binding var isFocused: Bool
@Binding var height: CGFloat
var placeholder: String
/// When false, the composer never makes itself first responder. The panel
/// turns this off while a modal field (search, edit, new workspace) or the
/// sync overlay is up — otherwise every render stole focus back here.
var canStealFocus: Bool = true
var onSubmit: () -> Void
var onEscape: (() -> Void)? = nil
@@ -34,7 +38,10 @@ struct ComposerView: NSViewRepresentable {
let scrollView = NSScrollView()
scrollView.drawsBackground = false
scrollView.borderType = .noBorder
scrollView.hasVerticalScroller = true
// The scroller starts disabled and is only switched on once the text
// is taller than the max height. With it always on, every growth step
// briefly overflows and the scroller flashes for one frame.
scrollView.hasVerticalScroller = false
scrollView.autohidesScrollers = true
scrollView.horizontalScrollElasticity = .none
@@ -70,7 +77,7 @@ struct ComposerView: NSViewRepresentable {
if textView.placeholderText != placeholder {
textView.placeholderText = placeholder
}
if isFocused, scrollView.window?.firstResponder != textView {
if isFocused, canStealFocus, scrollView.window?.firstResponder != textView {
scrollView.window?.makeFirstResponder(textView)
}
}
@@ -81,7 +88,9 @@ struct ComposerView: NSViewRepresentable {
layoutManager.ensureLayout(for: textContainer)
let content = layoutManager.usedRect(for: textContainer).height + 2 * textView.textContainerInset.height
let line = Self.oneLineHeight
let clamped = max(line, min(content, line * maxLines + 1))
let maxHeight = line * maxLines + 1
textView.enclosingScrollView?.hasVerticalScroller = content > maxHeight
let clamped = max(line, min(content, maxHeight))
if abs(height - clamped) > 0.5 {
// Defer so the binding is not written from inside a layout pass.
DispatchQueue.main.async { self.height = clamped }
@@ -103,7 +112,7 @@ struct ComposerView: NSViewRepresentable {
) { [weak self, weak textView] note in
guard let self, let textView,
(note.object as? NSWindow) === textView.window,
self.parent.isFocused else { return }
self.parent.isFocused, self.parent.canStealFocus else { return }
textView.window?.makeFirstResponder(textView)
}
}
@@ -131,8 +140,14 @@ struct ComposerView: NSViewRepresentable {
return true
}
guard commandSelector == #selector(NSResponder.insertNewline(_:)) else { return false }
if NSApp.currentEvent?.modifierFlags.contains(.shift) == true {
return false
// NSApp.currentEvent is not reliably set when the command fires,
// so the Shift state comes from the keyDown currently being handled.
// And returning false here does NOT fall through to the default
// newline insertion once a delegate implements doCommandBy — the
// event just dies — so insert the newline explicitly.
if (textView as? ComposerTextView)?.keyDownModifiers.contains(.shift) == true {
textView.insertNewline(nil)
return true
}
parent.onSubmit()
return true
@@ -145,6 +160,16 @@ private final class ComposerTextView: NSTextView {
didSet { needsDisplay = true }
}
/// Modifier flags of the keyDown currently being handled. `doCommandBy`
/// runs inside that call tree and cannot rely on `NSApp.currentEvent`.
var keyDownModifiers: NSEvent.ModifierFlags = []
override func keyDown(with event: NSEvent) {
keyDownModifiers = event.modifierFlags
super.keyDown(with: event)
keyDownModifiers = []
}
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
guard string.isEmpty, !placeholderText.isEmpty else { return }