diff --git a/AGENTS.md b/AGENTS.md index c20469d..52328f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,8 @@ clicking an item copies it back to the clipboard. `NSStatusItem Preferred Position Prompt`). Left click toggles the panel, right click shows the menu (accessibility state, launch at login, open data folder, quit). Contains the single-instance guard and `LoginItem` - (`SMAppService`). Also sets `NSApp.mainMenu` with Hide Panel (Cmd+W) and Quit + (`SMAppService`). The sync panel also carries a Launch at Login toggle (the + app's only settings surface). Also sets `NSApp.mainMenu` with Hide Panel (Cmd+W) and Quit (Cmd+Q) — an accessory app shows no menu bar, but the main menu still services key equivalents, which is the only way to quit from the keyboard while the panel has focus. `applicationShouldHandleReopen` shows the panel so @@ -37,6 +38,13 @@ clicking an item copies it back to the clipboard. light mode keeps the system window colour. `Theme.background` is also handed to the `NSPanel` so the strip under the transparent titlebar matches, and the row colour stays slightly lighter than black so cards remain visible. +- `Sources/Prompt/ComposerView.swift` — the chat-style composer + (`NSViewRepresentable` around an `NSTextView`): wraps long prompts, grows to + fit up to seven lines, then scrolls. Return submits, Shift+Return inserts a + 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. - `Sources/Prompt/PanelView.swift` — the panel UI. Header (workspace switcher, search, add photos), item list, composer. There is no in-header close button: the window's own close button hides the panel. Composer `# Name` creates/ diff --git a/README.md b/README.md index 77e0357..5e8727f 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ to your work. Select what you need and send it back to your clipboard. image. - **Capture from anywhere** — select text in any app and tap **Left Shift twice**. - **Photos** — paste (Cmd+V), drag & drop, or add via the photo button. +- **A composer that grows** — long prompts wrap and the input grows up to seven + lines, then scrolls. Return sends, Shift+Return inserts a newline. +- **Launch at Login** — toggle in the sync panel (Cmd+,) or the menu bar icon's + right-click menu, to start Prompt on boot. - **Workspaces** — type `# Name` in the composer, or use "New Workspace" in the Cmd+K switcher (which opens a name modal); Cmd+K switches, and the up/down arrows in the switcher reorder them. (The code and the saved JSON @@ -33,6 +37,7 @@ to your work. Select what you need and send it back to your clipboard. | Switch/create workspace | Cmd+K | | Search | Cmd+F | | Focus composer | Cmd+N | +| New line in composer | Shift+Return | | Sync settings | Cmd+, | | Copy selection as numbered list | Shift+Cmd+C | | Copy selection as plain lines | Option+Cmd+C | diff --git a/Sources/Prompt/ComposerView.swift b/Sources/Prompt/ComposerView.swift new file mode 100644 index 0000000..fb81e09 --- /dev/null +++ b/Sources/Prompt/ComposerView.swift @@ -0,0 +1,157 @@ +import AppKit +import SwiftUI + +/// A chat-style composer backed by AppKit: wraps long prompts, grows to fit up +/// to seven lines, then scrolls. Return submits; Shift+Return inserts a +/// newline. A plain SwiftUI `TextField(axis: .vertical)` cannot keep Return as +/// "send", which is why this is not one. +/// +/// Height is driven by the caller: the representable measures the text and +/// writes the clamped height into `height`, which the caller applies as a +/// `.frame(height:)`. Driving the frame is what actually resizes a +/// representable — `intrinsicContentSize` is ignored once SwiftUI offers a +/// bigger size. +struct ComposerView: NSViewRepresentable { + @Binding var text: String + @Binding var isFocused: Bool + @Binding var height: CGFloat + var placeholder: String + var onSubmit: () -> Void + var onEscape: (() -> Void)? = nil + + private let maxLines: CGFloat = 7 + + /// The one-line height, so the composer starts at exactly one line instead + /// of snapping to its real height on the first keystroke. + static var oneLineHeight: CGFloat { + let font = NSFont.systemFont(ofSize: NSFont.systemFontSize) + return font.ascender - font.descender + font.leading + 10 // 2 x 5 inset + } + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSScrollView() + scrollView.drawsBackground = false + scrollView.borderType = .noBorder + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.horizontalScrollElasticity = .none + + let textView = ComposerTextView() + textView.delegate = context.coordinator + textView.placeholderText = placeholder + textView.isRichText = false + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.autoresizingMask = [.width] + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainerInset = NSSize(width: 0, height: 5) + textView.font = .systemFont(ofSize: NSFont.systemFontSize) + textView.textColor = .labelColor + textView.drawsBackground = false + // No containerSize: with widthTracksTextView the container follows the + // view's width, which is what makes long lines wrap. + + scrollView.documentView = textView + textView.frame = NSRect(origin: .zero, size: scrollView.contentSize) + context.coordinator.watchWindow(of: textView) + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? ComposerTextView else { return } + context.coordinator.parent = self + if textView.string != text { + textView.string = text + updateHeight(textView) + } + textView.placeholderText = placeholder + if isFocused, scrollView.window?.firstResponder != textView { + scrollView.window?.makeFirstResponder(textView) + } + } + + fileprivate func updateHeight(_ textView: ComposerTextView) { + guard let layoutManager = textView.layoutManager, + let textContainer = textView.textContainer else { return } + 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)) + if abs(height - clamped) > 0.5 { + // Defer so the binding is not written from inside a layout pass. + DispatchQueue.main.async { self.height = clamped } + } + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var parent: ComposerView + private var windowObserver: Any? + + init(_ parent: ComposerView) { self.parent = parent } + + /// Re-assert focus every time the panel becomes key again. The focus + /// binding stays `true` while hidden, so without this a re-shown panel + /// would not refocus the composer. + fileprivate func watchWindow(of textView: ComposerTextView) { + windowObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, object: nil, queue: .main + ) { [weak self, weak textView] note in + guard let self, let textView, + (note.object as? NSWindow) === textView.window, + self.parent.isFocused else { return } + textView.window?.makeFirstResponder(textView) + } + } + + deinit { + if let windowObserver { + NotificationCenter.default.removeObserver(windowObserver) + } + } + + func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? ComposerTextView else { return } + if parent.text != textView.string { + parent.text = textView.string + } + parent.updateHeight(textView) + } + + // Return sends, Shift+Return inserts a newline. Esc is forwarded so the + // panel can clear its selection or hide, which a TextField used to do + // via onExitCommand. + func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if commandSelector == #selector(NSResponder.cancelOperation(_:)) { + parent.onEscape?() + return true + } + guard commandSelector == #selector(NSResponder.insertNewline(_:)) else { return false } + if NSApp.currentEvent?.modifierFlags.contains(.shift) == true { + return false + } + parent.onSubmit() + return true + } + } +} + +private final class ComposerTextView: NSTextView { + var placeholderText: String = "" { + didSet { needsDisplay = true } + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + guard string.isEmpty, !placeholderText.isEmpty else { return } + let attributes: [NSAttributedString.Key: Any] = [ + .font: font as Any, + .foregroundColor: NSColor.placeholderTextColor, + ] + let inset = textContainerInset + let origin = NSPoint(x: inset.width + (textContainer?.lineFragmentPadding ?? 0), y: inset.height) + placeholderText.draw(at: origin, withAttributes: attributes) + } +} diff --git a/Sources/Prompt/PanelView.swift b/Sources/Prompt/PanelView.swift index 37618f3..fa8fb42 100644 --- a/Sources/Prompt/PanelView.swift +++ b/Sources/Prompt/PanelView.swift @@ -7,6 +7,8 @@ struct PanelView: View { @ObservedObject private var sync = SyncService.shared @State private var composer = "" + @State private var composerFocused = false + @State private var composerHeight: CGFloat = ComposerView.oneLineHeight @State private var search = "" @State private var showSearch = false @State private var showSectionSwitcher = false @@ -19,7 +21,7 @@ struct PanelView: View { @State private var copiedID: UUID? @State private var accessibilityGranted = AXIsProcessTrusted() - private enum Field: Hashable { case composer, search, newWorkspace } + private enum Field: Hashable { case search, newWorkspace } @FocusState private var focusedField: Field? var body: some View { @@ -47,10 +49,10 @@ struct PanelView: View { .overlay(newWorkspaceModal) .onDrop(of: [UTType.image.identifier, UTType.fileURL.identifier], isTargeted: nil, perform: handleDrop) .onReceive(NotificationCenter.default.publisher(for: .promptPanelDidShow)) { _ in - focusedField = .composer + composerFocused = true accessibilityGranted = AXIsProcessTrusted() } - .onAppear { focusedField = .composer } + .onAppear { composerFocused = true } } // MARK: - Header @@ -120,7 +122,7 @@ struct PanelView: View { .onExitCommand { search = "" showSearch = false - focusedField = .composer + composerFocused = true } if !search.isEmpty { Button { search = "" } label: { Image(systemName: "xmark.circle.fill") } @@ -473,6 +475,12 @@ struct PanelView: View { .font(.caption) .foregroundStyle(.secondary) } + Divider() + Toggle("Launch at Login", isOn: Binding( + get: { LoginItem.isEnabled }, + set: { _ in _ = LoginItem.toggle() } + )) + .font(.callout) if let error = sync.lastError { Text(error) .font(.caption) @@ -548,7 +556,7 @@ struct PanelView: View { private func dismissNewWorkspace() { showNewWorkspace = false newWorkspaceName = "" - focusedField = .composer + composerFocused = true } // MARK: - Selection @@ -615,28 +623,32 @@ struct PanelView: View { // MARK: - Composer private var composerField: some View { - HStack(spacing: 8) { - TextField("Add a note or prompt (\(store.activeSection.name))", text: $composer) - .textFieldStyle(.plain) - .focused($focusedField, equals: .composer) - .onSubmit(submit) - .onExitCommand { - // Esc clears a selection first, and only hides the panel - // once there is nothing selected. + HStack(alignment: .bottom, spacing: 8) { + ComposerView( + text: $composer, + isFocused: $composerFocused, + height: $composerHeight, + placeholder: "Add a note or prompt (\(store.activeSection.name))", + onSubmit: submit, + onEscape: { if selection.isEmpty { PanelController.shared.hide() } else { clearSelection() } } + ) + .frame(maxWidth: .infinity) + .frame(height: composerHeight) if !composer.isEmpty { Button(action: submit) { Image(systemName: "arrow.up.circle.fill") } .buttonStyle(.plain) .foregroundStyle(Color.accentColor) + .help("Add (Return; Shift+Return for a new line)") } } .padding(.horizontal, 12) - .padding(.vertical, 10) + .padding(.vertical, 8) } private func submit() { @@ -663,7 +675,7 @@ struct PanelView: View { withAnimation(.easeInOut(duration: 0.15)) { showSectionSwitcher.toggle() } } .keyboardShortcut("k", modifiers: .command) - Button("") { focusedField = .composer } + Button("") { composerFocused = true } .keyboardShortcut("n", modifiers: .command) Button("") { withAnimation(.easeInOut(duration: 0.15)) { showSync.toggle() } @@ -739,7 +751,7 @@ struct PanelView: View { focusedField = .search } else { search = "" - focusedField = .composer + composerFocused = true } }