diff --git a/AGENTS.md b/AGENTS.md index 73714ec..45330f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,12 @@ clicking an item copies it back to the clipboard. newline, Esc forwards to the panel (clear selection, else hide). Height is measured from the text and pushed into a `height` binding the panel applies as a frame — a representable cannot resize itself through - `intrinsicContentSize` once SwiftUI offers a larger size. + `intrinsicContentSize` once SwiftUI offers a larger size. The vertical + scroller is disabled until the text is taller than the max height, so it + never flashes during growth. The composer re-asserts first responder on + updates only while `canStealFocus` is true; the panel turns it off whenever + a modal field (search, edit, new workspace) or the sync overlay owns the + keyboard — otherwise every render stole focus back mid-edit. - `Sources/Prompt/PanelView.swift` — the panel UI. Header (workspace switcher, search, add photos), a segmented Workspace/All tab (All lists every prompt labeled with its workspace), item list, in-panel edit modal, composer. @@ -221,6 +226,16 @@ brew upgrade --cask prompt firing. The tap is re-enabled on `tapDisabledByTimeout`. - `Store` is `@MainActor`; anything reaching it from monitors/callbacks must go through `Task { @MainActor in }`. +- **The composer steals focus via `updateNSView`, so gate it.** It used to + re-assert first responder on every render, which yanked focus out of the + edit dialog (and search/sync fields) on every keystroke. The gate is + `canStealFocus`; any new modal text field must be included in it. +- **`doCommandBy` has no fall-through once the delegate implements it.** + Returning false for `insertNewline` on Shift+Return killed the event instead + of inserting a newline, so Shift+Return never actually worked. The composer + now inserts it explicitly, and reads the Shift state from the `keyDown` + currently being handled because `NSApp.currentEvent` is not reliably + populated when the command fires. - `captureSelection()` blocks up to 0.5s polling the pasteboard — keep it off the main thread (the hotkey handler already dispatches to a global queue). - The double-Shift tap needs Accessibility; without it the event tap silently diff --git a/Sources/Prompt/ComposerView.swift b/Sources/Prompt/ComposerView.swift index f5f43ad..5d744ac 100644 --- a/Sources/Prompt/ComposerView.swift +++ b/Sources/Prompt/ComposerView.swift @@ -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 } diff --git a/Sources/Prompt/PanelView.swift b/Sources/Prompt/PanelView.swift index ea316a0..8ec79f0 100644 --- a/Sources/Prompt/PanelView.swift +++ b/Sources/Prompt/PanelView.swift @@ -19,13 +19,15 @@ struct PanelView: View { @State private var lastSelectedID: UUID? @State private var editingItem: PromptItem? @State private var editText = "" + @State private var editFocused = false + @State private var editHeight: CGFloat = ComposerView.oneLineHeight @State private var copiedID: UUID? @State private var accessibilityGranted = AXIsProcessTrusted() private enum Tab { case workspace, all } @State private var tab: Tab = .workspace - private enum Field: Hashable { case search, newWorkspace, editor } + private enum Field: Hashable { case search, newWorkspace } @FocusState private var focusedField: Field? var body: some View { @@ -141,6 +143,7 @@ struct PanelView: View { .onExitCommand { search = "" showSearch = false + focusedField = nil composerFocused = true } if !search.isEmpty { @@ -322,8 +325,9 @@ struct PanelView: View { if case .text(let text) = item.content, !multiple { Button("Edit…") { editText = text + editHeight = ComposerView.oneLineHeight editingItem = item - focusedField = .editor + editFocused = true } } if case .image(let filename) = item.content, !multiple { @@ -542,6 +546,9 @@ struct PanelView: View { // MARK: - Edit + /// The editor is a ComposerView too, so it behaves exactly like the main + /// input: Return saves, Shift+Return inserts a newline, Esc cancels, and + /// pasting goes through the same working path. @ViewBuilder private var editModal: some View { if let item = editingItem { @@ -551,14 +558,18 @@ struct PanelView: View { VStack(alignment: .leading, spacing: 12) { Text("Edit") .font(.headline) - TextEditor(text: $editText) - .font(.callout) - .scrollContentBackground(.hidden) - .frame(minHeight: 120, maxHeight: 280) - .padding(8) - .background(Theme.rowSwiftUI) - .cornerRadius(8) - .focused($focusedField, equals: .editor) + ComposerView( + text: $editText, + isFocused: $editFocused, + height: $editHeight, + placeholder: "", + onSubmit: { saveEdit(item) }, + onEscape: { dismissEditor() } + ) + .frame(height: editHeight) + .padding(8) + .background(Theme.rowSwiftUI) + .cornerRadius(8) HStack { Spacer() Button("Cancel") { dismissEditor() } @@ -593,6 +604,7 @@ struct PanelView: View { private func dismissEditor() { editingItem = nil editText = "" + editFocused = false composerFocused = true } @@ -640,6 +652,7 @@ struct PanelView: View { private func dismissNewWorkspace() { showNewWorkspace = false newWorkspaceName = "" + focusedField = nil composerFocused = true } @@ -713,6 +726,9 @@ struct PanelView: View { isFocused: $composerFocused, height: $composerHeight, placeholder: "Add a note or prompt (\(store.activeSection.name))", + // A modal field (search, edit, new workspace) or the sync + // overlay owns the keyboard; don't steal focus back mid-edit. + canStealFocus: focusedField == nil && !showSync && editingItem == nil, onSubmit: submit, onEscape: { if selection.isEmpty { @@ -835,6 +851,7 @@ struct PanelView: View { focusedField = .search } else { search = "" + focusedField = nil composerFocused = true } }