Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
176 changes: 176 additions & 0 deletions Sources/parrot/Input/FocusedTextTarget.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
9 changes: 8 additions & 1 deletion Sources/parrot/Parrot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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))
Expand Down
147 changes: 147 additions & 0 deletions Sources/parrot/UI/TranscriptFallbackPopover.swift
Original file line number Diff line number Diff line change
@@ -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)
)
}
}