diff --git a/README.md b/README.md index 8f80dced..41965a98 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ The installer drops the binary in `/usr/local/bin/parrot`. Builds are unsigned f 1. **Run it.** Either `parrot install --launch-at-login` (daemonized, runs forever, lives in the menu bar), or `parrot` in any terminal tab. 2. **Click into the text field you want to dictate into** — Messages, the address bar, a Slack thread, anywhere a cursor blinks. 3. **Hold the `fn` key, speak, release.** A small pill appears at the bottom of the screen while the mic is hot. -4. **The transcript types itself in at the cursor** when you release. Usually within 200-300ms. +4. **The transcript types itself in at the cursor** when you release. Usually within 200-300ms. If no editable text field is focused, a popover keeps the transcript available to copy. That's it. There is no record button, no stop button, no "send" — `fn` is the whole interface. diff --git a/Sources/parrot/Input/FocusedTextTarget.swift b/Sources/parrot/Input/FocusedTextTarget.swift new file mode 100644 index 00000000..ee8571ba --- /dev/null +++ b/Sources/parrot/Input/FocusedTextTarget.swift @@ -0,0 +1,176 @@ +import ApplicationServices +import Foundation + +enum FocusedTextTarget { + static var isEditable: Bool { + let system = AXUIElementCreateSystemWide() + var focusedValue: CFTypeRef? + guard AXUIElementCopyAttributeValue( + system, + kAXFocusedUIElementAttribute as CFString, + &focusedValue + ) == .success, let focusedValue else { + return false + } + + let focused = focusedValue as! AXUIElement + if isSecureTextElement(focused) { + return false + } + if isEditableElement(focused) || hasEditableAncestor(focused) { + return true + } + var remainingNodes = 96 + return containsEditableDescendant( + focused, + remainingDepth: 4, + remainingNodes: &remainingNodes + ) + } + + private static func isEditableElement(_ element: AXUIElement) -> Bool { + let role = stringAttribute(kAXRoleAttribute, of: element) + let subrole = stringAttribute(kAXSubroleAttribute, of: element) + + if subrole == kAXSecureTextFieldSubrole as String { + return false + } + + let editableRoles = [ + kAXTextAreaRole as String, + kAXTextFieldRole as String, + kAXComboBoxRole as String, + ] + if let role, editableRoles.contains(role) + || subrole == kAXSearchFieldSubrole as String { + return true + } + + if isSettable(kAXSelectedTextAttribute, on: element) + || isSettable(kAXSelectedTextRangeAttribute, on: element) { + return true + } + + let roleDescription = stringAttribute(kAXRoleDescriptionAttribute, of: element)? + .lowercased() + return roleDescription?.contains("text") == true + && isSettable(kAXValueAttribute, on: element) + } + + private static func isSecureTextElement(_ element: AXUIElement) -> Bool { + stringAttribute(kAXSubroleAttribute, of: element) + == kAXSecureTextFieldSubrole as String + } + + private static func hasEditableAncestor(_ element: AXUIElement) -> Bool { + var current = element + for _ in 0..<6 { + guard let parent = elementAttribute(kAXParentAttribute, of: current) else { + return false + } + if isSecureTextElement(parent) { + return false + } + if isEditableElement(parent) { + return true + } + current = parent + } + return false + } + + private static func containsEditableDescendant( + _ element: AXUIElement, + remainingDepth: Int, + remainingNodes: inout Int + ) -> Bool { + guard remainingDepth > 0, remainingNodes > 0 else { return false } + + var childrenValue: CFTypeRef? + guard AXUIElementCopyAttributeValue( + element, + kAXChildrenAttribute as CFString, + &childrenValue + ) == .success, let children = childrenValue as? [AXUIElement] else { + return false + } + + for child in children { + guard remainingNodes > 0 else { return false } + remainingNodes -= 1 + if isSecureTextElement(child) { + continue + } + if boolAttribute(kAXFocusedAttribute, of: child) == true + && isEditableElement(child) + || containsEditableDescendant( + child, + remainingDepth: remainingDepth - 1, + remainingNodes: &remainingNodes + ) { + return true + } + } + return false + } + + private static func boolAttribute( + _ attribute: String, + of element: AXUIElement + ) -> Bool? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue( + element, + attribute as CFString, + &value + ) == .success else { + return nil + } + return value as? Bool + } + + private static func isSettable( + _ attribute: String, + on element: AXUIElement + ) -> Bool { + var settable = DarwinBoolean(false) + return AXUIElementIsAttributeSettable( + element, + attribute as CFString, + &settable + ) == .success && settable.boolValue + } + + private static func elementAttribute( + _ attribute: String, + of element: AXUIElement + ) -> AXUIElement? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue( + element, + attribute as CFString, + &value + ) == .success, let value else { + return nil + } + guard CFGetTypeID(value) == AXUIElementGetTypeID() else { + return nil + } + return (value as! AXUIElement) + } + + private static func stringAttribute( + _ attribute: String, + of element: AXUIElement + ) -> String? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue( + element, + attribute as CFString, + &value + ) == .success else { + return nil + } + return value as? String + } +} diff --git a/Sources/parrot/Parrot.swift b/Sources/parrot/Parrot.swift index 05a69ebe..384234dd 100644 --- a/Sources/parrot/Parrot.swift +++ b/Sources/parrot/Parrot.swift @@ -89,6 +89,7 @@ struct Run: ParsableCommand { capture.onLevel = { level in overlay.pushLevel(level) } } let menuBar = MainActor.assumeIsolated { MenuBarController(modelID: chosenModel.id) } + let fallbackPopover = MainActor.assumeIsolated { TranscriptFallbackPopover() } do { try monitor.start { event in @@ -98,6 +99,7 @@ struct Run: ParsableCommand { try capture.start() FileHandle.standardError.write(Data("● recording\n".utf8)) MainActor.assumeIsolated { + fallbackPopover.hide() overlay?.show(.recording) menuBar.setRecording(true) } @@ -140,9 +142,14 @@ struct Run: ParsableCommand { String(format: "→ %.2fs · %@\n", elapsed, text).utf8 )) await MainActor.run { - TextInjector.inject(text) overlay?.hide() menuBar.setRecording(false) + guard !text.isEmpty else { return } + if FocusedTextTarget.isEditable { + TextInjector.inject(text) + } else { + fallbackPopover.show(text) + } } } catch { FileHandle.standardError.write(Data("transcription failed: \(error)\n".utf8)) diff --git a/Sources/parrot/UI/TranscriptFallbackPopover.swift b/Sources/parrot/UI/TranscriptFallbackPopover.swift new file mode 100644 index 00000000..bb557ad4 --- /dev/null +++ b/Sources/parrot/UI/TranscriptFallbackPopover.swift @@ -0,0 +1,147 @@ +import AppKit +import SwiftUI + +@MainActor +final class TranscriptFallbackPopover { + private var panel: NSPanel? + + func show(_ transcript: String) { + hide() + let panel = makePanel(transcript: transcript) + self.panel = panel + positionAtBottomCenter(panel) + panel.orderFrontRegardless() + } + + func hide() { + panel?.orderOut(nil) + panel = nil + } + + private func makePanel(transcript: String) -> NSPanel { + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 148), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.isFloatingPanel = true + panel.becomesKeyOnlyIfNeeded = true + panel.level = .statusBar + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.ignoresMouseEvents = false + panel.collectionBehavior = [ + .canJoinAllSpaces, + .stationary, + .ignoresCycle, + .fullScreenAuxiliary, + ] + panel.hidesOnDeactivate = false + + let host = NSHostingView( + rootView: TranscriptFallbackView( + transcript: transcript, + onCopy: { [weak self] in + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(transcript, forType: .string) + self?.hide() + }, + onDismiss: { [weak self] in + self?.hide() + } + ) + ) + host.frame = panel.contentView?.bounds ?? .zero + host.autoresizingMask = [.width, .height] + panel.contentView = host + return panel + } + + private func positionAtBottomCenter(_ panel: NSPanel) { + guard let screen = NSScreen.main else { return } + let visible = screen.visibleFrame + panel.setFrameOrigin( + NSPoint( + x: visible.midX - panel.frame.width / 2, + y: visible.minY + 32 + ) + ) + } +} + +private enum TranscriptFallbackStyle { + static let background = Color(red: 16/255, green: 18/255, blue: 18/255) + static let accent = Color(red: 181/255, green: 209/255, blue: 255/255) +} + +private struct TranscriptFallbackView: View { + let transcript: String + let onCopy: () -> Void + let onDismiss: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Image(systemName: "text.cursor") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(TranscriptFallbackStyle.accent) + + Text("No text field selected") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + + Spacer() + + Button(action: onDismiss) { + Image(systemName: "xmark") + .font(.system(size: 11, weight: .semibold)) + } + .buttonStyle(.plain) + .foregroundStyle(.white.opacity(0.65)) + .help("Dismiss") + } + + Text(transcript) + .font(.system(size: 13)) + .foregroundStyle(.white) + .lineLimit(3) + .frame(maxWidth: .infinity, minHeight: 34, alignment: .leading) + .textSelection(.enabled) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + Color.white.opacity(0.08), + in: RoundedRectangle(cornerRadius: 10) + ) + + HStack { + Text("Copy it, then select a text field.") + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.65)) + + Spacer() + + Button(action: onCopy) { + Label("Copy", systemImage: "doc.on.doc") + .font(.system(size: 12, weight: .semibold)) + } + .buttonStyle(.plain) + .foregroundStyle(TranscriptFallbackStyle.background) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + TranscriptFallbackStyle.accent, + in: Capsule() + ) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 18) + .fill(TranscriptFallbackStyle.background) + ) + } +}